feat(api): Initial API server and stamps routes.

This commit is contained in:
2025-06-07 05:29:00 +10:00
parent a02cc0977a
commit 77dc90cc32
5 changed files with 652 additions and 0 deletions

231
src/routes/documents.py Normal file
View File

@@ -0,0 +1,231 @@
"""
- `/documents` with `POST, GET`
- `/documents/{document_id}` with `GET`, `PATCH`, `DELETE`
- `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set.
"""
from datetime import datetime
from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List
from aiohttp import web
from data import Condition, condition
from data.queries import JOINTYPE
from datamodels import DataModel
from .lib import ModelField, datamodelsv
from .stamps import Stamp, StampCreateParams, StampEditParams, StampPayload
routes = web.RouteTableDef()
class DocPayload(TypedDict):
document_id: int
document_data: str
seal: int
created_at: str
metadata: Optional[str]
stamps: List[StampPayload]
class DocCreateParamsReq(TypedDict, total=True):
document_data: str
seal: int
class DocCreateParams(DocCreateParamsReq, total=False):
metadata: Optional[str]
stamps: List[StampCreateParams]
class DocEditParams(TypedDict, total=False):
document_data: str
seal: int
metadata: Optional[str]
stamps: List[StampCreateParams]
fields = [
ModelField('document_id', int, False, False, False),
ModelField('document_data', str, True, True, True),
ModelField('seal', int, True, True, True),
ModelField('created_at', str, False, False, False),
ModelField('metadata', Optional[str], False, True, True),
ModelField('stamps', List[StampCreateParams], False, True, True),
]
req_fields = {field.name for field in fields if field.required}
edit_fields = {field.name for field in fields if field.can_edit}
create_fields = {field.name for field in fields if field.can_create}
class Document:
def __init__(self, app: web.Application, row: DataModel.Document):
self.app = app
self.data = app[datamodelsv]
self.row = row
@classmethod
async def fetch_from_id(cls, app: web.Application, document_id: int) -> Optional[Self]:
data = app[datamodelsv]
row = await data.Document.fetch(document_id)
return cls(app, row) if row is not None else None
@classmethod
async def query(
cls,
app: web.Application,
document_id: Optional[int] = None,
seal: Optional[int] = None,
created_before: Optional[str] = None,
created_after: Optional[str] = None,
metadata: Optional[str] = None,
stamp_type: Optional[str] = None,
) -> List[Self]:
data = app[datamodelsv]
Doc = data.Document
conds = []
if document_id is not None:
conds.append(Doc.document_id == document_id)
if seal is not None:
conds.append(Doc.seal == seal)
if created_before is not None:
cbefore = datetime.fromisoformat(created_before)
conds.append(Doc.created_at <= cbefore)
if created_after is not None:
cafter = datetime.fromisoformat(created_after)
conds.append(Doc.created_at >= cafter)
if metadata is not None:
conds.append(Doc.metadata == metadata)
query = data.Document.table.fetch_rows_where(*conds)
results = await query
query = data.Document.table.select_where(*conds)
if stamp_type is not None:
query.join('document_stamps', using=('document_id',), join_type=JOINTYPE.LEFT)
query.join(
'stamp_types',
on=(data.DocumentStamp.stamp_type == data.StampType.stamp_type_id)
)
query.where(data.StampType.stamp_type_name == stamp_type)
query.select(docid = "DISTINCT(document_id)")
query.with_no_adapter()
results = await query
ids = [result['docid'] for result in results]
if ids:
rows = await data.Document.table.fetch_rows_where(
document_id=ids
)
else:
rows = []
else:
rows = await query
return [cls(app, row) for row in rows]
@classmethod
async def create(cls, app: web.Application, **kwargs: Unpack[DocCreateParams]) -> Self:
data = app[datamodelsv]
document_data = kwargs['document_data']
seal = kwargs['seal']
stamp_params = kwargs.get('stamps', [])
metadata = kwargs.get('metadata')
# Build the document first
row = await data.Document.create(
document_data=document_data,
seal=seal,
metadata=metadata,
)
# Then build the stamps
stamps = []
for stampdata in stamp_params:
stampdata.setdefault('document_id', row.document_id)
stamp = await Stamp.create(app, **stampdata)
stamps.append(stamp)
return cls(app, row)
async def get_stamps(self) -> List[Stamp]:
stamprows = await self.data.DocumentStamp.table.fetch_rows_where(document_id=self.row.document_id)
return [Stamp(self.app, row) for row in stamprows]
async def prepare(self) -> DocPayload:
stamps = await self.get_stamps()
results: DocPayload = {
'document_id': self.row.document_id,
'document_data': self.row.document_data,
'seal': self.row.seal,
'created_at': self.row.created_at.isoformat(),
'metadata': self.row.metadata,
'stamps': [await stamp.prepare() for stamp in stamps]
}
return results
async def edit(self, **kwargs: Unpack[DocEditParams]):
data = self.data
row = self.row
# Update the row data
# If stamps are given, delete the existing ones
# Then write in the new ones.
update_args = {}
for key in {'document_data', 'seal', 'metadata'}:
if key in kwargs:
update_args[key] = kwargs[key]
if update_args:
await self.row.update(**update_args)
# TODO: Should really be in a transaction
# Actually each handler should be in a transaction
if new_stamps := kwargs.get('stamps', []):
await self.data.DocumentStamp.table.delete_where(document_id=self.row.document_id)
for stampdata in new_stamps:
stampdata.setdefault('document_id', row.document_id)
await Stamp.create(self.app, **stampdata)
async def delete(self) -> DocPayload:
payload = await self.prepare()
await self.row.delete()
return payload
@routes.view('/documents')
class DocumentsView(web.View):
async def get(self):
request = self.request
filter_params = {}
keys = [
'document_id',
'seal',
'created_before',
'created_after',
'metadata',
'stamp_type',
]
for key in keys:
if key in request.query:
filter_params[key] = request.query[key]
elif key in request:
filter_params[key] = request[key]
documents = await Document.query(request.app, **filter_params)
payload = [await doc.prepare() for doc in documents]
return web.json_response(payload)
async def post(self):
request = self.request
params = await request.json()
for key in create_fields:
if key in request:
params.setdefault(key, request[key])
if extra := next((key for key in params if key not in create_fields), None):
raise web.HTTPBadRequest(text=f"Invalid key '{extra}' passed to document creation.")
if missing := next((key for key in req_fields if key not in params), None):
raise web.HTTPBadRequest(text=f"Document params missing required key '{missing}'")
document = await Document.create(self.request.app, **params)
payload = await document.prepare()
return web.json_response(payload)