feat: upgrade app template server from vet reference

- integer-id persistence: memory (default, zero-dep) + mongo_int repos
  selected by REPO_BACKEND via create_repo factory
- interfaces/_common.py: shared svc/dump/paginate/expand_ref/read_or_404
  helpers; item/job routes rebuilt on them (paginated {total,items} lists,
  PUT replace, 204 deletes, int path ids)
- interfaces/system: /health liveness probe (public)
- openapi.yaml rewritten to 3.1.0 with x- extensions on Item/Job
  (x-resource, x-primary-key, x-display-format, x-list-columns, x-label,
  x-order, x-hidden, x-filterable, x-sortable, x-autocomplete), auth-server
  x-* securityScheme wiring, shared error responses
- config/init/main: REPO_BACKEND knob, load_dotenv before spec build
Verified: 7/7 tests pass; live smoke test (/health ok, 401 without token)
This commit is contained in:
2026-10-19 17:06:47 +05:30
parent ee954431cc
commit e88005d936
19 changed files with 1213 additions and 215 deletions

View File

@@ -1,7 +1,9 @@
DEBUG=false
REPO_BACKEND=memory
MONGO_URI=mongodb://localhost:27017
MONGO_DB=app_template
PORT=8000
WORKER_POLL_SECONDS=30
WORKER_MAX_RETRIES=3
AUTH_SERVER=http://localhost:8000
JWT_SERVER=http://localhost:8000
JWT_SERVER=http://localhost:8000

View File

@@ -3,6 +3,23 @@
App configuration loaded from environment — a Settings dataclass with defaults
plus a singleton settings instance populated from OS environment variables at
import time.
---
The template reads these knobs:
- ``repo_backend`` — ``memory`` (default, zero-dependency process-local store)
or ``mongo`` (persistent, via the shared mongo-ops connection manager).
- ``mongo_uri`` / ``mongo_db`` — connection targets when the Mongo backend is
selected.
- ``port`` — the port uvicorn binds (keep in sync with the OpenAPI
``servers:`` entry and the client's ``VITE_API_BASE_URL``).
- ``worker_poll_seconds`` / ``worker_max_retries`` — background worker cadence.
``AUTH_SERVER`` is intentionally *not* re-exported here: ``openapi_first``
resolves ``{AUTH_SERVER}`` straight from the process environment when the
application is built, so the env var remains the single source of truth for
the generated bearer-JWT introspection dependency.
"""
import os
from dataclasses import dataclass
@@ -14,23 +31,30 @@ class Settings:
Attributes:
debug (bool): Enable debug behavior. Defaults to False.
repo_backend (str): Persistence flavour, ``memory`` or ``mongo``.
Defaults to ``memory``.
mongo_uri (str): MongoDB connection URI. Defaults to mongodb://localhost:27017.
mongo_db (str): MongoDB database name. Defaults to app_template.
port (int): Local HTTP port. Defaults to 8000.
worker_poll_seconds (int): Worker poll interval in seconds. Defaults to 30.
worker_max_retries (int): Maximum job retries before skipping. Defaults to 3.
"""
debug: bool = False
repo_backend: str = "memory"
mongo_uri: str = "mongodb://localhost:27017"
mongo_db: str = "app_template"
port: int = 8000
worker_poll_seconds: int = 30
worker_max_retries: int = 3
settings = Settings(
debug=os.environ.get("DEBUG", "").lower() in ("1", "true", "yes"),
repo_backend=os.environ.get("REPO_BACKEND", "memory").lower(),
mongo_uri=os.environ.get("MONGO_URI", "mongodb://localhost:27017"),
mongo_db=os.environ.get("MONGO_DB", "app_template"),
port=int(os.environ.get("PORT", "8000")),
worker_poll_seconds=int(os.environ.get("WORKER_POLL_SECONDS", "30")),
worker_max_retries=int(os.environ.get("WORKER_MAX_RETRIES", "3")),
)
)

View File

@@ -9,11 +9,13 @@ class Item(BaseModel):
"""A single countable resource.
Attributes:
id (int | None): Server-assigned identifier. Defaults to None.
name (str): Item name.
quantity (int): Item quantity. Defaults to 1.
"""
id: int | None = None
name: str
quantity: int = Field(default=1)
model_config = {"extra": "ignore"}
model_config = {"extra": "ignore"}

View File

@@ -1,21 +1,25 @@
"""# Summary
Mongo persistence for items: document schema and repository factory.
Items persistence ports — integer-id document and repository factory.
"""
from mongo_ops import BaseDocument, BaseRepository
from mongo_ops import BaseDocument
from pydantic import Field
from domain.item.models import Item
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
from infra.db.repos import create_repo
class ItemDocument(Item, BaseDocument):
"""Mongo document form of :class:`Item`."""
"""Persisted item document. The integer ``id`` shadows the ObjectId."""
id: int | None = Field(default=None, validation_alias="_id")
def create_item_repo() -> BaseRepository:
"""Create the Mongo-backed item repository.
def create_item_repo():
"""Create the item repository selected by ``settings.repo_backend``.
Returns:
BaseRepository: Repository wired to the ``items`` collection.
IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the
``items`` collection/store.
"""
return mongo_repo_factory("items", ItemDocument)
return create_repo("items", ItemDocument)

View File

@@ -11,6 +11,7 @@ class Job(BaseModel):
"""A unit of background work.
Attributes:
id (int | None): Server-assigned identifier. Defaults to None.
name (str): Job name.
status (str): Lifecycle status. Defaults to "pending".
retry_count (int): Times the job failed. Defaults to 0.
@@ -18,10 +19,11 @@ class Job(BaseModel):
payload (dict | None): Arbitrary job payload. Defaults to None.
"""
id: int | None = None
name: str
status: Literal["pending", "processing", "done", "failed"] = "pending"
retry_count: int = Field(default=0)
error_message: str | None = None
payload: dict | None = None
model_config = {"extra": "ignore"}
model_config = {"extra": "ignore"}

View File

@@ -1,21 +1,25 @@
"""# Summary
Mongo persistence for jobs: document schema and repository factory.
Jobs persistence ports — integer-id document and repository factory.
"""
from mongo_ops import BaseDocument, BaseRepository
from mongo_ops import BaseDocument
from pydantic import Field
from domain.job.models import Job
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
from infra.db.repos import create_repo
class JobDocument(Job, BaseDocument):
"""Mongo document form of :class:`Job`."""
"""Persisted job document. The integer ``id`` shadows the ObjectId."""
id: int | None = Field(default=None, validation_alias="_id")
def create_job_repo() -> BaseRepository:
"""Create the Mongo-backed job repository.
def create_job_repo():
"""Create the job repository selected by ``settings.repo_backend``.
Returns:
BaseRepository: Repository wired to the ``jobs`` collection.
IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the
``jobs`` collection/store.
"""
return mongo_repo_factory("jobs", JobDocument)
return create_repo("jobs", JobDocument)

View File

@@ -0,0 +1,31 @@
"""# Summary
Repository factory — picks the in-memory or Mongo integer-id adapter based
on ``settings.repo_backend``.
---
Every domain slice calls :func:`create_repo` so the backend choice lives in
one place: ``REPO_BACKEND=memory`` (default) yields the zero-dependency
process-local store; ``REPO_BACKEND=mongo`` yields the persistent adapter.
"""
from typing import Any
from config import settings
from infra.db.repos.memory import IntIdMemoryRepository
from infra.db.repos.mongo_int import IntIdMongoRepository
def create_repo(collection: str, document_cls: type[Any]):
"""Create the integer-id repository selected by ``settings.repo_backend``.
Args:
collection (str): Collection / store name for the resource.
document_cls (type[Any]): Document model bound to the repository.
Returns:
IntIdMemoryRepository | IntIdMongoRepository: The configured repository.
"""
if settings.repo_backend == "mongo":
return IntIdMongoRepository(collection, document_cls)
return IntIdMemoryRepository(document_cls)

View File

@@ -0,0 +1,161 @@
"""# Summary
In-memory integer-id repository — the zero-dependency persistence adapter.
---
One process-local dictionary per resource, keyed by auto-incrementing integer
``id``. The wire contract (``openapi.yaml``) declares integer primary keys,
so this adapter speaks integers end-to-end and is the default backend
(``REPO_BACKEND=memory``).
The method surface mirrors the mongo-ops repository duck-type consumed by
``infra.adapters.service.CrudsServiceAdaptor``: ``create``, ``get_by_id``,
``get_many``, ``update``, ``patch``, ``delete``.
Notes:
Not thread-safe and not durable — deliberately. Swap to the Mongo
adapter via ``REPO_BACKEND=mongo`` without touching domain code.
"""
from typing import Any
from pydantic import BaseModel
class IntIdMemoryRepository:
"""Dictionary-backed repository with auto-increment integer ids.
Attributes:
document_cls (type[BaseModel]): Document model instantiated for
every stored record.
"""
def __init__(self, document_cls: type[BaseModel]):
"""Initialize an empty store for ``document_cls``.
Args:
document_cls (type[BaseModel]): Document model bound to this repo.
"""
self.document_cls = document_cls
self._docs: dict[int, BaseModel] = {}
self._seq = 0
def _to_doc(self, data: Any) -> BaseModel:
"""Coerce ``data`` into a document instance.
Args:
data (Any): Document instance or field mapping.
Returns:
BaseModel: Document built from ``data``.
"""
if isinstance(data, self.document_cls):
return data
return self.document_cls(**dict(data))
async def create(self, data: Any) -> BaseModel:
"""Persist a new document with a freshly allocated integer id.
Args:
data (Any): Document instance or field mapping (id ignored).
Returns:
BaseModel: The stored document with ``id`` assigned.
"""
doc = self._to_doc(data)
self._seq += 1
doc.id = self._seq
self._docs[doc.id] = doc
return doc
async def get_by_id(self, id: int) -> BaseModel | None:
"""Fetch one document by integer id.
Args:
id (int): Document identifier.
Returns:
BaseModel | None: The stored document, or ``None`` when absent.
"""
return self._docs.get(int(id))
async def get_many(self, filter: dict | None = None, **_: Any) -> list[BaseModel]:
"""List documents, optionally filtered by top-level equality.
Args:
filter (dict | None): Equality filter on document fields.
Returns:
list[BaseModel]: Matching documents in insertion order.
"""
docs = list(self._docs.values())
if filter:
docs = [d for d in docs if all(getattr(d, k, None) == v for k, v in filter.items())]
return docs
async def update(self, id: int, data: Any) -> BaseModel | None:
"""Replace a document by integer id.
Args:
id (int): Document identifier.
data (Any): Replacement document or field mapping.
Returns:
BaseModel | None: The stored document, or ``None`` when absent.
"""
if int(id) not in self._docs:
return None
doc = self._to_doc(data)
doc.id = int(id)
self._docs[doc.id] = doc
return doc
async def patch(self, id: int, data: dict) -> BaseModel | None:
"""Partially update a document by integer id.
Args:
id (int): Document identifier.
data (dict): Fields to merge into the stored document.
Returns:
BaseModel | None: The stored document, or ``None`` when absent.
"""
current = self._docs.get(int(id))
if current is None:
return None
merged = current.model_dump()
merged.update(data)
doc = self.document_cls(**merged)
doc.id = int(id)
self._docs[doc.id] = doc
return doc
async def delete(self, id: int) -> bool:
"""Delete a document by integer id.
Args:
id (int): Document identifier.
Returns:
bool: ``True`` when a document was removed.
"""
return self._docs.pop(int(id), None) is not None
async def delete_many(self, filter: dict | None = None) -> int:
"""Delete every document (optionally filtered by equality).
Args:
filter (dict | None): Equality filter on document fields.
Returns:
int: Number of documents removed.
"""
victims = await self.get_many(filter)
for doc in victims:
self._docs.pop(doc.id, None)
return len(victims)
def clear(self) -> None:
"""Remove every document and reset the id counter."""
self._docs.clear()
self._seq = 0

View File

@@ -0,0 +1,184 @@
"""# Summary
MongoDB integer-id repository — the persistent persistence adapter.
---
The wire contract declares integer primary keys, while mongo-ops'
``BaseRepository`` allocates ``ObjectId`` keys. This adapter therefore talks
to the shared ``MongoConnectionManager`` database directly (the Tier A
connection lifecycle is reused; only the id strategy differs) and stores
records with auto-incrementing integer ``_id`` values allocated from a
``_counters`` collection.
The method surface mirrors the in-memory adapter exactly, so
``CrudsServiceAdaptor`` and the domain services are backend-agnostic.
"""
from typing import Any
from mongo_ops import MongoConnectionManager
from pydantic import BaseModel
class IntIdMongoRepository:
"""Motor-backed repository with auto-increment integer ids.
Attributes:
collection (str): MongoDB collection name.
document_cls (type[BaseModel]): Document model bound to this repo.
"""
def __init__(self, collection: str, document_cls: type[BaseModel]):
"""Bind the repository to a collection and document model.
Args:
collection (str): Collection name to operate on.
document_cls (type[BaseModel]): Document model persisted here.
"""
self.collection = collection
self.document_cls = document_cls
@property
def _col(self):
"""AsyncIOMotorCollection: The live collection handle."""
return MongoConnectionManager._database[self.collection]
async def _next_id(self) -> int:
"""Allocate the next integer id from the shared counters collection.
Returns:
int: A monotonically increasing identifier.
"""
counters = MongoConnectionManager._database["_counters"]
doc = await counters.find_one_and_update(
{"_id": self.collection},
{"$inc": {"seq": 1}},
upsert=True,
return_document=True,
)
return int(doc["seq"])
def _to_doc(self, data: Any) -> BaseModel:
"""Coerce ``data`` into a document instance.
Args:
data (Any): Document instance or field mapping.
Returns:
BaseModel: Document built from ``data``.
"""
if isinstance(data, self.document_cls):
return data
return self.document_cls(**dict(data))
def _to_payload(self, doc: BaseModel, id: int | None = None) -> dict:
"""Serialize a document into a Mongo-ready payload.
Args:
doc (BaseModel): Document to serialize.
id (int | None): Explicit ``_id``; taken from the doc when omitted.
Returns:
dict: Mapping with an integer ``_id`` key.
"""
payload = doc.model_dump()
payload.pop("id", None)
payload["_id"] = id if id is not None else doc.id
return payload
async def create(self, data: Any) -> BaseModel:
"""Persist a new document with a freshly allocated integer id.
Args:
data (Any): Document instance or field mapping (id ignored).
Returns:
BaseModel: The stored document with ``id`` assigned.
"""
doc = self._to_doc(data)
new_id = await self._next_id()
await self._col.insert_one(self._to_payload(doc, new_id))
doc.id = new_id
return doc
async def get_by_id(self, id: int) -> BaseModel | None:
"""Fetch one document by integer id.
Args:
id (int): Document identifier.
Returns:
BaseModel | None: The stored document, or ``None`` when absent.
"""
raw = await self._col.find_one({"_id": int(id)})
return self.document_cls.model_validate(raw) if raw else None
async def get_many(self, filter: dict | None = None, **_: Any) -> list[BaseModel]:
"""List documents, optionally filtered by top-level equality.
Args:
filter (dict | None): Equality filter on document fields.
Returns:
list[BaseModel]: Matching documents in insertion order.
"""
cursor = self._col.find(filter or {})
return [self.document_cls.model_validate(raw) async for raw in cursor]
async def update(self, id: int, data: Any) -> BaseModel | None:
"""Replace a document by integer id.
Args:
id (int): Document identifier.
data (Any): Replacement document or field mapping.
Returns:
BaseModel | None: The stored document, or ``None`` when absent.
"""
doc = self._to_doc(data)
payload = self._to_payload(doc, int(id))
result = await self._col.replace_one({"_id": int(id)}, payload)
if result.matched_count == 0:
return None
doc.id = int(id)
return doc
async def patch(self, id: int, data: dict) -> BaseModel | None:
"""Partially update a document by integer id.
Args:
id (int): Document identifier.
data (dict): Fields to merge into the stored document.
Returns:
BaseModel | None: The stored document, or ``None`` when absent.
"""
updates = {k: v for k, v in data.items() if k != "id"}
result = await self._col.update_one({"_id": int(id)}, {"$set": updates})
if result.matched_count == 0:
return None
return await self.get_by_id(id)
async def delete(self, id: int) -> bool:
"""Delete a document by integer id.
Args:
id (int): Document identifier.
Returns:
bool: ``True`` when a document was removed.
"""
result = await self._col.delete_one({"_id": int(id)})
return result.deleted_count > 0
async def delete_many(self, filter: dict | None = None) -> int:
"""Delete every document (optionally filtered by equality).
Args:
filter (dict | None): Equality filter on document fields.
Returns:
int: Number of documents removed.
"""
result = await self._col.delete_many(filter or {})
return result.deleted_count

View File

@@ -1,8 +1,15 @@
"""# Summary
Infrastructure initialization — connects MongoDB and wires all repos and services.
Shared bootstrap for both the FastAPI app and the worker, guarded against
repeated initialization via a module-level flag.
Composition root — connects MongoDB (when selected), wires all repos and
services, and returns the resources dict. Shared bootstrap for the FastAPI
app, the worker and the seed/drop scripts, guarded against repeated
initialization via a module-level flag.
---
The ``memory`` backend skips the Mongo connection entirely — repos are
process-local dictionaries, so a plain ``uvicorn main:app`` runs with zero
external services.
"""
from typing import Any
@@ -18,18 +25,21 @@ _initialized: bool = False
async def init() -> dict[str, Any]:
"""Connect MongoDB, wire all repos and services, and return the resources dict.
"""Wire all repos and services, and return the resources dict.
Idempotent: repeated calls short-circuit and return the cached resources.
Only the ``mongo`` backend performs a network connection here.
Returns:
Dict[str, Any]: Named services keyed by resource name.
Dict[str, Any]: Named services keyed by resource name
(``<resource>_svc`` and ``<resource>_repo``).
"""
global _resources, _initialized
if _initialized:
return _resources
await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)
if settings.repo_backend == "mongo":
await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)
item_repo = create_item_repo()
job_repo = create_job_repo()
@@ -44,8 +54,9 @@ async def init() -> dict[str, Any]:
async def shutdown() -> None:
"""Disconnect MongoDB and reset the resources cache."""
"""Disconnect MongoDB (if connected) and reset the resources cache."""
global _initialized
await shutdown_database()
if settings.repo_backend == "mongo":
await shutdown_database()
_resources.clear()
_initialized = False
_initialized = False

View File

@@ -0,0 +1,146 @@
"""# Summary
Shared interface helpers — service resolution, serialization, pagination and
foreign-key expansion used by every route module.
---
FK fields on the wire may arrive either as a bare integer id (from an
``x-fk`` picker) or as an already-expanded object. The wire contract always
*returns* fully expanded objects, so create/update handlers normalize the
input through :func:`expand_ref` before persisting.
"""
from fastapi import HTTPException, Request
def svc(request: Request, name: str):
"""Resolve a CRUD service from app state by resource name.
Args:
request (Request): Incoming request whose app.state carries resources.
name (str): Resource key suffix, e.g. ``"parent"`` → ``parent_svc``.
Returns:
Any: The named CRUD service.
"""
return request.app.state.db_resources[f"{name}_svc"]
def dump(doc) -> dict:
"""Serialize a document to a JSON-ready mapping with an integer id.
Args:
doc (Any): Pydantic document to serialize.
Returns:
dict: JSON-mode mapping (``by_alias`` so ``_id`` maps back to ``id``).
"""
return doc.model_dump(mode="json", by_alias=True)
def paginate(items: list, limit: int, offset: int) -> dict:
"""Slice ``items`` into the ``{total, items}`` pagination envelope.
A ``limit`` of 0 means "all remaining items" (used by react-openapi for
FK option lists and counts).
Args:
items (list): Full result list.
limit (int): Page size; ``0`` returns everything after ``offset``.
offset (int): Number of items to skip.
Returns:
dict: ``{"total": int, "items": list}``.
"""
offset = max(offset, 0)
page = items[offset:] if limit == 0 else items[offset : offset + limit]
return {"total": len(items), "items": page}
async def expand_ref(svc_obj, value, label: str) -> dict | None:
"""Normalize a foreign-key reference into a fully expanded object.
Args:
svc_obj (Any): Target resource CRUD service used for lookups.
value (dict | int | None): Bare id, ``{"id": N}`` stub, or full object.
label (str): Human label used in 422 error messages.
Returns:
dict | None: Expanded object, or ``None`` when ``value`` is ``None``.
Raises:
HTTPException: 422 when the reference cannot be resolved to a document.
"""
if value is None:
return None
if isinstance(value, dict):
if set(value.keys()) == {"id"}:
value = value["id"]
else:
return value
if isinstance(value, int):
doc = await svc_obj.read(value)
if doc is None:
raise HTTPException(status_code=422, detail=f"{label} {value} not found")
return dump(doc)
raise HTTPException(status_code=422, detail=f"invalid {label} reference")
async def expand_ref_list(svc_obj, value, label: str) -> list[dict]:
"""Normalize a list foreign-key reference into expanded objects.
Args:
svc_obj (Any): Target resource CRUD service used for lookups.
value (list | None): List of bare ids, stubs, or full objects.
label (str): Human label used in 422 error messages.
Returns:
list[dict]: Expanded objects.
Raises:
HTTPException: 422 when any reference cannot be resolved.
"""
if value is None:
return []
if not isinstance(value, list):
raise HTTPException(status_code=422, detail=f"invalid {label} list")
results = []
for entry in value:
results.append(await expand_ref(svc_obj, entry, label))
return results
async def read_or_404(svc_obj, id: int, label: str):
"""Read a document by id or raise a 404 HTTPException.
Args:
svc_obj (Any): CRUD service to read from.
id (int): Document identifier.
label (str): Human label used in the 404 detail message.
Returns:
Any: The found document.
Raises:
HTTPException: 404 when no document matches ``id``.
"""
doc = await svc_obj.read(id)
if doc is None:
raise HTTPException(status_code=404, detail=f"{label} not found")
return doc
async def delete_or_404(svc_obj, id: int, label: str) -> None:
"""Delete a document by id or raise a 404 HTTPException.
Args:
svc_obj (Any): CRUD service to delete from.
id (int): Document identifier.
label (str): Human label used in the 404 detail message.
Raises:
HTTPException: 404 when no document matches ``id``.
"""
ok = await svc_obj.delete(id)
if not ok:
raise HTTPException(status_code=404, detail=f"{label} not found")

View File

@@ -2,41 +2,89 @@
Item route handlers — names must match openapi.yaml operationIds.
"""
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi import Request, Response
from domain.item.models import Item
from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc
def svc(request: Request):
"""Resolve the item CRUD service from app state."""
return request.app.state.db_resources["item_svc"]
async def list_items(request: Request, limit: int = 20, offset: int = 0) -> dict:
"""List items (paginated). ``limit=0`` returns all remaining items.
Args:
request (Request): Incoming request.
limit (int): Page size. Defaults to 20.
offset (int): Items to skip. Defaults to 0.
Returns:
dict: ``{"total": int, "items": list[Item]}``.
"""
docs = await svc(request, "item").search({})
return paginate([dump(d) for d in docs], limit, offset)
async def create_item(request: Request, body: dict):
"""Create an item from the request body."""
data = body.model_dump() if hasattr(body, "model_dump") else dict(body)
doc = await svc(request).create(Item(**data))
return JSONResponse(status_code=201, content=doc.model_dump(mode="json", by_alias=True))
async def create_item(request: Request, body: dict, response: Response) -> dict:
"""Create an item.
Args:
request (Request): Incoming request.
body (dict): Item fields (``id`` ignored).
response (Response): Response object; set to 201 on success.
Returns:
dict: The created item.
"""
response.status_code = 201
body.pop("id", None)
doc = await svc(request, "item").create(body)
return dump(doc)
async def get_item(request: Request, id: str):
"""Fetch one item by id."""
doc = await svc(request).read(id)
if doc is None:
return JSONResponse(status_code=404, content={"detail": "item not found"})
return doc.model_dump(mode="json", by_alias=True)
async def get_item(request: Request, id: int) -> dict:
"""Fetch one item by id.
Args:
request (Request): Incoming request.
id (int): Item identifier.
Returns:
dict: The item document.
Raises:
HTTPException: 404 when the item does not exist.
"""
return dump(await read_or_404(svc(request, "item"), id, "item"))
async def list_items(request: Request):
"""List all items."""
docs = await svc(request).search({})
return [d.model_dump(mode="json", by_alias=True) for d in docs]
async def update_item(request: Request, id: int, body: dict) -> dict:
"""Replace an item by id.
Args:
request (Request): Incoming request.
id (int): Item identifier.
body (dict): Full replacement payload.
Returns:
dict: The updated item.
Raises:
HTTPException: 404 when the item does not exist.
"""
await read_or_404(svc(request, "item"), id, "item")
body.pop("id", None)
doc = await svc(request, "item").update(id, body)
return dump(doc)
async def delete_item(request: Request, id: str):
"""Delete one item by id."""
ok = await svc(request).delete(id)
if not ok:
return JSONResponse(status_code=404, content={"detail": "item not found"})
return {"deleted": True}
async def delete_item(request: Request, id: int, response: Response) -> None:
"""Delete an item by id.
Args:
request (Request): Incoming request.
id (int): Item identifier.
response (Response): Response object; set to 204 on success.
Raises:
HTTPException: 404 when the item does not exist.
"""
await delete_or_404(svc(request, "item"), id, "item")
response.status_code = 204

View File

@@ -2,33 +2,89 @@
Job route handlers — names must match openapi.yaml operationIds.
"""
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi import Request, Response
from domain.job.models import Job
from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc
def svc(request: Request):
"""Resolve the job CRUD service from app state."""
return request.app.state.db_resources["job_svc"]
async def list_jobs(request: Request, limit: int = 20, offset: int = 0) -> dict:
"""List jobs (paginated). ``limit=0`` returns all remaining items.
Args:
request (Request): Incoming request.
limit (int): Page size. Defaults to 20.
offset (int): Items to skip. Defaults to 0.
Returns:
dict: ``{"total": int, "items": list[Job]}``.
"""
docs = await svc(request, "job").search({})
return paginate([dump(d) for d in docs], limit, offset)
async def create_job(request: Request, body: dict):
"""Create a job from the request body."""
data = body.model_dump() if hasattr(body, "model_dump") else dict(body)
doc = await svc(request).create(Job(**data))
return JSONResponse(status_code=201, content=doc.model_dump(mode="json", by_alias=True))
async def create_job(request: Request, body: dict, response: Response) -> dict:
"""Create a job.
Args:
request (Request): Incoming request.
body (dict): Job fields (``id`` ignored).
response (Response): Response object; set to 201 on success.
Returns:
dict: The created job.
"""
response.status_code = 201
body.pop("id", None)
doc = await svc(request, "job").create(body)
return dump(doc)
async def get_job(request: Request, id: str):
"""Fetch one job by id."""
doc = await svc(request).read(id)
if doc is None:
return JSONResponse(status_code=404, content={"detail": "job not found"})
return doc.model_dump(mode="json", by_alias=True)
async def get_job(request: Request, id: int) -> dict:
"""Fetch one job by id.
Args:
request (Request): Incoming request.
id (int): Job identifier.
Returns:
dict: The job document.
Raises:
HTTPException: 404 when the job does not exist.
"""
return dump(await read_or_404(svc(request, "job"), id, "job"))
async def list_jobs(request: Request):
"""List all jobs."""
docs = await svc(request).search({})
return [d.model_dump(mode="json", by_alias=True) for d in docs]
async def update_job(request: Request, id: int, body: dict) -> dict:
"""Replace a job by id.
Args:
request (Request): Incoming request.
id (int): Job identifier.
body (dict): Full replacement payload.
Returns:
dict: The updated job.
Raises:
HTTPException: 404 when the job does not exist.
"""
await read_or_404(svc(request, "job"), id, "job")
body.pop("id", None)
doc = await svc(request, "job").update(id, body)
return dump(doc)
async def delete_job(request: Request, id: int, response: Response) -> None:
"""Delete a job by id.
Args:
request (Request): Incoming request.
id (int): Job identifier.
response (Response): Response object; set to 204 on success.
Raises:
HTTPException: 404 when the job does not exist.
"""
await delete_or_404(svc(request, "job"), id, "job")
response.status_code = 204

View File

@@ -6,4 +6,5 @@ re-exports every route handler from the subpackages into one flat namespace.
"""
# ruff: noqa: F403
from interfaces.item.routes import *
from interfaces.job.routes import *
from interfaces.job.routes import *
from interfaces.system.routes import *

View File

@@ -0,0 +1,3 @@
"""# Summary
System interfaces."""

View File

@@ -0,0 +1,13 @@
"""# Summary
System route handlers — health probe and other non-resource endpoints.
"""
async def health() -> dict:
"""Liveness probe — public, unauthenticated.
Returns:
dict: ``{"status": "ok", "service": "app-template"}``.
"""
return {"status": "ok", "service": "app-template"}

View File

@@ -3,6 +3,12 @@
FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
Handlers are looked up by operationId in interfaces.openapi, so no router tables
are declared here; the lifespan initializes and shuts down app resources.
---
``load_dotenv()`` runs before the app is constructed so ``{AUTH_SERVER}`` in
``openapi.yaml``'s bearer-JWT security scheme resolves from ``.env`` at build
time (openapi_first resolves env placeholders when the spec is loaded).
"""
# ruff: noqa: E402
import logging
@@ -12,8 +18,6 @@ from dotenv import load_dotenv
from openapi_first.app import OpenAPIFirstApp
from starlette.middleware.cors import CORSMiddleware
from init import init, shutdown
load_dotenv()
logging.basicConfig(
@@ -22,6 +26,7 @@ logging.basicConfig(
)
import interfaces.openapi as openapi_routes
from init import init, shutdown
logger = logging.getLogger(__name__)
@@ -47,9 +52,9 @@ async def lifespan(app: OpenAPIFirstApp):
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=openapi_routes,
title="Item API",
title="App Template API",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

View File

@@ -1,161 +1,451 @@
openapi: 3.0.3
openapi: 3.1.0
info:
title: Item API
title: App Template API
version: 0.1.0
paths:
/items:
get:
operationId: list_items
summary: List items
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Item"
post:
operationId: create_item
summary: Create an item
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ItemInput"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Item"
/items/{id}:
get:
operationId: get_item
summary: Get an item
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Item"
"404":
description: Not found
delete:
operationId: delete_item
summary: Delete an item
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Deleted
"404":
description: Not found
/jobs:
get:
operationId: list_jobs
summary: List jobs
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Job"
post:
operationId: create_job
summary: Create a job
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/JobInput"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Job"
/jobs/{id}:
get:
operationId: get_job
summary: Get a job
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Job"
"404":
description: Not found
description: |
App template API — OpenAPI-first backend pairing with the client template
and the @aetoskia/react-openapi admin panel renderer. Resources are driven
by OpenAPI x- extension fields; auth is bearer JWT introspected against
the auth-server. Item and Job are example domains — replace them with
your own, keeping the x- conventions documented in the react-openapi MCP
bundle (module react-openapi, object specExtensions).
servers:
- url: http://localhost:8000
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
x-server-url: "{AUTH_SERVER}"
x-introspect-path: "/introspect"
x-login-path: "/login"
x-register-path: "/register"
x-logout-path: "/logout"
x-me-path: "/me"
schemas:
Item:
type: object
required: [name, quantity]
x-resource: items
x-primary-key: id
x-display-format: "{name} × {quantity}"
x-list-columns: [name, quantity]
properties:
id:
type: string
name:
type: string
quantity:
type: integer
ItemInput:
type: object
required: [name]
properties:
readOnly: true
x-order: 0
x-hidden: { form: true, list: true }
x-label: "ID"
name:
type: string
x-order: 1
x-label: "Name"
x-description: "Item name"
x-filterable: true
x-sortable: true
x-autocomplete: token
quantity:
type: integer
default: 1
x-order: 2
x-label: "Quantity"
x-description: "How many units"
x-sortable: true
required: [id, name]
Job:
type: object
required: [name, status]
x-resource: jobs
x-primary-key: id
x-display-format: "{name} [{status}]"
x-list-columns: [name, status, retry_count]
properties:
id:
type: string
type: integer
readOnly: true
x-order: 0
x-hidden: { form: true, list: true }
x-label: "ID"
name:
type: string
x-order: 1
x-label: "Name"
x-description: "Job name"
x-filterable: true
x-autocomplete: token
status:
type: string
enum: [pending, processing, done, failed]
x-order: 2
x-label: "Status"
x-description: "Lifecycle status"
x-filterable: true
retry_count:
type: integer
readOnly: true
x-order: 3
x-label: "Retries"
error_message:
type: string
readOnly: true
x-order: 4
x-label: "Last Error"
payload:
type: object
JobInput:
x-order: 5
x-label: "Payload"
x-description: "Arbitrary job payload"
required: [id, name, status]
ErrorBody:
type: object
required: [name]
properties:
name:
detail:
type: string
payload:
type: object
required: [detail]
HTTPValidationError:
type: object
properties:
detail:
type: array
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
properties:
loc:
type: array
items:
type: string
msg:
type: string
type:
type: string
required: [loc, msg, type]
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
Forbidden:
description: Insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
ValidationError:
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
InternalServerError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
paths:
/health:
get:
summary: Liveness probe (public, unauthenticated)
operationId: health
security: []
responses:
'200':
description: Service is alive
content:
application/json:
schema:
type: object
properties:
status:
type: string
service:
type: string
/items:
get:
summary: List items (paginated)
operationId: list_items
parameters:
- in: query
name: limit
schema: {type: integer, default: 20}
- in: query
name: offset
schema: {type: integer, default: 0}
responses:
'200':
description: Paginated list of items
content:
application/json:
schema:
type: object
properties:
total:
type: integer
items:
type: array
items:
$ref: '#/components/schemas/Item'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
post:
summary: Create an item
operationId: create_item
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
responses:
'201':
description: Item created
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
/items/{id}:
get:
operationId: get_item
parameters:
- name: id
in: path
required: true
schema: {type: integer}
responses:
'200':
description: Single item
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
put:
operationId: update_item
parameters:
- name: id
in: path
required: true
schema: {type: integer}
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
responses:
'200':
description: Item updated
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
operationId: delete_item
parameters:
- name: id
in: path
required: true
schema: {type: integer}
responses:
'204':
description: Item deleted
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
/jobs:
get:
summary: List jobs (paginated)
operationId: list_jobs
parameters:
- in: query
name: limit
schema: {type: integer, default: 20}
- in: query
name: offset
schema: {type: integer, default: 0}
responses:
'200':
description: Paginated list of jobs
content:
application/json:
schema:
type: object
properties:
total:
type: integer
items:
type: array
items:
$ref: '#/components/schemas/Job'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
post:
summary: Create a job
operationId: create_job
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
responses:
'201':
description: Job created
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
/jobs/{id}:
get:
operationId: get_job
parameters:
- name: id
in: path
required: true
schema: {type: integer}
responses:
'200':
description: Single job
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
put:
operationId: update_job
parameters:
- name: id
in: path
required: true
schema: {type: integer}
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
responses:
'200':
description: Job updated
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
operationId: delete_job
parameters:
- name: id
in: path
required: true
schema: {type: integer}
responses:
'204':
description: Job deleted
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
'500':
$ref: '#/components/responses/InternalServerError'

View File

@@ -8,14 +8,25 @@ import interfaces.openapi as routes_module
def test_openapi_app_builds():
"""The spec-first app must build and plant each route handler."""
module = importlib.import_module(routes_module.__name__)
for operation in ("create_item", "get_item", "list_items", "delete_item",
"create_job", "get_job", "list_jobs"):
for operation in (
"health",
"list_items",
"create_item",
"get_item",
"update_item",
"delete_item",
"list_jobs",
"create_job",
"get_job",
"update_job",
"delete_job",
):
assert hasattr(module, operation)
app = OpenAPIFirstApp(
openapi_path="openapi.yaml",
routes_module=module,
title="Item API",
title="App Template API",
version="0.1.0",
)
assert app is not None
assert app is not None