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:
@@ -1,7 +1,9 @@
|
||||
DEBUG=false
|
||||
REPO_BACKEND=memory
|
||||
MONGO_URI=mongodb://localhost:27017
|
||||
MONGO_DB=app_template
|
||||
PORT=8000
|
||||
WORKER_POLL_SECONDS=30
|
||||
WORKER_MAX_RETRIES=3
|
||||
AUTH_SERVER=http://localhost:8000
|
||||
JWT_SERVER=http://localhost:8000
|
||||
JWT_SERVER=http://localhost:8000
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,11 +9,13 @@ class Item(BaseModel):
|
||||
"""A single countable resource.
|
||||
|
||||
Attributes:
|
||||
id (int | None): Server-assigned identifier. Defaults to None.
|
||||
name (str): Item name.
|
||||
quantity (int): Item quantity. Defaults to 1.
|
||||
"""
|
||||
|
||||
id: int | None = None
|
||||
name: str
|
||||
quantity: int = Field(default=1)
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
"""# Summary
|
||||
|
||||
Mongo persistence for items: document schema and repository factory.
|
||||
Items persistence ports — integer-id document and repository factory.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
from mongo_ops import BaseDocument
|
||||
from pydantic import Field
|
||||
|
||||
from domain.item.models import Item
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from infra.db.repos import create_repo
|
||||
|
||||
|
||||
class ItemDocument(Item, BaseDocument):
|
||||
"""Mongo document form of :class:`Item`."""
|
||||
"""Persisted item document. The integer ``id`` shadows the ObjectId."""
|
||||
|
||||
id: int | None = Field(default=None, validation_alias="_id")
|
||||
|
||||
|
||||
def create_item_repo() -> BaseRepository:
|
||||
"""Create the Mongo-backed item repository.
|
||||
def create_item_repo():
|
||||
"""Create the item repository selected by ``settings.repo_backend``.
|
||||
|
||||
Returns:
|
||||
BaseRepository: Repository wired to the ``items`` collection.
|
||||
IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the
|
||||
``items`` collection/store.
|
||||
"""
|
||||
return mongo_repo_factory("items", ItemDocument)
|
||||
return create_repo("items", ItemDocument)
|
||||
|
||||
@@ -11,6 +11,7 @@ class Job(BaseModel):
|
||||
"""A unit of background work.
|
||||
|
||||
Attributes:
|
||||
id (int | None): Server-assigned identifier. Defaults to None.
|
||||
name (str): Job name.
|
||||
status (str): Lifecycle status. Defaults to "pending".
|
||||
retry_count (int): Times the job failed. Defaults to 0.
|
||||
@@ -18,10 +19,11 @@ class Job(BaseModel):
|
||||
payload (dict | None): Arbitrary job payload. Defaults to None.
|
||||
"""
|
||||
|
||||
id: int | None = None
|
||||
name: str
|
||||
status: Literal["pending", "processing", "done", "failed"] = "pending"
|
||||
retry_count: int = Field(default=0)
|
||||
error_message: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
"""# Summary
|
||||
|
||||
Mongo persistence for jobs: document schema and repository factory.
|
||||
Jobs persistence ports — integer-id document and repository factory.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
from mongo_ops import BaseDocument
|
||||
from pydantic import Field
|
||||
|
||||
from domain.job.models import Job
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from infra.db.repos import create_repo
|
||||
|
||||
|
||||
class JobDocument(Job, BaseDocument):
|
||||
"""Mongo document form of :class:`Job`."""
|
||||
"""Persisted job document. The integer ``id`` shadows the ObjectId."""
|
||||
|
||||
id: int | None = Field(default=None, validation_alias="_id")
|
||||
|
||||
|
||||
def create_job_repo() -> BaseRepository:
|
||||
"""Create the Mongo-backed job repository.
|
||||
def create_job_repo():
|
||||
"""Create the job repository selected by ``settings.repo_backend``.
|
||||
|
||||
Returns:
|
||||
BaseRepository: Repository wired to the ``jobs`` collection.
|
||||
IntIdMemoryRepository | IntIdMongoRepository: Repository wired to the
|
||||
``jobs`` collection/store.
|
||||
"""
|
||||
return mongo_repo_factory("jobs", JobDocument)
|
||||
return create_repo("jobs", JobDocument)
|
||||
|
||||
@@ -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)
|
||||
|
||||
161
standards/src/templates/app/infra/db/repos/memory.py
Normal file
161
standards/src/templates/app/infra/db/repos/memory.py
Normal 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
|
||||
184
standards/src/templates/app/infra/db/repos/mongo_int.py
Normal file
184
standards/src/templates/app/infra/db/repos/mongo_int.py
Normal 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
|
||||
@@ -1,8 +1,15 @@
|
||||
"""# Summary
|
||||
|
||||
Infrastructure initialization — connects MongoDB and wires all repos and services.
|
||||
Shared bootstrap for both the FastAPI app and the worker, guarded against
|
||||
repeated initialization via a module-level flag.
|
||||
Composition root — connects MongoDB (when selected), wires all repos and
|
||||
services, and returns the resources dict. Shared bootstrap for the FastAPI
|
||||
app, the worker and the seed/drop scripts, guarded against repeated
|
||||
initialization via a module-level flag.
|
||||
|
||||
---
|
||||
|
||||
The ``memory`` backend skips the Mongo connection entirely — repos are
|
||||
process-local dictionaries, so a plain ``uvicorn main:app`` runs with zero
|
||||
external services.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
@@ -18,18 +25,21 @@ _initialized: bool = False
|
||||
|
||||
|
||||
async def init() -> dict[str, Any]:
|
||||
"""Connect MongoDB, wire all repos and services, and return the resources dict.
|
||||
"""Wire all repos and services, and return the resources dict.
|
||||
|
||||
Idempotent: repeated calls short-circuit and return the cached resources.
|
||||
Only the ``mongo`` backend performs a network connection here.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Named services keyed by resource name.
|
||||
Dict[str, Any]: Named services keyed by resource name
|
||||
(``<resource>_svc`` and ``<resource>_repo``).
|
||||
"""
|
||||
global _resources, _initialized
|
||||
if _initialized:
|
||||
return _resources
|
||||
|
||||
await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)
|
||||
if settings.repo_backend == "mongo":
|
||||
await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)
|
||||
|
||||
item_repo = create_item_repo()
|
||||
job_repo = create_job_repo()
|
||||
@@ -44,8 +54,9 @@ async def init() -> dict[str, Any]:
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
"""Disconnect MongoDB and reset the resources cache."""
|
||||
"""Disconnect MongoDB (if connected) and reset the resources cache."""
|
||||
global _initialized
|
||||
await shutdown_database()
|
||||
if settings.repo_backend == "mongo":
|
||||
await shutdown_database()
|
||||
_resources.clear()
|
||||
_initialized = False
|
||||
_initialized = False
|
||||
|
||||
146
standards/src/templates/app/interfaces/_common.py
Normal file
146
standards/src/templates/app/interfaces/_common.py
Normal 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")
|
||||
@@ -2,41 +2,89 @@
|
||||
|
||||
Item route handlers — names must match openapi.yaml operationIds.
|
||||
"""
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import Request, Response
|
||||
|
||||
from domain.item.models import Item
|
||||
from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc
|
||||
|
||||
|
||||
def svc(request: Request):
|
||||
"""Resolve the item CRUD service from app state."""
|
||||
return request.app.state.db_resources["item_svc"]
|
||||
async def list_items(request: Request, limit: int = 20, offset: int = 0) -> dict:
|
||||
"""List items (paginated). ``limit=0`` returns all remaining items.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
limit (int): Page size. Defaults to 20.
|
||||
offset (int): Items to skip. Defaults to 0.
|
||||
|
||||
Returns:
|
||||
dict: ``{"total": int, "items": list[Item]}``.
|
||||
"""
|
||||
docs = await svc(request, "item").search({})
|
||||
return paginate([dump(d) for d in docs], limit, offset)
|
||||
|
||||
|
||||
async def create_item(request: Request, body: dict):
|
||||
"""Create an item from the request body."""
|
||||
data = body.model_dump() if hasattr(body, "model_dump") else dict(body)
|
||||
doc = await svc(request).create(Item(**data))
|
||||
return JSONResponse(status_code=201, content=doc.model_dump(mode="json", by_alias=True))
|
||||
async def create_item(request: Request, body: dict, response: Response) -> dict:
|
||||
"""Create an item.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
body (dict): Item fields (``id`` ignored).
|
||||
response (Response): Response object; set to 201 on success.
|
||||
|
||||
Returns:
|
||||
dict: The created item.
|
||||
"""
|
||||
response.status_code = 201
|
||||
body.pop("id", None)
|
||||
doc = await svc(request, "item").create(body)
|
||||
return dump(doc)
|
||||
|
||||
|
||||
async def get_item(request: Request, id: str):
|
||||
"""Fetch one item by id."""
|
||||
doc = await svc(request).read(id)
|
||||
if doc is None:
|
||||
return JSONResponse(status_code=404, content={"detail": "item not found"})
|
||||
return doc.model_dump(mode="json", by_alias=True)
|
||||
async def get_item(request: Request, id: int) -> dict:
|
||||
"""Fetch one item by id.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
id (int): Item identifier.
|
||||
|
||||
Returns:
|
||||
dict: The item document.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the item does not exist.
|
||||
"""
|
||||
return dump(await read_or_404(svc(request, "item"), id, "item"))
|
||||
|
||||
|
||||
async def list_items(request: Request):
|
||||
"""List all items."""
|
||||
docs = await svc(request).search({})
|
||||
return [d.model_dump(mode="json", by_alias=True) for d in docs]
|
||||
async def update_item(request: Request, id: int, body: dict) -> dict:
|
||||
"""Replace an item by id.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
id (int): Item identifier.
|
||||
body (dict): Full replacement payload.
|
||||
|
||||
Returns:
|
||||
dict: The updated item.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the item does not exist.
|
||||
"""
|
||||
await read_or_404(svc(request, "item"), id, "item")
|
||||
body.pop("id", None)
|
||||
doc = await svc(request, "item").update(id, body)
|
||||
return dump(doc)
|
||||
|
||||
|
||||
async def delete_item(request: Request, id: str):
|
||||
"""Delete one item by id."""
|
||||
ok = await svc(request).delete(id)
|
||||
if not ok:
|
||||
return JSONResponse(status_code=404, content={"detail": "item not found"})
|
||||
return {"deleted": True}
|
||||
async def delete_item(request: Request, id: int, response: Response) -> None:
|
||||
"""Delete an item by id.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
id (int): Item identifier.
|
||||
response (Response): Response object; set to 204 on success.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the item does not exist.
|
||||
"""
|
||||
await delete_or_404(svc(request, "item"), id, "item")
|
||||
response.status_code = 204
|
||||
|
||||
@@ -2,33 +2,89 @@
|
||||
|
||||
Job route handlers — names must match openapi.yaml operationIds.
|
||||
"""
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import Request, Response
|
||||
|
||||
from domain.job.models import Job
|
||||
from interfaces._common import delete_or_404, dump, paginate, read_or_404, svc
|
||||
|
||||
|
||||
def svc(request: Request):
|
||||
"""Resolve the job CRUD service from app state."""
|
||||
return request.app.state.db_resources["job_svc"]
|
||||
async def list_jobs(request: Request, limit: int = 20, offset: int = 0) -> dict:
|
||||
"""List jobs (paginated). ``limit=0`` returns all remaining items.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
limit (int): Page size. Defaults to 20.
|
||||
offset (int): Items to skip. Defaults to 0.
|
||||
|
||||
Returns:
|
||||
dict: ``{"total": int, "items": list[Job]}``.
|
||||
"""
|
||||
docs = await svc(request, "job").search({})
|
||||
return paginate([dump(d) for d in docs], limit, offset)
|
||||
|
||||
|
||||
async def create_job(request: Request, body: dict):
|
||||
"""Create a job from the request body."""
|
||||
data = body.model_dump() if hasattr(body, "model_dump") else dict(body)
|
||||
doc = await svc(request).create(Job(**data))
|
||||
return JSONResponse(status_code=201, content=doc.model_dump(mode="json", by_alias=True))
|
||||
async def create_job(request: Request, body: dict, response: Response) -> dict:
|
||||
"""Create a job.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
body (dict): Job fields (``id`` ignored).
|
||||
response (Response): Response object; set to 201 on success.
|
||||
|
||||
Returns:
|
||||
dict: The created job.
|
||||
"""
|
||||
response.status_code = 201
|
||||
body.pop("id", None)
|
||||
doc = await svc(request, "job").create(body)
|
||||
return dump(doc)
|
||||
|
||||
|
||||
async def get_job(request: Request, id: str):
|
||||
"""Fetch one job by id."""
|
||||
doc = await svc(request).read(id)
|
||||
if doc is None:
|
||||
return JSONResponse(status_code=404, content={"detail": "job not found"})
|
||||
return doc.model_dump(mode="json", by_alias=True)
|
||||
async def get_job(request: Request, id: int) -> dict:
|
||||
"""Fetch one job by id.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
id (int): Job identifier.
|
||||
|
||||
Returns:
|
||||
dict: The job document.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the job does not exist.
|
||||
"""
|
||||
return dump(await read_or_404(svc(request, "job"), id, "job"))
|
||||
|
||||
|
||||
async def list_jobs(request: Request):
|
||||
"""List all jobs."""
|
||||
docs = await svc(request).search({})
|
||||
return [d.model_dump(mode="json", by_alias=True) for d in docs]
|
||||
async def update_job(request: Request, id: int, body: dict) -> dict:
|
||||
"""Replace a job by id.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
id (int): Job identifier.
|
||||
body (dict): Full replacement payload.
|
||||
|
||||
Returns:
|
||||
dict: The updated job.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the job does not exist.
|
||||
"""
|
||||
await read_or_404(svc(request, "job"), id, "job")
|
||||
body.pop("id", None)
|
||||
doc = await svc(request, "job").update(id, body)
|
||||
return dump(doc)
|
||||
|
||||
|
||||
async def delete_job(request: Request, id: int, response: Response) -> None:
|
||||
"""Delete a job by id.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming request.
|
||||
id (int): Job identifier.
|
||||
response (Response): Response object; set to 204 on success.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the job does not exist.
|
||||
"""
|
||||
await delete_or_404(svc(request, "job"), id, "job")
|
||||
response.status_code = 204
|
||||
|
||||
@@ -6,4 +6,5 @@ re-exports every route handler from the subpackages into one flat namespace.
|
||||
"""
|
||||
# ruff: noqa: F403
|
||||
from interfaces.item.routes import *
|
||||
from interfaces.job.routes import *
|
||||
from interfaces.job.routes import *
|
||||
from interfaces.system.routes import *
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""# Summary
|
||||
|
||||
System interfaces."""
|
||||
13
standards/src/templates/app/interfaces/system/routes.py
Normal file
13
standards/src/templates/app/interfaces/system/routes.py
Normal 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"}
|
||||
@@ -3,6 +3,12 @@
|
||||
FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
|
||||
Handlers are looked up by operationId in interfaces.openapi, so no router tables
|
||||
are declared here; the lifespan initializes and shuts down app resources.
|
||||
|
||||
---
|
||||
|
||||
``load_dotenv()`` runs before the app is constructed so ``{AUTH_SERVER}`` in
|
||||
``openapi.yaml``'s bearer-JWT security scheme resolves from ``.env`` at build
|
||||
time (openapi_first resolves env placeholders when the spec is loaded).
|
||||
"""
|
||||
# ruff: noqa: E402
|
||||
import logging
|
||||
@@ -12,8 +18,6 @@ from dotenv import load_dotenv
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from init import init, shutdown
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -22,6 +26,7 @@ logging.basicConfig(
|
||||
)
|
||||
|
||||
import interfaces.openapi as openapi_routes
|
||||
from init import init, shutdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,9 +52,9 @@ async def lifespan(app: OpenAPIFirstApp):
|
||||
app = OpenAPIFirstApp(
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=openapi_routes,
|
||||
title="Item API",
|
||||
title="App Template API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
@@ -1,161 +1,451 @@
|
||||
openapi: 3.0.3
|
||||
openapi: 3.1.0
|
||||
|
||||
info:
|
||||
title: Item API
|
||||
title: App Template API
|
||||
version: 0.1.0
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: list_items
|
||||
summary: List items
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Item"
|
||||
post:
|
||||
operationId: create_item
|
||||
summary: Create an item
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemInput"
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
/items/{id}:
|
||||
get:
|
||||
operationId: get_item
|
||||
summary: Get an item
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
"404":
|
||||
description: Not found
|
||||
delete:
|
||||
operationId: delete_item
|
||||
summary: Delete an item
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Deleted
|
||||
"404":
|
||||
description: Not found
|
||||
/jobs:
|
||||
get:
|
||||
operationId: list_jobs
|
||||
summary: List jobs
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Job"
|
||||
post:
|
||||
operationId: create_job
|
||||
summary: Create a job
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobInput"
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Job"
|
||||
/jobs/{id}:
|
||||
get:
|
||||
operationId: get_job
|
||||
summary: Get a job
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Job"
|
||||
"404":
|
||||
description: Not found
|
||||
description: |
|
||||
App template API — OpenAPI-first backend pairing with the client template
|
||||
and the @aetoskia/react-openapi admin panel renderer. Resources are driven
|
||||
by OpenAPI x- extension fields; auth is bearer JWT introspected against
|
||||
the auth-server. Item and Job are example domains — replace them with
|
||||
your own, keeping the x- conventions documented in the react-openapi MCP
|
||||
bundle (module react-openapi, object specExtensions).
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8000
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
x-server-url: "{AUTH_SERVER}"
|
||||
x-introspect-path: "/introspect"
|
||||
x-login-path: "/login"
|
||||
x-register-path: "/register"
|
||||
x-logout-path: "/logout"
|
||||
x-me-path: "/me"
|
||||
|
||||
schemas:
|
||||
Item:
|
||||
type: object
|
||||
required: [name, quantity]
|
||||
x-resource: items
|
||||
x-primary-key: id
|
||||
x-display-format: "{name} × {quantity}"
|
||||
x-list-columns: [name, quantity]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
ItemInput:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
readOnly: true
|
||||
x-order: 0
|
||||
x-hidden: { form: true, list: true }
|
||||
x-label: "ID"
|
||||
name:
|
||||
type: string
|
||||
x-order: 1
|
||||
x-label: "Name"
|
||||
x-description: "Item name"
|
||||
x-filterable: true
|
||||
x-sortable: true
|
||||
x-autocomplete: token
|
||||
quantity:
|
||||
type: integer
|
||||
default: 1
|
||||
x-order: 2
|
||||
x-label: "Quantity"
|
||||
x-description: "How many units"
|
||||
x-sortable: true
|
||||
required: [id, name]
|
||||
|
||||
Job:
|
||||
type: object
|
||||
required: [name, status]
|
||||
x-resource: jobs
|
||||
x-primary-key: id
|
||||
x-display-format: "{name} [{status}]"
|
||||
x-list-columns: [name, status, retry_count]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type: integer
|
||||
readOnly: true
|
||||
x-order: 0
|
||||
x-hidden: { form: true, list: true }
|
||||
x-label: "ID"
|
||||
name:
|
||||
type: string
|
||||
x-order: 1
|
||||
x-label: "Name"
|
||||
x-description: "Job name"
|
||||
x-filterable: true
|
||||
x-autocomplete: token
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, processing, done, failed]
|
||||
x-order: 2
|
||||
x-label: "Status"
|
||||
x-description: "Lifecycle status"
|
||||
x-filterable: true
|
||||
retry_count:
|
||||
type: integer
|
||||
readOnly: true
|
||||
x-order: 3
|
||||
x-label: "Retries"
|
||||
error_message:
|
||||
type: string
|
||||
readOnly: true
|
||||
x-order: 4
|
||||
x-label: "Last Error"
|
||||
payload:
|
||||
type: object
|
||||
JobInput:
|
||||
x-order: 5
|
||||
x-label: "Payload"
|
||||
x-description: "Arbitrary job payload"
|
||||
required: [id, name, status]
|
||||
|
||||
ErrorBody:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
detail:
|
||||
type: string
|
||||
payload:
|
||||
type: object
|
||||
required: [detail]
|
||||
|
||||
HTTPValidationError:
|
||||
type: object
|
||||
properties:
|
||||
detail:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
loc:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
msg:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required: [loc, msg, type]
|
||||
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
Forbidden:
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
ValidationError:
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
InternalServerError:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: Liveness probe (public, unauthenticated)
|
||||
operationId: health
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Service is alive
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
service:
|
||||
type: string
|
||||
|
||||
/items:
|
||||
get:
|
||||
summary: List items (paginated)
|
||||
operationId: list_items
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
schema: {type: integer, default: 20}
|
||||
- in: query
|
||||
name: offset
|
||||
schema: {type: integer, default: 0}
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated list of items
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Item'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
post:
|
||||
summary: Create an item
|
||||
operationId: create_item
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Item'
|
||||
responses:
|
||||
'201':
|
||||
description: Item created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Item'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
/items/{id}:
|
||||
get:
|
||||
operationId: get_item
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
responses:
|
||||
'200':
|
||||
description: Single item
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Item'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
put:
|
||||
operationId: update_item
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Item'
|
||||
responses:
|
||||
'200':
|
||||
description: Item updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Item'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
delete:
|
||||
operationId: delete_item
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
responses:
|
||||
'204':
|
||||
description: Item deleted
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/jobs:
|
||||
get:
|
||||
summary: List jobs (paginated)
|
||||
operationId: list_jobs
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
schema: {type: integer, default: 20}
|
||||
- in: query
|
||||
name: offset
|
||||
schema: {type: integer, default: 0}
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated list of jobs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
post:
|
||||
summary: Create a job
|
||||
operationId: create_job
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
responses:
|
||||
'201':
|
||||
description: Job created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
/jobs/{id}:
|
||||
get:
|
||||
operationId: get_job
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
responses:
|
||||
'200':
|
||||
description: Single job
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
put:
|
||||
operationId: update_job
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
responses:
|
||||
'200':
|
||||
description: Job updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
delete:
|
||||
operationId: delete_job
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
responses:
|
||||
'204':
|
||||
description: Job deleted
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
@@ -8,14 +8,25 @@ import interfaces.openapi as routes_module
|
||||
def test_openapi_app_builds():
|
||||
"""The spec-first app must build and plant each route handler."""
|
||||
module = importlib.import_module(routes_module.__name__)
|
||||
for operation in ("create_item", "get_item", "list_items", "delete_item",
|
||||
"create_job", "get_job", "list_jobs"):
|
||||
for operation in (
|
||||
"health",
|
||||
"list_items",
|
||||
"create_item",
|
||||
"get_item",
|
||||
"update_item",
|
||||
"delete_item",
|
||||
"list_jobs",
|
||||
"create_job",
|
||||
"get_job",
|
||||
"update_job",
|
||||
"delete_job",
|
||||
):
|
||||
assert hasattr(module, operation)
|
||||
|
||||
app = OpenAPIFirstApp(
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=module,
|
||||
title="Item API",
|
||||
title="App Template API",
|
||||
version="0.1.0",
|
||||
)
|
||||
assert app is not None
|
||||
assert app is not None
|
||||
|
||||
Reference in New Issue
Block a user