From e88005d93658ba49dd3109eb70f55b99389925b9 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 19 Oct 2026 17:06:47 +0530 Subject: [PATCH] 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) --- standards/src/templates/app/.env.example | 4 +- standards/src/templates/app/config.py | 26 +- .../src/templates/app/domain/item/models.py | 4 +- .../src/templates/app/domain/item/ports/db.py | 20 +- .../src/templates/app/domain/job/models.py | 4 +- .../src/templates/app/domain/job/ports/db.py | 20 +- .../templates/app/infra/db/repos/__init__.py | 31 + .../templates/app/infra/db/repos/memory.py | 161 +++++ .../templates/app/infra/db/repos/mongo_int.py | 184 ++++++ standards/src/templates/app/init.py | 29 +- .../src/templates/app/interfaces/_common.py | 146 +++++ .../templates/app/interfaces/item/routes.py | 102 +++- .../templates/app/interfaces/job/routes.py | 98 +++- .../src/templates/app/interfaces/openapi.py | 3 +- .../app/interfaces/system/__init__.py | 3 + .../templates/app/interfaces/system/routes.py | 13 + standards/src/templates/app/main.py | 13 +- standards/src/templates/app/openapi.yaml | 548 +++++++++++++----- .../app/tests/integration/test_item_routes.py | 19 +- 19 files changed, 1213 insertions(+), 215 deletions(-) create mode 100644 standards/src/templates/app/infra/db/repos/memory.py create mode 100644 standards/src/templates/app/infra/db/repos/mongo_int.py create mode 100644 standards/src/templates/app/interfaces/_common.py create mode 100644 standards/src/templates/app/interfaces/system/__init__.py create mode 100644 standards/src/templates/app/interfaces/system/routes.py diff --git a/standards/src/templates/app/.env.example b/standards/src/templates/app/.env.example index a4f8c32..4f8cccb 100644 --- a/standards/src/templates/app/.env.example +++ b/standards/src/templates/app/.env.example @@ -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 \ No newline at end of file +JWT_SERVER=http://localhost:8000 diff --git a/standards/src/templates/app/config.py b/standards/src/templates/app/config.py index a163e38..9cab0b2 100644 --- a/standards/src/templates/app/config.py +++ b/standards/src/templates/app/config.py @@ -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")), -) \ No newline at end of file +) diff --git a/standards/src/templates/app/domain/item/models.py b/standards/src/templates/app/domain/item/models.py index 63c3875..0a8cc54 100644 --- a/standards/src/templates/app/domain/item/models.py +++ b/standards/src/templates/app/domain/item/models.py @@ -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"} \ No newline at end of file + model_config = {"extra": "ignore"} diff --git a/standards/src/templates/app/domain/item/ports/db.py b/standards/src/templates/app/domain/item/ports/db.py index d8905d2..ce08f65 100644 --- a/standards/src/templates/app/domain/item/ports/db.py +++ b/standards/src/templates/app/domain/item/ports/db.py @@ -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) \ No newline at end of file + return create_repo("items", ItemDocument) diff --git a/standards/src/templates/app/domain/job/models.py b/standards/src/templates/app/domain/job/models.py index 968d8c2..f90881b 100644 --- a/standards/src/templates/app/domain/job/models.py +++ b/standards/src/templates/app/domain/job/models.py @@ -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"} \ No newline at end of file + model_config = {"extra": "ignore"} diff --git a/standards/src/templates/app/domain/job/ports/db.py b/standards/src/templates/app/domain/job/ports/db.py index c085eb3..f437b3c 100644 --- a/standards/src/templates/app/domain/job/ports/db.py +++ b/standards/src/templates/app/domain/job/ports/db.py @@ -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) \ No newline at end of file + return create_repo("jobs", JobDocument) diff --git a/standards/src/templates/app/infra/db/repos/__init__.py b/standards/src/templates/app/infra/db/repos/__init__.py index e69de29..d2a94a5 100644 --- a/standards/src/templates/app/infra/db/repos/__init__.py +++ b/standards/src/templates/app/infra/db/repos/__init__.py @@ -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) diff --git a/standards/src/templates/app/infra/db/repos/memory.py b/standards/src/templates/app/infra/db/repos/memory.py new file mode 100644 index 0000000..1e55546 --- /dev/null +++ b/standards/src/templates/app/infra/db/repos/memory.py @@ -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 diff --git a/standards/src/templates/app/infra/db/repos/mongo_int.py b/standards/src/templates/app/infra/db/repos/mongo_int.py new file mode 100644 index 0000000..b2d13b6 --- /dev/null +++ b/standards/src/templates/app/infra/db/repos/mongo_int.py @@ -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 diff --git a/standards/src/templates/app/init.py b/standards/src/templates/app/init.py index 46b1a7f..83f3c74 100644 --- a/standards/src/templates/app/init.py +++ b/standards/src/templates/app/init.py @@ -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 + (``_svc`` and ``_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 \ No newline at end of file + _initialized = False diff --git a/standards/src/templates/app/interfaces/_common.py b/standards/src/templates/app/interfaces/_common.py new file mode 100644 index 0000000..428cacc --- /dev/null +++ b/standards/src/templates/app/interfaces/_common.py @@ -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") diff --git a/standards/src/templates/app/interfaces/item/routes.py b/standards/src/templates/app/interfaces/item/routes.py index 995f782..ae067aa 100644 --- a/standards/src/templates/app/interfaces/item/routes.py +++ b/standards/src/templates/app/interfaces/item/routes.py @@ -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} \ No newline at end of file +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 diff --git a/standards/src/templates/app/interfaces/job/routes.py b/standards/src/templates/app/interfaces/job/routes.py index 3d4f80f..78aacc8 100644 --- a/standards/src/templates/app/interfaces/job/routes.py +++ b/standards/src/templates/app/interfaces/job/routes.py @@ -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] \ No newline at end of file +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 diff --git a/standards/src/templates/app/interfaces/openapi.py b/standards/src/templates/app/interfaces/openapi.py index 489f65a..0c2f6a7 100644 --- a/standards/src/templates/app/interfaces/openapi.py +++ b/standards/src/templates/app/interfaces/openapi.py @@ -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 * \ No newline at end of file +from interfaces.job.routes import * +from interfaces.system.routes import * diff --git a/standards/src/templates/app/interfaces/system/__init__.py b/standards/src/templates/app/interfaces/system/__init__.py new file mode 100644 index 0000000..88b0746 --- /dev/null +++ b/standards/src/templates/app/interfaces/system/__init__.py @@ -0,0 +1,3 @@ +"""# Summary + +System interfaces.""" diff --git a/standards/src/templates/app/interfaces/system/routes.py b/standards/src/templates/app/interfaces/system/routes.py new file mode 100644 index 0000000..1f64931 --- /dev/null +++ b/standards/src/templates/app/interfaces/system/routes.py @@ -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"} diff --git a/standards/src/templates/app/main.py b/standards/src/templates/app/main.py index a569f7b..fb95627 100644 --- a/standards/src/templates/app/main.py +++ b/standards/src/templates/app/main.py @@ -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=["*"]) \ No newline at end of file +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) diff --git a/standards/src/templates/app/openapi.yaml b/standards/src/templates/app/openapi.yaml index 652471a..015b716 100644 --- a/standards/src/templates/app/openapi.yaml +++ b/standards/src/templates/app/openapi.yaml @@ -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 \ No newline at end of file + 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' diff --git a/standards/src/templates/app/tests/integration/test_item_routes.py b/standards/src/templates/app/tests/integration/test_item_routes.py index 3525d88..e9e6936 100644 --- a/standards/src/templates/app/tests/integration/test_item_routes.py +++ b/standards/src/templates/app/tests/integration/test_item_routes.py @@ -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 \ No newline at end of file + assert app is not None