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