Support canonical subject
Also support resource URIs with slashes Misc: Better OpenAPI examples and docstrings
This commit is contained in:
parent
8cd70fca10
commit
50d9e4bf6f
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
|
||||||
path = Path(filename)
|
# If we can't get a file, try percent-encoding
|
||||||
if not path:
|
if not Path(path).is_file():
|
||||||
return None
|
filename = url_encode(resource)
|
||||||
|
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
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue