Add OpenAPI documentation #1

Merged
a merged 3 commits from openapi into main 2022-12-10 10:34:05 +00:00
5 changed files with 208 additions and 52 deletions

View file

@ -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`

View file

@ -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"},

View file

@ -1,62 +1,33 @@
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",
version = "2"
)
@app.get("/")
def lookup(
resource: str = Query(None),
rel: list[str] = Query(None)
):
"""
Respond to a WebFinger query.
"""
app.include_router(webfinger.lookup.router)
# 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 cleanup_openapi(app: FastAPI) -> None:
"""Cleanup inconsistencies in the OpenAPI documentation"""
openapi_schema = app.openapi()
# 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,
)
# Remove 422 Validation Error from Webfinger lookup (RFC7033 uses 400 Bad Request)
openapi_schema["paths"]["/.well-known/webfinger"]["get"]["responses"].pop("422")
# open the local file
with open(filename, "r") as file:
data: dict = json.loads(file.read())
# Remove 422 Validation Error from schemas
schemas = openapi_schema["components"]["schemas"]
schemas.pop("ValidationError")
schemas.pop("HTTPValidationError")
# 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]
# 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
# 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)
cleanup_openapi(app)
if __name__ == "__main__":
import uvicorn

18
webfinger/io.py Normal file
View file

@ -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

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)