Add OpenAPI documentation

This commit is contained in:
a 2022-12-10 10:34:05 +00:00
parent b5d038f0fe
commit 8cd70fca10
5 changed files with 210 additions and 52 deletions

View file

@ -57,3 +57,7 @@ uvicorn webfinger:app --port 7033
### Post-run ### Post-run
proxy `/.well-known/webfinger` to localhost:7033 proxy `/.well-known/webfinger` to localhost:7033
## Development
`pdm run uvicorn webfinger:app --reload`

View file

@ -1,6 +1,6 @@
[project] [project]
name = "webfinger" name = "webfinger"
version = "1" version = "2"
description = "Simple WebFinger server that returns static resources. Written with Python and FastAPI." description = "Simple WebFinger server that returns static resources. Written with Python and FastAPI."
authors = [ authors = [
{name = "a", email = "a@trwnh.com"}, {name = "a", email = "a@trwnh.com"},

View file

@ -1,62 +1,34 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import PlainTextResponse, JSONResponse import webfinger.lookup
from fastapi.params import Query
import json
from pathlib import Path
from os import environ as env
app = FastAPI() app = FastAPI(
title = "webfinger",
@app.get("/") version = "2"
def lookup(
resource: str = Query(None),
rel: list[str] = Query(None)
):
"""
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 app.include_router(webfinger.lookup.router)
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 def cleanup_openapi(app: FastAPI) -> None:
with open(filename, "r") as file: """Cleanup inconsistencies in the OpenAPI documentation"""
data: dict = json.loads(file.read()) openapi_schema = app.openapi()
# filter links if rel is provided # Remove 422 Validation Error from Webfinger lookup (RFC7033 uses 400 Bad Request)
links = [link for link in data.get('links', [])] webfinger_responses: dict = openapi_schema["paths"]["/.well-known/webfinger"]["get"]["responses"]
if rel: webfinger_responses.pop("422")
links = [link for link in links if link.get('rel', '') in rel]
# construct json response # Remove 422 Validation Error from schemas
content = { schemas: dict = openapi_schema["components"]["schemas"]
"subject": resource, schemas.pop("ValidationError")
"aliases": [alias for alias in data.get('aliases')], schemas.pop("HTTPValidationError")
"properties": data.get('properties'),
"links": links, # Mark resource param as required
} lookup_params: list[dict] = openapi_schema["paths"]["/.well-known/webfinger"]["get"]["parameters"]
content = {k: v for k, v in content.items() if v} # remove null values resource_param = [param for param in lookup_params if param["name"] == "resource"][0]
headers = { resource_param["required"] = True
"Content-Type": "application/jrd+json"
}
return JSONResponse(content=content, headers=headers) cleanup_openapi(app)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

19
webfinger/io.py Normal file
View file

@ -0,0 +1,19 @@
import json
from os import environ as env
from pathlib import Path
def get_document(resource: str) -> dict | None:
# Get a filename for the resource document.
dir = env.get("RESOURCE_DIR") or "resource"
filename = f"{dir}/{resource}.json"
# Check that the file exists.
path = Path(filename)
if not path:
return None
# Open the file and load the JSON as a dictionary.
with open(filename, "r") as file:
data: dict = json.loads(file.read())
return data

163
webfinger/lookup.py Normal file
View file

@ -0,0 +1,163 @@
from fastapi import APIRouter, Response
from fastapi.responses import PlainTextResponse, JSONResponse
from fastapi.params import Query
from pydantic import BaseModel
from webfinger.io import get_document
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"
}
]
}
'''
NO_RESOURCE_HINT = "Query ?resource={resource} to obtain information about that resource."
## 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": NO_RESOURCE_HINT
}
},
},
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
@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},
)
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.
"""
# If no resource is given, then show a basic hint.
if not resource:
return PlainTextResponse(
content=NO_RESOURCE_HINT,
status_code=400,
)
# Otherwise, try to read the resource document.
data = get_document(resource)
# 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,
)
# 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 and return it.
content = {
"subject": resource,
"aliases": aliases,
"properties": 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)