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 DEBUG=false
REPO_BACKEND=memory
MONGO_URI=mongodb://localhost:27017 MONGO_URI=mongodb://localhost:27017
MONGO_DB=app_template MONGO_DB=app_template
PORT=8000
WORKER_POLL_SECONDS=30 WORKER_POLL_SECONDS=30
WORKER_MAX_RETRIES=3 WORKER_MAX_RETRIES=3
AUTH_SERVER=http://localhost:8000 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 App configuration loaded from environment — a Settings dataclass with defaults
plus a singleton settings instance populated from OS environment variables at plus a singleton settings instance populated from OS environment variables at
import time. 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 import os
from dataclasses import dataclass from dataclasses import dataclass
@@ -14,23 +31,30 @@ class Settings:
Attributes: Attributes:
debug (bool): Enable debug behavior. Defaults to False. 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_uri (str): MongoDB connection URI. Defaults to mongodb://localhost:27017.
mongo_db (str): MongoDB database name. Defaults to app_template. 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_poll_seconds (int): Worker poll interval in seconds. Defaults to 30.
worker_max_retries (int): Maximum job retries before skipping. Defaults to 3. worker_max_retries (int): Maximum job retries before skipping. Defaults to 3.
""" """
debug: bool = False debug: bool = False
repo_backend: str = "memory"
mongo_uri: str = "mongodb://localhost:27017" mongo_uri: str = "mongodb://localhost:27017"
mongo_db: str = "app_template" mongo_db: str = "app_template"
port: int = 8000
worker_poll_seconds: int = 30 worker_poll_seconds: int = 30
worker_max_retries: int = 3 worker_max_retries: int = 3
settings = Settings( settings = Settings(
debug=os.environ.get("DEBUG", "").lower() in ("1", "true", "yes"), 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_uri=os.environ.get("MONGO_URI", "mongodb://localhost:27017"),
mongo_db=os.environ.get("MONGO_DB", "app_template"), 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_poll_seconds=int(os.environ.get("WORKER_POLL_SECONDS", "30")),
worker_max_retries=int(os.environ.get("WORKER_MAX_RETRIES", "3")), worker_max_retries=int(os.environ.get("WORKER_MAX_RETRIES", "3")),
) )

View File

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

View File

@@ -1,21 +1,25 @@
"""# Summary """# 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 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): 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: def create_item_repo():
"""Create the Mongo-backed item repository. """Create the item repository selected by ``settings.repo_backend``.
Returns: 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. """A unit of background work.
Attributes: Attributes:
id (int | None): Server-assigned identifier. Defaults to None.
name (str): Job name. name (str): Job name.
status (str): Lifecycle status. Defaults to "pending". status (str): Lifecycle status. Defaults to "pending".
retry_count (int): Times the job failed. Defaults to 0. 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. payload (dict | None): Arbitrary job payload. Defaults to None.
""" """
id: int | None = None
name: str name: str
status: Literal["pending", "processing", "done", "failed"] = "pending" status: Literal["pending", "processing", "done", "failed"] = "pending"
retry_count: int = Field(default=0) retry_count: int = Field(default=0)
error_message: str | None = None error_message: str | None = None
payload: dict | None = None payload: dict | None = None
model_config = {"extra": "ignore"} model_config = {"extra": "ignore"}

View File

@@ -1,21 +1,25 @@
"""# Summary """# 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 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): 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: def create_job_repo():
"""Create the Mongo-backed job repository. """Create the job repository selected by ``settings.repo_backend``.
Returns: 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 """# Summary
Infrastructure initialization — connects MongoDB and wires all repos and services. Composition root — connects MongoDB (when selected), wires all repos and
Shared bootstrap for both the FastAPI app and the worker, guarded against services, and returns the resources dict. Shared bootstrap for the FastAPI
repeated initialization via a module-level flag. 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 from typing import Any
@@ -18,18 +25,21 @@ _initialized: bool = False
async def init() -> dict[str, Any]: 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. Idempotent: repeated calls short-circuit and return the cached resources.
Only the ``mongo`` backend performs a network connection here.
Returns: 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 global _resources, _initialized
if _initialized: if _initialized:
return _resources 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() item_repo = create_item_repo()
job_repo = create_job_repo() job_repo = create_job_repo()
@@ -44,8 +54,9 @@ async def init() -> dict[str, Any]:
async def shutdown() -> None: async def shutdown() -> None:
"""Disconnect MongoDB and reset the resources cache.""" """Disconnect MongoDB (if connected) and reset the resources cache."""
global _initialized global _initialized
await shutdown_database() if settings.repo_backend == "mongo":
await shutdown_database()
_resources.clear() _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. Item route handlers — names must match openapi.yaml operationIds.
""" """
from fastapi import Request from fastapi import Request, Response
from fastapi.responses import JSONResponse
from domain.item.models import Item from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc
def svc(request: Request): async def list_items(request: Request, limit: int = 20, offset: int = 0) -> dict:
"""Resolve the item CRUD service from app state.""" """List items (paginated). ``limit=0`` returns all remaining items.
return request.app.state.db_resources["item_svc"]
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): async def create_item(request: Request, body: dict, response: Response) -> dict:
"""Create an item from the request body.""" """Create an item.
data = body.model_dump() if hasattr(body, "model_dump") else dict(body)
doc = await svc(request).create(Item(**data)) Args:
return JSONResponse(status_code=201, content=doc.model_dump(mode="json", by_alias=True)) 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): async def get_item(request: Request, id: int) -> dict:
"""Fetch one item by id.""" """Fetch one item by id.
doc = await svc(request).read(id)
if doc is None: Args:
return JSONResponse(status_code=404, content={"detail": "item not found"}) request (Request): Incoming request.
return doc.model_dump(mode="json", by_alias=True) 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): async def update_item(request: Request, id: int, body: dict) -> dict:
"""List all items.""" """Replace an item by id.
docs = await svc(request).search({})
return [d.model_dump(mode="json", by_alias=True) for d in docs] 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): async def delete_item(request: Request, id: int, response: Response) -> None:
"""Delete one item by id.""" """Delete an item by id.
ok = await svc(request).delete(id)
if not ok: Args:
return JSONResponse(status_code=404, content={"detail": "item not found"}) request (Request): Incoming request.
return {"deleted": True} 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. Job route handlers — names must match openapi.yaml operationIds.
""" """
from fastapi import Request from fastapi import Request, Response
from fastapi.responses import JSONResponse
from domain.job.models import Job from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc
def svc(request: Request): async def list_jobs(request: Request, limit: int = 20, offset: int = 0) -> dict:
"""Resolve the job CRUD service from app state.""" """List jobs (paginated). ``limit=0`` returns all remaining items.
return request.app.state.db_resources["job_svc"]
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): async def create_job(request: Request, body: dict, response: Response) -> dict:
"""Create a job from the request body.""" """Create a job.
data = body.model_dump() if hasattr(body, "model_dump") else dict(body)
doc = await svc(request).create(Job(**data)) Args:
return JSONResponse(status_code=201, content=doc.model_dump(mode="json", by_alias=True)) 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): async def get_job(request: Request, id: int) -> dict:
"""Fetch one job by id.""" """Fetch one job by id.
doc = await svc(request).read(id)
if doc is None: Args:
return JSONResponse(status_code=404, content={"detail": "job not found"}) request (Request): Incoming request.
return doc.model_dump(mode="json", by_alias=True) 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): async def update_job(request: Request, id: int, body: dict) -> dict:
"""List all jobs.""" """Replace a job by id.
docs = await svc(request).search({})
return [d.model_dump(mode="json", by_alias=True) for d in docs] 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 # ruff: noqa: F403
from interfaces.item.routes import * 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. FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
Handlers are looked up by operationId in interfaces.openapi, so no router tables Handlers are looked up by operationId in interfaces.openapi, so no router tables
are declared here; the lifespan initializes and shuts down app resources. 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 # ruff: noqa: E402
import logging import logging
@@ -12,8 +18,6 @@ from dotenv import load_dotenv
from openapi_first.app import OpenAPIFirstApp from openapi_first.app import OpenAPIFirstApp
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from init import init, shutdown
load_dotenv() load_dotenv()
logging.basicConfig( logging.basicConfig(
@@ -22,6 +26,7 @@ logging.basicConfig(
) )
import interfaces.openapi as openapi_routes import interfaces.openapi as openapi_routes
from init import init, shutdown
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,9 +52,9 @@ async def lifespan(app: OpenAPIFirstApp):
app = OpenAPIFirstApp( app = OpenAPIFirstApp(
openapi_path="openapi.yaml", openapi_path="openapi.yaml",
routes_module=openapi_routes, routes_module=openapi_routes,
title="Item API", title="App Template API",
version="0.1.0", version="0.1.0",
lifespan=lifespan, 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: info:
title: Item API title: App Template API
version: 0.1.0 version: 0.1.0
paths: description: |
/items: App template API — OpenAPI-first backend pairing with the client template
get: and the @aetoskia/react-openapi admin panel renderer. Resources are driven
operationId: list_items by OpenAPI x- extension fields; auth is bearer JWT introspected against
summary: List items the auth-server. Item and Job are example domains — replace them with
responses: your own, keeping the x- conventions documented in the react-openapi MCP
"200": bundle (module react-openapi, object specExtensions).
description: OK
content: servers:
application/json: - url: http://localhost:8000
schema:
type: array security:
items: - bearerAuth: []
$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
components: 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: schemas:
Item: Item:
type: object type: object
required: [name, quantity] x-resource: items
x-primary-key: id
x-display-format: "{name} × {quantity}"
x-list-columns: [name, quantity]
properties: properties:
id: id:
type: string
name:
type: string
quantity:
type: integer type: integer
ItemInput: readOnly: true
type: object x-order: 0
required: [name] x-hidden: { form: true, list: true }
properties: x-label: "ID"
name: name:
type: string type: string
x-order: 1
x-label: "Name"
x-description: "Item name"
x-filterable: true
x-sortable: true
x-autocomplete: token
quantity: quantity:
type: integer type: integer
default: 1 default: 1
x-order: 2
x-label: "Quantity"
x-description: "How many units"
x-sortable: true
required: [id, name]
Job: Job:
type: object 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: properties:
id: id:
type: string type: integer
readOnly: true
x-order: 0
x-hidden: { form: true, list: true }
x-label: "ID"
name: name:
type: string type: string
x-order: 1
x-label: "Name"
x-description: "Job name"
x-filterable: true
x-autocomplete: token
status: status:
type: string type: string
enum: [pending, processing, done, failed] enum: [pending, processing, done, failed]
x-order: 2
x-label: "Status"
x-description: "Lifecycle status"
x-filterable: true
retry_count: retry_count:
type: integer type: integer
readOnly: true
x-order: 3
x-label: "Retries"
error_message: error_message:
type: string type: string
readOnly: true
x-order: 4
x-label: "Last Error"
payload: payload:
type: object type: object
JobInput: x-order: 5
x-label: "Payload"
x-description: "Arbitrary job payload"
required: [id, name, status]
ErrorBody:
type: object type: object
required: [name]
properties: properties:
name: detail:
type: string type: string
payload: required: [detail]
type: object
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(): def test_openapi_app_builds():
"""The spec-first app must build and plant each route handler.""" """The spec-first app must build and plant each route handler."""
module = importlib.import_module(routes_module.__name__) module = importlib.import_module(routes_module.__name__)
for operation in ("create_item", "get_item", "list_items", "delete_item", for operation in (
"create_job", "get_job", "list_jobs"): "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) assert hasattr(module, operation)
app = OpenAPIFirstApp( app = OpenAPIFirstApp(
openapi_path="openapi.yaml", openapi_path="openapi.yaml",
routes_module=module, routes_module=module,
title="Item API", title="App Template API",
version="0.1.0", version="0.1.0",
) )
assert app is not None assert app is not None