From 49eaa69188abdf9fe8e9a033f65a79b22b9b2d3b Mon Sep 17 00:00:00 2001 From: a Date: Sat, 10 Dec 2022 03:04:28 -0600 Subject: [PATCH 1/3] WIP: Add OpenAPI documentation --- README.md | 4 + pyproject.toml | 2 +- webfinger/__init__.py | 78 ++++++------------- webfinger/lookup.py | 169 +++++++++++++++++++++++++++++++++++++++++ webfinger/resources.py | 0 5 files changed, 198 insertions(+), 55 deletions(-) create mode 100644 webfinger/lookup.py create mode 100644 webfinger/resources.py diff --git a/README.md b/README.md index 5221343..f17d302 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,7 @@ uvicorn webfinger:app --port 7033 ### Post-run proxy `/.well-known/webfinger` to localhost:7033 + +## Development + +`pdm run uvicorn webfinger:app --reload` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c12e97c..f0e804b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "webfinger" -version = "1" +version = "2" description = "Simple WebFinger server that returns static resources. Written with Python and FastAPI." authors = [ {name = "a", email = "a@trwnh.com"}, diff --git a/webfinger/__init__.py b/webfinger/__init__.py index 5cd5fb5..210d1bd 100644 --- a/webfinger/__init__.py +++ b/webfinger/__init__.py @@ -1,62 +1,32 @@ from fastapi import FastAPI -from fastapi.responses import PlainTextResponse, JSONResponse -from fastapi.params import Query -import json -from pathlib import Path -from os import environ as env +import webfinger.lookup -app = FastAPI() +app = FastAPI( + title = "webfinger" +) +app.include_router(webfinger.lookup.router) -@app.get("/") -def lookup( - resource: str = Query(None), - rel: list[str] = Query(None) - ): - """ - Respond to a WebFinger query. - """ +from fastapi.openapi.utils import generate_operation_id +from fastapi.routing import APIRoute - # Basic info as a hint, if no resource given - if not resource: - return PlainTextResponse( - content="Query ?resource= for more information.", - status_code=400, - ) - - # otherwise, load that resource and return its data - dir = env.get("RESOURCE_DIR") or "resource" - filename = f"{dir}/{resource}.json" - path = Path(filename) +def remove_422s(app: FastAPI) -> None: + openapi_schema = app.openapi() + operation_ids_to_update: set[str] = set() + for route in app.routes: + if not isinstance(route, APIRoute): + continue + methods = route.methods or ["GET"] + if getattr(route.endpoint, "__remove_422__", None): + for method in methods: + operation_ids_to_update.add(generate_operation_id(route=route, method=method)) + paths = openapi_schema["paths"] + for _, operations in paths.items(): + for method, metadata in operations.items(): + operation_id = metadata.get("operationId") + if operation_id in operation_ids_to_update: + metadata["responses"].pop("422", None) - # TODO redirect to upstream webfinger server(s) if resource not found locally - # and if there is an upstream to try - if not path.is_file(): - return PlainTextResponse( - content=f"{resource} not found", - status_code=404, - ) - - # open the local file - with open(filename, "r") as file: - data: dict = json.loads(file.read()) - - # filter links if rel is provided - links = [link for link in data.get('links', [])] - if rel: - links = [link for link in links if link.get('rel', '') in rel] - - # construct json response - content = { - "subject": resource, - "aliases": [alias for alias in data.get('aliases')], - "properties": data.get('properties'), - "links": links, - } - content = {k: v for k, v in content.items() if v} # remove null values - headers = { - "Content-Type": "application/jrd+json" - } - return JSONResponse(content=content, headers=headers) +remove_422s(app) if __name__ == "__main__": import uvicorn diff --git a/webfinger/lookup.py b/webfinger/lookup.py new file mode 100644 index 0000000..7f7e6f3 --- /dev/null +++ b/webfinger/lookup.py @@ -0,0 +1,169 @@ +from fastapi import APIRouter, Response +from fastapi.responses import PlainTextResponse, JSONResponse +from fastapi.params import Query +import json +from pathlib import Path +from os import environ as env +from pydantic import BaseModel + + +router = APIRouter( + prefix="/.well-known/webfinger" +) + + +## String literals + +MEDIA_TYPE = "application/jrd+json" + +EXAMPLE_RESOURCE = r''' +{ + "aliases": ["https://trwnh.com/actors/7057bc10-db1c-4ebe-9e00-22cf04be4e5e", "https://trwnh.com/~a"], + "links": [ + { + "rel": "self", + "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + "href": "https://trwnh.com/actors/7057bc10-db1c-4ebe-9e00-22cf04be4e5e" + }, + { + "rel": "https://webfinger.net/rel/profile-page/", + "type": "text/html", + "href": "https://trwnh.com/~a" + } + ] +} +''' + + +## Pydantic models for OpenAPI schema + +class Link(BaseModel): + rel: str + type: str | None + href: str | None + titles: str | None + properties: str | None + + class Config: + schema_extra = { + "example": '''{ + "rel": "https://webfinger.net/rel/profile-page/", + "type": "text/html", + "href": "https://trwnh.com/~a" + }''' + } + +class Resource(BaseModel): + subject: str + aliases: list[str] | None + links: list[Link] | None + + class Config: + schema_extra = { + "example": EXAMPLE_RESOURCE + } + +class WebfingerResponse(Response): + media_type = MEDIA_TYPE + + +## Example responses for OpenAPI schema + +RESPONSES = { + 400: { + "description": "Bad Request. No resource was provided.", + "content": { + "text/plain": { + "example": "Query ?resource= for more information." + } + }, + }, + 404: { + "description": "Not Found. Could not find a document for the provided resource.", + "content": { + "text/plain": { + "example": "{resource} not found" + } + }, + }, + 200: { + "description": "The resource you requested was found, and has the returned resource document.", + "model": Resource, + "content": { + MEDIA_TYPE: { + "example": EXAMPLE_RESOURCE + } + } + }, +} + + +## The lookup method as described in RFC7033 + +def remove_422(func): + func.__remove_422__ = True + return func + +@router.get( + "", + summary = "Lookup a Webfinger resource", + description = "Query the Webfinger service for a resource URI and obtain its associated resource document", + tags = ["RFC7033"], + response_model = Resource, + response_class = WebfingerResponse, + responses = {**RESPONSES}, + +) +@remove_422 +async def lookup( + resource: str = Query( + None, + description = "The subject whose document you are looking up. If not provided, you will get a 400 Bad Request." + ), + rel: list[str] = Query( + None, + description = "If provided, filter the links array for this rel-value only. This parameter may be included multiple times." + ) + ): + """ + Respond to a WebFinger query. + """ + + # Basic info as a hint, if no resource given + if not resource: + return PlainTextResponse( + content="Query ?resource= for more information.", + status_code=400, + ) + + # otherwise, load that resource and return its data + dir = env.get("RESOURCE_DIR") or "resource" + filename = f"{dir}/{resource}.json" + path = Path(filename) + + # TODO redirect to upstream webfinger server(s) if resource not found locally + # and if there is an upstream to try + if not path.is_file(): + return PlainTextResponse( + content=f"{resource} not found", + status_code=404, + ) + + # open the local file + with open(filename, "r") as file: + data: dict = json.loads(file.read()) + + # filter links if rel is provided + links = [link for link in data.get('links', [])] + if rel: + links = [link for link in links if link.get('rel', '') in rel] + + # construct json response + content = { + "subject": resource, + "aliases": [alias for alias in data.get('aliases')], + "properties": data.get('properties'), + "links": links, + } + content = {k: v for k, v in content.items() if v} # remove null values + return JSONResponse(content=content, media_type=MEDIA_TYPE) \ No newline at end of file diff --git a/webfinger/resources.py b/webfinger/resources.py new file mode 100644 index 0000000..e69de29 -- 2.47.0 From 08019dffe78ea6df633819965d2119865d3662cc Mon Sep 17 00:00:00 2001 From: a Date: Sat, 10 Dec 2022 04:23:08 -0600 Subject: [PATCH 2/3] Cleanup --- webfinger/__init__.py | 41 ++++++++++++++++++------------------ webfinger/io.py | 18 ++++++++++++++++ webfinger/lookup.py | 48 +++++++++++++++++++------------------------ 3 files changed, 60 insertions(+), 47 deletions(-) create mode 100644 webfinger/io.py diff --git a/webfinger/__init__.py b/webfinger/__init__.py index 210d1bd..1c8df31 100644 --- a/webfinger/__init__.py +++ b/webfinger/__init__.py @@ -2,31 +2,32 @@ from fastapi import FastAPI import webfinger.lookup app = FastAPI( - title = "webfinger" + title = "webfinger", + version = "2" ) + app.include_router(webfinger.lookup.router) -from fastapi.openapi.utils import generate_operation_id -from fastapi.routing import APIRoute -def remove_422s(app: FastAPI) -> None: - openapi_schema = app.openapi() - operation_ids_to_update: set[str] = set() - for route in app.routes: - if not isinstance(route, APIRoute): - continue - methods = route.methods or ["GET"] - if getattr(route.endpoint, "__remove_422__", None): - for method in methods: - operation_ids_to_update.add(generate_operation_id(route=route, method=method)) - paths = openapi_schema["paths"] - for _, operations in paths.items(): - for method, metadata in operations.items(): - operation_id = metadata.get("operationId") - if operation_id in operation_ids_to_update: - metadata["responses"].pop("422", None) +def cleanup_openapi(app: FastAPI) -> None: + """Cleanup inconsistencies in the OpenAPI documentation""" + openapi_schema = app.openapi() -remove_422s(app) + # Remove 422 Validation Error from Webfinger lookup (RFC7033 uses 400 Bad Request) + openapi_schema["paths"]["/.well-known/webfinger"]["get"]["responses"].pop("422") + + # Remove 422 Validation Error from schemas + schemas = openapi_schema["components"]["schemas"] + schemas.pop("ValidationError") + schemas.pop("HTTPValidationError") + + # Mark resource param as required + lookup_params = openapi_schema["paths"]["/.well-known/webfinger"]["get"]["parameters"] + resource_param = [param for param in lookup_params if param["name"] == "resource"][0] + resource_param["required"] = True + + +cleanup_openapi(app) if __name__ == "__main__": import uvicorn diff --git a/webfinger/io.py b/webfinger/io.py new file mode 100644 index 0000000..bcdcda8 --- /dev/null +++ b/webfinger/io.py @@ -0,0 +1,18 @@ +import json +from os import environ as env +from pathlib import Path + +def get_document(resource: str) -> dict | None: + # + dir = env.get("RESOURCE_DIR") or "resource" + filename = f"{dir}/{resource}.json" + path = Path(filename) + + if not path: + return None + + # open the local file + with open(filename, "r") as file: + data: dict = json.loads(file.read()) + + return data \ No newline at end of file diff --git a/webfinger/lookup.py b/webfinger/lookup.py index 7f7e6f3..d859c90 100644 --- a/webfinger/lookup.py +++ b/webfinger/lookup.py @@ -1,11 +1,11 @@ from fastapi import APIRouter, Response from fastapi.responses import PlainTextResponse, JSONResponse from fastapi.params import Query -import json -from pathlib import Path -from os import environ as env + from pydantic import BaseModel +from webfinger.io import get_document + router = APIRouter( prefix="/.well-known/webfinger" @@ -34,6 +34,8 @@ EXAMPLE_RESOURCE = r''' } ''' +NO_RESOURCE_HINT = "Query ?resource={resource} to obtain information about that resource." + ## Pydantic models for OpenAPI schema @@ -74,7 +76,7 @@ RESPONSES = { "description": "Bad Request. No resource was provided.", "content": { "text/plain": { - "example": "Query ?resource= for more information." + "example": NO_RESOURCE_HINT } }, }, @@ -98,11 +100,7 @@ RESPONSES = { } -## The lookup method as described in RFC7033 - -def remove_422(func): - func.__remove_422__ = True - return func +## The lookup method as described in RFC7033 @router.get( "", @@ -114,7 +112,6 @@ def remove_422(func): responses = {**RESPONSES}, ) -@remove_422 async def lookup( resource: str = Query( None, @@ -129,40 +126,37 @@ async def lookup( Respond to a WebFinger query. """ - # Basic info as a hint, if no resource given + # If no resource is given, then show a basic hint. if not resource: return PlainTextResponse( - content="Query ?resource= for more information.", + content=NO_RESOURCE_HINT, status_code=400, ) - # otherwise, load that resource and return its data - dir = env.get("RESOURCE_DIR") or "resource" - filename = f"{dir}/{resource}.json" - path = Path(filename) + # Otherwise, try to read the resource document. + data = get_document(resource) - # TODO redirect to upstream webfinger server(s) if resource not found locally - # and if there is an upstream to try - if not path.is_file(): + # If the document could not be read, then it likely didn't exist. + if data is None: return PlainTextResponse( content=f"{resource} not found", status_code=404, ) - # open the local file - with open(filename, "r") as file: - data: dict = json.loads(file.read()) - - # filter links if rel is provided + # Obtain values from the resource document. + aliases = [alias for alias in data.get('aliases', [])] links = [link for link in data.get('links', [])] + properties = data.get('properties', {}) + + # If optional rel is provided, then filter only for links with that rel-type. if rel: links = [link for link in links if link.get('rel', '') in rel] - # construct json response + # Construct JSON response and return it. content = { "subject": resource, - "aliases": [alias for alias in data.get('aliases')], - "properties": data.get('properties'), + "aliases": aliases, + "properties": properties, "links": links, } content = {k: v for k, v in content.items() if v} # remove null values -- 2.47.0 From b38aa24d831b0abefafddc522eec4d1a386b3099 Mon Sep 17 00:00:00 2001 From: a Date: Sat, 10 Dec 2022 04:31:35 -0600 Subject: [PATCH 3/3] remove unused stub file --- webfinger/resources.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 webfinger/resources.py diff --git a/webfinger/resources.py b/webfinger/resources.py deleted file mode 100644 index e69de29..0000000 -- 2.47.0