Support canonical subject

Also support resource URIs with slashes

Misc: Better OpenAPI examples and docstrings
This commit is contained in:
a 2022-12-14 22:19:32 -06:00
parent 8cd70fca10
commit 50d9e4bf6f
4 changed files with 108 additions and 67 deletions

View file

@ -16,4 +16,6 @@ license = {text = "AGPL"}
requires = ["pdm-pep517>=1.0.0"] requires = ["pdm-pep517>=1.0.0"]
build-backend = "pdm.pep517.api" build-backend = "pdm.pep517.api"
[tool.pdm] [tool.pdm.scripts]
dev = "uvicorn webfinger:app --port 7033 --reload"
start = "python -m webfinger"

View file

@ -3,7 +3,7 @@ import webfinger.lookup
app = FastAPI( app = FastAPI(
title = "webfinger", title = "webfinger",
version = "2" version = "1"
) )
app.include_router(webfinger.lookup.router) app.include_router(webfinger.lookup.router)

View file

@ -1,19 +1,44 @@
import json import json
from os import environ as env from os import environ as env
from urllib.parse import quote_plus as url_encode
from pathlib import Path from pathlib import Path
def get_document(resource: str) -> dict | None: def get_jrd(resource: str) -> dict[str, str | list[str] | dict[str,str]]:
"""
Obtain a JSON Resource Descriptor (JRD)
A JRD is a JSON object containing the following:
- subject (string value; SHOULD be present)
- aliases (array of string values; OPTIONAL)
- properties (object containing key-value pairs as strings; OPTIONAL)
- links (array of objects containing link relation information; OPTIONAL)
Parameters:
resource (str): The URI of the resouce
Returns:
jrd (dict): Parsed JRD
Raises:
FileNotFoundError: No JRD file exists in the resource directory
OSError: A file may exist, but it is not readable
json.JSONDecodeError: A file exists, but does not contain a valid JSON object
"""
# Get a filename for the resource document. # Get a filename for the resource document.
dir = env.get("RESOURCE_DIR") or "resource" dir = env.get("RESOURCE_DIR") or "resource"
filename = f"{dir}/{resource}.json" filename = resource
path = f"{dir}/{filename}.json"
# Check that the file exists. # If we can't get a file, try percent-encoding
path = Path(filename) if not Path(path).is_file():
if not path: filename = url_encode(resource)
return None path = f"{dir}/{filename}.json"
# Open the file and load the JSON as a dictionary. # Open the file and load the JSON as a dictionary.
with open(filename, "r") as file: try:
data: dict = json.loads(file.read()) with open(path, "r") as file:
jrd: dict[str, str | list[str] | dict[str,str]] = json.loads(file.read())
except:
raise
return data return jrd

View file

@ -3,8 +3,9 @@ from fastapi.responses import PlainTextResponse, JSONResponse
from fastapi.params import Query from fastapi.params import Query
from pydantic import BaseModel from pydantic import BaseModel
import json
from webfinger.io import get_document from webfinger.io import get_jrd
router = APIRouter( router = APIRouter(
@ -16,26 +17,6 @@ router = APIRouter(
MEDIA_TYPE = "application/jrd+json" 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 ## Pydantic models for OpenAPI schema
@ -48,54 +29,81 @@ class Link(BaseModel):
class Config: class Config:
schema_extra = { schema_extra = {
"example": '''{ "example": r'''{
"rel": "https://webfinger.net/rel/profile-page/", "rel": "https://webfinger.net/rel/profile-page/",
"type": "text/html", "type": "text/html",
"href": "https://trwnh.com/~a" "href": "https://trwnh.com/~a"
}''' }'''
} }
class Resource(BaseModel): class JRD(BaseModel):
subject: str subject: str
aliases: list[str] | None aliases: list[str] | None
links: list[Link] | None links: list[Link] | None
class Config: class Config:
schema_extra = { schema_extra = {
"example": EXAMPLE_RESOURCE "example": r'''{
"subject": "acct:a@trwnh.com",
"aliases": ["https://trwnh.com/actors/7057bc10-db1c-4ebe-9e00-22cf04be4e5e", "https://trwnh.com/~a", "acct:trwnh@trwnh.com"],
"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"
}
]
}'''
} }
class WebfingerResponse(Response): class JRDResponse(Response):
media_type = MEDIA_TYPE media_type = MEDIA_TYPE
## Example responses for OpenAPI schema ## Example responses for OpenAPI schema
RESOURCE_NOT_PROVIDED = "Query ?resource={resource} to obtain information about that resource."
RESOURCE_NOT_FOUND = "Resource not found"
RESOURCE_NOT_PARSED = "Resource contains invalid JSON or was unreadable"
RESPONSES = { RESPONSES = {
200: {
"description": "OK: The resource you requested was found, and has the returned resource document.",
"model": JRD,
"content": {
MEDIA_TYPE: {
"example": json.loads(JRD.Config.schema_extra['example']),
},
},
},
400: { 400: {
"description": "Bad Request. No resource was provided.", "description": "Bad Request: No resource was provided.",
"content": { "content": {
"text/plain": { "text/plain": {
"example": NO_RESOURCE_HINT "example": RESOURCE_NOT_PROVIDED
} },
}, },
}, },
404: { 404: {
"description": "Not Found. Could not find a document for the provided resource.", "description": "Not Found: Could not find a document for the provided resource.",
"content": { "content": {
"text/plain": { "text/plain": {
"example": "{resource} not found" "example": RESOURCE_NOT_FOUND
} },
}, },
}, },
200: { 500: {
"description": "The resource you requested was found, and has the returned resource document.", "description": "Server Error: The resource exists, but contains invalid JSON or was unreadable.",
"model": Resource,
"content": { "content": {
MEDIA_TYPE: { "text/plain": {
"example": EXAMPLE_RESOURCE "example": RESOURCE_NOT_PARSED
} },
} },
}, },
} }
@ -107,46 +115,52 @@ RESPONSES = {
summary = "Lookup a Webfinger resource", summary = "Lookup a Webfinger resource",
description = "Query the Webfinger service for a resource URI and obtain its associated resource document", description = "Query the Webfinger service for a resource URI and obtain its associated resource document",
tags = ["RFC7033"], tags = ["RFC7033"],
response_model = Resource, response_model = JRD,
response_class = WebfingerResponse, response_class = JRDResponse,
responses = {**RESPONSES}, responses = {**RESPONSES},
) )
async def lookup( async def lookup(
resource: str = Query( resource: str = Query(
None, None,
title = "Resource URI",
description = "The subject whose document you are looking up. If not provided, you will get a 400 Bad Request." description = "The subject whose document you are looking up. If not provided, you will get a 400 Bad Request."
), ),
rel: list[str] = Query( rel: list[str] = Query(
None, None,
title = "Link relation",
description = "If provided, filter the links array for this rel-value only. This parameter may be included multiple times." description = "If provided, filter the links array for this rel-value only. This parameter may be included multiple times."
) )
): ):
""" """
Respond to a WebFinger query. Respond to a WebFinger query.
""" """
# If no resource is given, then show a basic hint. # If no resource is given, then show a basic hint.
if not resource: if not resource:
return PlainTextResponse( return PlainTextResponse(
content=NO_RESOURCE_HINT, content=RESOURCE_NOT_PROVIDED,
status_code=400, status_code=400,
) )
# Otherwise, try to read the resource document. # Otherwise, try to read the resource document.
data = get_document(resource) try:
jrd = get_jrd(resource)
# If the document could not be read, then it likely didn't exist. except FileNotFoundError: # JRD file does not exist
if data is None:
return PlainTextResponse( return PlainTextResponse(
content=f"{resource} not found", content=RESOURCE_NOT_FOUND,
status_code=404, status_code=404,
) )
except: # JRD file could not be read or parsed
return PlainTextResponse(
content=RESOURCE_NOT_PARSED,
status_code=500,
)
# Obtain values from the resource document. # Obtain values from the resource document.
aliases = [alias for alias in data.get('aliases', [])] subject: str = jrd.get('subject', None) or resource
links = [link for link in data.get('links', [])] aliases: list[str] = [alias for alias in jrd.get('aliases', [])]
properties = data.get('properties', {}) properties: dict[str, str] = jrd.get('properties', {})
links: list[dict[str,str]] = [link for link in jrd.get('links', [])]
# If optional rel is provided, then filter only for links with that rel-type. # If optional rel is provided, then filter only for links with that rel-type.
if rel: if rel:
@ -154,7 +168,7 @@ async def lookup(
# Construct JSON response and return it. # Construct JSON response and return it.
content = { content = {
"subject": resource, "subject": subject,
"aliases": aliases, "aliases": aliases,
"properties": properties, "properties": properties,
"links": links, "links": links,