add swagger api docs

This commit is contained in:
Izalia Mae 2024-02-16 20:25:50 -05:00
parent af1caaf7c9
commit c3b4d9ca98
7 changed files with 782 additions and 76 deletions

View file

@ -1 +1,2 @@
include data/statements.sql include data/statements.sql
include data/swagger.yaml

View file

@ -7,5 +7,3 @@ ActivityRelay is a small ActivityPub server that relays messages to subscribed i
[Configuration](configuration.md) [Configuration](configuration.md)
[Commands](commands.md) [Commands](commands.md)
[API](api.md)

View file

@ -9,6 +9,7 @@ import time
import typing import typing
from aiohttp import web from aiohttp import web
from aiohttp_swagger import setup_swagger
from aputils.signer import Signer from aputils.signer import Signer
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Event, Thread from threading import Event, Thread
@ -22,6 +23,12 @@ from .misc import check_open_port
from .views import VIEWS from .views import VIEWS
from .views.api import handle_api_path from .views.api import handle_api_path
try:
from importlib.resources import files as pkgfiles
except ImportError:
from importlib_resources import files as pkgfiles
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from tinysql import Database, Row from tinysql import Database, Row
from .cache import Cache from .cache import Cache
@ -61,6 +68,11 @@ class Application(web.Application):
for path, view in VIEWS: for path, view in VIEWS:
self.router.add_view(path, view) self.router.add_view(path, view)
setup_swagger(self,
ui_version = 3,
swagger_from_file = pkgfiles('relay').joinpath('data', 'swagger.yaml')
)
@property @property
def cache(self) -> Cache: def cache(self) -> Cache:
@ -144,7 +156,9 @@ class Application(web.Application):
'--bind', f'{self.config.listen}:{self.config.port}', '--bind', f'{self.config.listen}:{self.config.port}',
'--worker-class', 'aiohttp.GunicornWebWorker', '--worker-class', 'aiohttp.GunicornWebWorker',
'--workers', str(self.config.workers), '--workers', str(self.config.workers),
'--env', f'CONFIG_FILE={self.config.path}' '--env', f'CONFIG_FILE={self.config.path}',
'--reload-extra-file', pkgfiles('relay').joinpath('data', 'swagger.yaml'),
'--reload-extra-file', pkgfiles('relay').joinpath('data', 'statements.sql')
] ]
if dev: if dev:
@ -222,7 +236,7 @@ async def handle_access_log(request: web.Request, response: web.Response) -> Non
request.method, request.method,
request.path, request.path,
response.status, response.status,
len(response.body), response.content_length or 0,
request.headers.get('User-Agent', 'n/a') request.headers.get('User-Agent', 'n/a')
) )

713
relay/data/swagger.yaml Normal file
View file

@ -0,0 +1,713 @@
swagger: "2.0"
info:
description: |
ActivityRelay API
version: "0.2.5"
title: ActivityRelay API
license:
name: AGPL 3
url: https://www.gnu.org/licenses/agpl-3.0.en.html
basePath: /api
schemes:
- https
securityDefinitions:
Bearer:
type: apiKey
name: Authorization
in: header
description: "Enter the token with the `Bearer ` prefix"
paths:
/:
get:
tags:
- Global
description: Attributes that apply to all endpoints
responses:
"401":
description: Token is missing or invalid
schema:
$ref: "#/definitions/Error"
/v1/relay:
get:
tags:
- Relay
description: Info about the relay instance
produces:
- application/json
responses:
"200":
description: Relay info
schema:
$ref: "#/definitions/Info"
/v1/token:
get:
tags:
- Token
description: Verify API token
produces:
- application/json
responses:
"200":
description: Valid token
schema:
$ref: "#/definitions/Message"
post:
tags:
- Token
description: Get a new token
parameters:
- in: formData
name: username
required: true
type: string
- in: formData
name: password
required: true
type: string
format: password
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Created token
schema:
$ref: "#/definitions/Token"
delete:
tags:
- Token
description: Revoke a token
produces:
- application/json
responses:
"200":
description: Revoked token
schema:
$ref: "#/definitions/Message"
/v1/config:
get:
tags:
- Config
description: Get the current config values
produces:
- application/json
responses:
"200":
description: Config values
schema:
$ref: "#/definitions/Config"
post:
tags:
- Config
description: Set a config value
parameters:
- in: formData
name: key
required: true
type: string
- in: formData
name: value
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Value was set
schema:
$ref: "#/definitions/Message"
"400":
description: Key is invalid
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Config
description: Set a config option to default
parameters:
- in: formData
name: key
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Value was reset
schema:
$ref: "#/definitions/Message"
"400":
description: Key is invalid
schema:
$ref: "#/definitions/Error"
/v1/instance:
get:
tags:
- Instance
description: Get the list of subscribed instances
produces:
- application/json
responses:
"200":
description: List of instances
schema:
type: array
items:
$ref: "#/definitions/Instance"
post:
tags:
- Instance
description: Add an instance
parameters:
- in: formData
name: actor
required: true
type: string
- in: formData
name: inbox
required: false
type: string
format: url
- in: formData
name: software
required: false
type: string
- in: formData
name: followid
required: false
type: string
format: url
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Newly added instance
schema:
$ref: "#/definitions/Instance"
"500":
description: Failed to fetch actor
schema:
$ref: "#/definitions/Error"
patch:
tags:
- Instance
description: Update an instance
parameters:
- in: formData
name: domain
required: true
type: string
- in: formData
name: actor
required: false
type: string
- in: formData
name: inbox
required: false
type: string
format: url
- in: formData
name: software
required: false
type: string
- in: formData
name: followid
required: false
type: string
format: url
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Updated instance data
schema:
$ref: "#/definitions/Instance"
"404":
description: Instance with the specified domain does not exist
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Instance
description: Remove an instance from the relay
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Instance was removed
schema:
$ref: "#/definitions/Message"
"404":
description: Instance with the specified domain does not exist
schema:
$ref: "#/definitions/Error"
/v1/domain_ban:
get:
tags:
- Domain Ban
description: Get a list of all banned domains
produces:
- application/json
responses:
"200":
description: List of banned domains
schema:
type: array
items:
$ref: "#/definitions/DomainBan"
post:
tags:
- Domain Ban
description: Ban a domain
parameters:
- in: formData
name: domain
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: New domain ban
schema:
$ref: "#/definitions/DomainBan"
"404":
description: Domain ban already exists
schema:
$ref: "#/definitions/Error"
patch:
tags:
- Domain Ban
description: Update a banned domain
parameters:
- in: formData
name: domain
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Updated domain ban
schema:
$ref: "#/definitions/DomainBan"
"400":
description: A reason or note was not specified
schema:
$ref: "#/definitions/Error"
"404":
description: Domain ban doesn't exist
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Domain Ban
description: Unban a domain
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Unbanned domain
schema:
$ref: "#/definitions/Message"
"404":
description: Domain ban doesn't exist
schema:
$ref: "#/definitions/Error"
/v1/software_ban:
get:
tags:
- Software Ban
description: Get a list of all banned software
produces:
- application/json
responses:
"200":
description: List of banned software
schema:
type: array
items:
$ref: "#/definitions/SoftwareBan"
post:
tags:
- Software Ban
description: Ban software
parameters:
- in: formData
name: name
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: New software ban
schema:
$ref: "#/definitions/SoftwareBan"
"404":
description: Software ban already exists
schema:
$ref: "#/definitions/Error"
patch:
tags:
- Software Ban
description: Update banned software
parameters:
- in: formData
name: name
required: true
type: string
- in: formData
name: reason
required: false
type: string
- in: formData
name: note
required: false
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Updated software ban
schema:
$ref: "#/definitions/SoftwareBan"
"400":
description: A reason or note was not specified
schema:
$ref: "#/definitions/Error"
"404":
description: Software ban doesn't exist
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Software Ban
description: Unban software
parameters:
- in: formData
name: name
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Unbanned software
schema:
$ref: "#/definitions/Message"
"404":
description: Software ban doesn't exist
schema:
$ref: "#/definitions/Error"
/v1/whitelist:
get:
tags:
- Whitelist
description: Get a list of all whitelisted domains
produces:
- application/json
responses:
"200":
description: List of whitelisted domains
schema:
type: array
items:
$ref: "#/definitions/Whitelist"
post:
tags:
- Whitelist
description: Add a domain to the whitelist
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: New whitelisted domain
schema:
$ref: "#/definitions/Whitelist"
"404":
description: Domain already whitelisted
schema:
$ref: "#/definitions/Error"
delete:
tags:
- Whitelist
description: Remove domain from the whitelist
parameters:
- in: formData
name: domain
required: true
type: string
consumes:
- application/json
- multipart/form-data
- application/x-www-form-urlencoded
produces:
- application/json
responses:
"200":
description: Domain removed from the whitelist
schema:
$ref: "#/definitions/Message"
"404":
description: Domain was not in the whitelist
schema:
$ref: "#/definitions/Error"
definitions:
Message:
type: object
properties:
message:
description: Human-readable message text
type: string
Error:
type: object
properties:
error:
description: Human-readable message text
type: string
Config:
type: object
properties:
log-level:
description: Maximum level of log messages to print to the console
type: string
name:
description: Name of the relay
type: string
note:
description: Blurb to display on the home page
type: string
whitelist-enabled:
description: Only allow specific instances to join the relay when enabled
type: boolean
DomainBan:
type: object
properties:
domain:
description: Banned domain
type: string
reason:
description: Public reason for the domain ban
type: string
note:
description: Private note for the software ban
type: string
created:
description: Date the ban was added
type: string
format: date-time
Info:
type: object
properties:
domain:
description: Domain the relay is hosted on
type: string
name:
description: Name of the relay
type: string
description:
description: Short blurb that describes the relay
type: string
version:
description: Version of the relay
type: string
whitelist_enabled:
description: Only allow specific instances to join the relay when enabled
type: boolean
email:
description: E-Mail address used to contact the admin
type: string
admin:
description: Fediverse account of the admin
type: string
icon:
description: Image for the relay instance
type: string
instances:
description: List of currently subscribed Fediverse instances
type: array
items:
type: string
Instance:
type: object
properties:
domain:
description: Domain the instance is hosted on
type: string
actor:
description: ActivityPub actor of the instance that joined
type: string
format: url
inbox:
description: Inbox (usually shared) of the instance to post to
type: string
format: url
followid:
description: Url to the Follow activity of the instance
type: string
format: url
software:
description: Nodeinfo-formatted name of the instance's software
type: string
created:
description: Date the instance joined or was added
type: string
format: date-time
SoftwareBan:
type: object
properties:
name:
type: string
description: Nodeinfo-formatted software name
reason:
description: Public reason for the software ban
type: string
note:
description: Private note for the software ban
type: string
created:
description: Date the ban was added
type: string
format: date-time
Token:
type: object
properties:
token:
description: Character string used for authenticating with the api
type: string
Whitelist:
type: object
properties:
domain:
type: string
description: Whitelisted domain
created:
description: Date the domain was added to the whitelist
type: string
format: date-time

View file

@ -34,7 +34,7 @@ PUBLIC_API_PATHS: tuple[tuple[str, str]] = (
def check_api_path(method: str, path: str) -> bool: def check_api_path(method: str, path: str) -> bool:
if (method, path) in PUBLIC_API_PATHS: if path.startswith('/api/doc') or (method, path) in PUBLIC_API_PATHS:
return False return False
return path.startswith('/api') return path.startswith('/api')
@ -42,6 +42,8 @@ def check_api_path(method: str, path: str) -> bool:
@web.middleware @web.middleware
async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Response: async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Response:
print("Authorization:", request.headers.get('Authorization'))
try: try:
request['token'] = request.headers['Authorization'].replace('Bearer', '').strip() request['token'] = request.headers['Authorization'].replace('Bearer', '').strip()
@ -67,7 +69,7 @@ async def handle_api_path(request: web.Request, handler: Coroutine) -> web.Respo
@register_route('/api/v1/token') @register_route('/api/v1/token')
class Login(View): class Login(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
return Response.new({'message': 'Token valid'}) return Response.new({'message': 'Token valid'}, ctype = 'json')
async def post(self, request: Request) -> Response: async def post(self, request: Request) -> Response:
@ -213,28 +215,14 @@ class Inbox(View):
return Response.new(row, ctype = 'json') return Response.new(row, ctype = 'json')
@register_route('/api/v1/instance/{domain}') async def patch(self, request: Request) -> Response:
class InboxSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (row := conn.get_inbox(domain)):
return Response.new_error(404, 'Instance with domain not found', 'json')
row['created'] = datetime.fromtimestamp(row['created'], tz = timezone.utc).isoformat()
return Response.new(row, ctype = 'json')
async def patch(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_inbox(domain): data = await self.get_api_data(['domain'], ['actor', 'software', 'followid'])
return Response.new_error(404, 'Instance with domain not found', 'json')
data = await self.get_api_data([], ['actor', 'software', 'followid'])
if isinstance(data, Response): if isinstance(data, Response):
return data return data
if not (instance := conn.get_inbox(domain)): if not (instance := conn.get_inbox(data['domain'])):
return Response.new_error(404, 'Instance with domain not found', 'json') return Response.new_error(404, 'Instance with domain not found', 'json')
instance = conn.update_inbox(instance['inbox'], **data) instance = conn.update_inbox(instance['inbox'], **data)
@ -244,10 +232,15 @@ class InboxSingle(View):
async def delete(self, request: Request, domain: str) -> Response: async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_inbox(domain): data = await self.get_api_data(['domain'], [])
if isinstance(data, Response):
return data
if not conn.get_inbox(data['domain']):
return Response.new_error(404, 'Instance with domain not found', 'json') return Response.new_error(404, 'Instance with domain not found', 'json')
conn.del_inbox(domain) conn.del_inbox(data['domain'])
return Response.new({'message': 'Deleted instance'}, ctype = 'json') return Response.new({'message': 'Deleted instance'}, ctype = 'json')
@ -276,40 +269,35 @@ class DomainBan(View):
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
@register_route('/api/v1/domain_ban/{domain}') async def patch(self, request: Request) -> Response:
class DomainBanSingle(View):
async def get(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not (ban := conn.get_domain_ban(domain)):
return Response.new_error(404, 'Domain ban not found', 'json')
return Response.new(ban, ctype = 'json')
async def patch(self, request: Request, domain: str) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_domain_ban(domain): data = await self.get_api_data(['domain'], ['note', 'reason'])
return Response.new_error(404, 'Domain not banned', 'json')
data = await self.get_api_data([], ['note', 'reason'])
if isinstance(data, Response): if isinstance(data, Response):
return data return data
if not conn.get_domain_ban(data['domain']):
return Response.new_error(404, 'Domain not banned', 'json')
if not any([data.get('note'), data.get('reason')]): if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json') return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_domain_ban(domain, **data) ban = conn.update_domain_ban(data['domain'], **data)
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
async def delete(self, request: Request, domain: str) -> Response: async def delete(self, request: Request) -> Response:
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_domain_ban(domain): data = await self.get_api_data(['domain'], [])
if isinstance(data, Response):
return data
if not conn.get_domain_ban(data['domain']):
return Response.new_error(404, 'Domain not banned', 'json') return Response.new_error(404, 'Domain not banned', 'json')
conn.del_domain_ban(domain) conn.del_domain_ban(data['domain'])
return Response.new({'message': 'Unbanned domain'}, ctype = 'json') return Response.new({'message': 'Unbanned domain'}, ctype = 'json')
@ -338,40 +326,35 @@ class SoftwareBan(View):
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
@register_route('/api/v1/software_ban/{name}') async def patch(self, request: Request) -> Response:
class SoftwareBanSingle(View): data = await self.get_api_data(['name'], ['note', 'reason'])
async def get(self, request: Request, name: str) -> Response:
with self.database.connection(False) as conn:
if not (ban := conn.get_software_ban(name)):
return Response.new_error(404, 'Software ban not found', 'json')
return Response.new(ban, ctype = 'json')
async def patch(self, request: Request, name: str) -> Response:
with self.database.connection(True) as conn:
if not conn.get_software_ban(name):
return Response.new_error(404, 'Software not banned', 'json')
data = await self.get_api_data([], ['note', 'reason'])
if isinstance(data, Response): if isinstance(data, Response):
return data return data
with self.database.connection(True) as conn:
if not conn.get_software_ban(data['name']):
return Response.new_error(404, 'Software not banned', 'json')
if not any([data.get('note'), data.get('reason')]): if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json') return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_software_ban(name, **data) ban = conn.update_software_ban(data['name'], **data)
return Response.new(ban, ctype = 'json') return Response.new(ban, ctype = 'json')
async def delete(self, request: Request, name: str) -> Response: async def delete(self, request: Request) -> Response:
data = await self.get_api_data(['name'], [])
if isinstance(data, Response):
return data
with self.database.connection(True) as conn: with self.database.connection(True) as conn:
if not conn.get_software_ban(name): if not conn.get_software_ban(data['name']):
return Response.new_error(404, 'Software not banned', 'json') return Response.new_error(404, 'Software not banned', 'json')
conn.del_software_ban(name) conn.del_software_ban(data['name'])
return Response.new({'message': 'Unbanned software'}, ctype = 'json') return Response.new({'message': 'Unbanned software'}, ctype = 'json')
@ -400,21 +383,16 @@ class Whitelist(View):
return Response.new(item, ctype = 'json') return Response.new(item, ctype = 'json')
@register_route('/api/v1/domain/{domain}') async def delete(self, request: Request) -> Response:
class WhitelistSingle(View): data = await self.get_api_data(['domain'], [])
async def get(self, request: Request, domain: str) -> Response:
if isinstance(data, Response):
return data
with self.database.connection(False) as conn: with self.database.connection(False) as conn:
if not (item := conn.get_domain_whitelist(domain)): if not conn.get_domain_whitelist(data['domain']):
return Response.new_error(404, 'Domain not in whitelist', 'json') return Response.new_error(404, 'Domain not in whitelist', 'json')
return Response.new(item, ctype = 'json') conn.del_domain_whitelist(data['domain'])
async def delete(self, request: Request, domain: str) -> Response:
with self.database.connection(False) as conn:
if not conn.get_domain_whitelist(domain):
return Response.new_error(404, 'Domain not in whitelist', 'json')
conn.del_domain_whitelist(domain)
return Response.new({'message': 'Removed domain from whitelist'}, ctype = 'json') return Response.new({'message': 'Removed domain from whitelist'}, ctype = 'json')

View file

@ -1,4 +1,5 @@
aiohttp>=3.9.1 aiohttp>=3.9.1
aiohttp-swagger[performance]==1.0.16
aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz
argon2-cffi==23.1.0 argon2-cffi==23.1.0
click>=8.1.2 click>=8.1.2

View file

@ -34,6 +34,7 @@ dev = file: dev-requirements.txt
[options.package_data] [options.package_data]
relay = relay =
data/statements.sql data/statements.sql
data/swagger.yaml
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =