feat(standards): fix F11-F16 and rebuild bundle (43 components) + trial findings
- F11 PopulateRule field_name/collection_name corrections - F12 service adaptor update handles PopulatingRepository - F13 nested-references section; F14 authless /users/me guidance - F15 template ruff-clean; F16 stop vendoring .ruff_cache, ship .gitignore - document --template-style in build_recipe/spec_first_routing; refresh collected openapi-first + standards bundle (12 modules, 43 components) - add trial.md and trial_findings.md (F1-F16, re-trial A/B evidence)
This commit is contained in:
7
build.py
7
build.py
@@ -241,6 +241,8 @@ _TEMPLATE_COMPONENTS: list[dict[str, str]] = [
|
|||||||
"description": "API + worker image with private-index wiring."},
|
"description": "API + worker image with private-index wiring."},
|
||||||
{"kind": "dockerignore", "path": ".dockerignore", "placeholder": "",
|
{"kind": "dockerignore", "path": ".dockerignore", "placeholder": "",
|
||||||
"description": "Docker build-context exclusions."},
|
"description": "Docker build-context exclusions."},
|
||||||
|
{"kind": "gitignore", "path": ".gitignore", "placeholder": "",
|
||||||
|
"description": "VCS exclusions (caches, venv, generated _gen/)."},
|
||||||
{"kind": "compose", "path": "docker-compose.yaml", "placeholder": "",
|
{"kind": "compose", "path": "docker-compose.yaml", "placeholder": "",
|
||||||
"description": "api + worker + mongo compose file."},
|
"description": "api + worker + mongo compose file."},
|
||||||
{"kind": "env-example", "path": ".env.example", "placeholder": "",
|
{"kind": "env-example", "path": ".env.example", "placeholder": "",
|
||||||
@@ -319,7 +321,10 @@ def build_templates(folder: Path, dry_run: bool = False) -> None:
|
|||||||
out_dir.parent.mkdir(parents=True, exist_ok=True)
|
out_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copytree(
|
shutil.copytree(
|
||||||
src, out_dir,
|
src, out_dir,
|
||||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
ignore=shutil.ignore_patterns(
|
||||||
|
"__pycache__", "*.pyc", ".ruff_cache", ".pytest_cache",
|
||||||
|
".mypy_cache", ".venv", "venv", ".git", "*.egg-info", "htmlcov",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
manifest = build_components_manifest(out_dir)
|
manifest = build_components_manifest(out_dir)
|
||||||
(folder / "mcp" / "components.json").write_text(
|
(folder / "mcp" / "components.json").write_text(
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
"name": "generate_routes",
|
"name": "generate_routes",
|
||||||
"kind": "function",
|
"kind": "function",
|
||||||
"path": "openapi_first.codegen.generate_routes",
|
"path": "openapi_first.codegen.generate_routes",
|
||||||
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False)",
|
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False, template_style: bool = False)",
|
||||||
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n template_style (bool, optional):\n If ``True``, emit ``async def`` handlers with a JSON body parameter\n named ``body`` (the standards-template convention) plus a\n service-resolution hint comment, instead of sync ``def`` handlers\n taking ``payload``.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
||||||
},
|
},
|
||||||
"generate_models": {
|
"generate_models": {
|
||||||
"name": "generate_models",
|
"name": "generate_models",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"module": "openapi_first.codegen_routes",
|
"module": "openapi_first.codegen_routes",
|
||||||
"content": {
|
"content": {
|
||||||
"path": "openapi_first.codegen_routes",
|
"path": "openapi_first.codegen_routes",
|
||||||
"docstring": "# Summary\n\nRoute handler code generation from OpenAPI specifications.\n\nThis module generates Python route handler stubs from an OpenAPI 3.x\nspecification. Each resource (derived from the first path segment)\ngets its own file under the output directory. Every OpenAPI operation\nmust define an ``operationId``, which becomes the handler function name.\n\nNotes:\n **Design constraints:**\n\n - ``operationId`` is required on every operation (matching\n ``binder.bind_routes``).\n - Handlers are stubs raising ``NotImplementedError``.\n - Sub-resources (e.g. ``/pets/{id}/photo``) are grouped with their\n parent resource (``pets``).\n - Parameter types and defaults are inferred from the spec.\n - ``response: Response`` is injected for non-200 success codes.\n - Resource names are sanitized to valid Python module identifiers,\n so a path such as ``/fetch-requests`` yields ``fetch_requests.py``.\n - Passing ``inject_request=True`` prepends ``request: Request`` to\n every handler (the composition-root / DI convention).\n - ``multipart/form-data`` and\n ``application/x-www-form-urlencoded`` request bodies expand to one\n parameter per schema property (``UploadFile`` / ``File`` / ``Form``)\n instead of an opaque ``payload: dict``.\n - Passing ``package=True`` writes ``<resource>/routes.py`` (with\n ``__init__.py`` sidecars) instead of a flat ``<resource>.py``.",
|
"docstring": "# Summary\n\nRoute handler code generation from OpenAPI specifications.\n\nThis module generates Python route handler stubs from an OpenAPI 3.x\nspecification. Each resource (derived from the first path segment)\ngets its own file under the output directory. Every OpenAPI operation\nmust define an ``operationId``, which becomes the handler function name.\n\nNotes:\n **Design constraints:**\n\n - ``operationId`` is required on every operation (matching\n ``binder.bind_routes``).\n - Handlers are stubs raising ``NotImplementedError``.\n - Sub-resources (e.g. ``/pets/{id}/photo``) are grouped with their\n parent resource (``pets``).\n - Parameter types and defaults are inferred from the spec.\n - ``response: Response`` is injected for non-200 success codes.\n - Resource names are sanitized to valid Python module identifiers,\n so a path such as ``/fetch-requests`` yields ``fetch_requests.py``.\n - Passing ``inject_request=True`` prepends ``request: Request`` to\n every handler (the composition-root / DI convention).\n - ``multipart/form-data`` and\n ``application/x-www-form-urlencoded`` request bodies expand to one\n parameter per schema property (``UploadFile`` / ``File`` / ``Form``)\n instead of an opaque ``payload: dict``.\n - Passing ``package=True`` writes ``<resource>/routes.py`` (with\n ``__init__.py`` sidecars) instead of a flat ``<resource>.py``.\n - Passing ``template_style=True`` emits ``async def`` handlers whose\n JSON body parameter is named ``body`` (matching the standards\n template) and adds a service-resolution hint comment.",
|
||||||
"objects": {
|
"objects": {
|
||||||
"load_openapi": {
|
"load_openapi": {
|
||||||
"name": "load_openapi",
|
"name": "load_openapi",
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"name": "generate_routes",
|
"name": "generate_routes",
|
||||||
"kind": "function",
|
"kind": "function",
|
||||||
"path": "openapi_first.codegen_routes.generate_routes",
|
"path": "openapi_first.codegen_routes.generate_routes",
|
||||||
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False) -> list[Path]",
|
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False, template_style: bool = False) -> list[Path]",
|
||||||
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n template_style (bool, optional):\n If ``True``, emit ``async def`` handlers with a JSON body parameter\n named ``body`` (the standards-template convention) plus a\n service-resolution hint comment, instead of sync ``def`` handlers\n taking ``payload``.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,8 +188,8 @@
|
|||||||
"name": "generate_routes",
|
"name": "generate_routes",
|
||||||
"kind": "function",
|
"kind": "function",
|
||||||
"path": "openapi_first.codegen.generate_routes",
|
"path": "openapi_first.codegen.generate_routes",
|
||||||
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False)",
|
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False, template_style: bool = False)",
|
||||||
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n template_style (bool, optional):\n If ``True``, emit ``async def`` handlers with a JSON body parameter\n named ``body`` (the standards-template convention) plus a\n service-resolution hint comment, instead of sync ``def`` handlers\n taking ``payload``.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
||||||
},
|
},
|
||||||
"generate_models": {
|
"generate_models": {
|
||||||
"name": "generate_models",
|
"name": "generate_models",
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
"kind": "module",
|
"kind": "module",
|
||||||
"path": "openapi_first.codegen_routes",
|
"path": "openapi_first.codegen_routes",
|
||||||
"signature": null,
|
"signature": null,
|
||||||
"docstring": "# Summary\n\nRoute handler code generation from OpenAPI specifications.\n\nThis module generates Python route handler stubs from an OpenAPI 3.x\nspecification. Each resource (derived from the first path segment)\ngets its own file under the output directory. Every OpenAPI operation\nmust define an ``operationId``, which becomes the handler function name.\n\nNotes:\n **Design constraints:**\n\n - ``operationId`` is required on every operation (matching\n ``binder.bind_routes``).\n - Handlers are stubs raising ``NotImplementedError``.\n - Sub-resources (e.g. ``/pets/{id}/photo``) are grouped with their\n parent resource (``pets``).\n - Parameter types and defaults are inferred from the spec.\n - ``response: Response`` is injected for non-200 success codes.\n - Resource names are sanitized to valid Python module identifiers,\n so a path such as ``/fetch-requests`` yields ``fetch_requests.py``.\n - Passing ``inject_request=True`` prepends ``request: Request`` to\n every handler (the composition-root / DI convention).\n - ``multipart/form-data`` and\n ``application/x-www-form-urlencoded`` request bodies expand to one\n parameter per schema property (``UploadFile`` / ``File`` / ``Form``)\n instead of an opaque ``payload: dict``.\n - Passing ``package=True`` writes ``<resource>/routes.py`` (with\n ``__init__.py`` sidecars) instead of a flat ``<resource>.py``.",
|
"docstring": "# Summary\n\nRoute handler code generation from OpenAPI specifications.\n\nThis module generates Python route handler stubs from an OpenAPI 3.x\nspecification. Each resource (derived from the first path segment)\ngets its own file under the output directory. Every OpenAPI operation\nmust define an ``operationId``, which becomes the handler function name.\n\nNotes:\n **Design constraints:**\n\n - ``operationId`` is required on every operation (matching\n ``binder.bind_routes``).\n - Handlers are stubs raising ``NotImplementedError``.\n - Sub-resources (e.g. ``/pets/{id}/photo``) are grouped with their\n parent resource (``pets``).\n - Parameter types and defaults are inferred from the spec.\n - ``response: Response`` is injected for non-200 success codes.\n - Resource names are sanitized to valid Python module identifiers,\n so a path such as ``/fetch-requests`` yields ``fetch_requests.py``.\n - Passing ``inject_request=True`` prepends ``request: Request`` to\n every handler (the composition-root / DI convention).\n - ``multipart/form-data`` and\n ``application/x-www-form-urlencoded`` request bodies expand to one\n parameter per schema property (``UploadFile`` / ``File`` / ``Form``)\n instead of an opaque ``payload: dict``.\n - Passing ``package=True`` writes ``<resource>/routes.py`` (with\n ``__init__.py`` sidecars) instead of a flat ``<resource>.py``.\n - Passing ``template_style=True`` emits ``async def`` handlers whose\n JSON body parameter is named ``body`` (matching the standards\n template) and adds a service-resolution hint comment.",
|
||||||
"members": {
|
"members": {
|
||||||
"load_openapi": {
|
"load_openapi": {
|
||||||
"name": "load_openapi",
|
"name": "load_openapi",
|
||||||
@@ -218,8 +218,8 @@
|
|||||||
"name": "generate_routes",
|
"name": "generate_routes",
|
||||||
"kind": "function",
|
"kind": "function",
|
||||||
"path": "openapi_first.codegen_routes.generate_routes",
|
"path": "openapi_first.codegen_routes.generate_routes",
|
||||||
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False) -> list[Path]",
|
"signature": "generate_routes(spec_path: Path, output_dir: Path, *, use_models: bool = False, models_module: str = 'models', inject_request: bool = False, package: bool = False, template_style: bool = False) -> list[Path]",
|
||||||
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
"docstring": "Generate route handler stubs from an OpenAPI specification.\n\nCreates one route module per resource in *output_dir*. Resources are\nderived from the first path segment (e.g. ``/pets`` and ``/pets/{id}``\nboth group under ``pets``) and sanitized to valid module identifiers.\n\nArgs:\n spec_path (Path):\n Path to the OpenAPI specification file (YAML or JSON).\n output_dir (Path):\n Directory where the generated route files are written.\n Created automatically if it does not exist.\n use_models (bool, optional):\n If ``True``, import Pydantic models from *models_module*\n for request-body schemas referenced via ``$ref``.\n models_module (str, optional):\n Dotted Python module path from which to import models\n (e.g. ``\"models\"``, ``\"app.models\"``).\n inject_request (bool, optional):\n If ``True``, prepend ``request: Request`` to every handler so the\n stub can later resolve services from ``request.app.state``.\n package (bool, optional):\n If ``True``, emit ``<resource>/routes.py`` packages (each with an\n ``__init__.py``) instead of flat ``<resource>.py`` modules.\n template_style (bool, optional):\n If ``True``, emit ``async def`` handlers with a JSON body parameter\n named ``body`` (the standards-template convention) plus a\n service-resolution hint comment, instead of sync ``def`` handlers\n taking ``payload``.\n\nReturns:\n list[Path]:\n Absolute paths of every generated route file.\n\nRaises:\n OpenAPISpecLoadError:\n If the spec cannot be loaded or validated.\n ValueError:\n If any operation is missing ``operationId``."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -811,7 +811,10 @@ environment-only; never commit them.</p>
|
|||||||
<span class="normal"><a href="#__codelineno-2-85">85</a></span>
|
<span class="normal"><a href="#__codelineno-2-85">85</a></span>
|
||||||
<span class="normal"><a href="#__codelineno-2-86">86</a></span>
|
<span class="normal"><a href="#__codelineno-2-86">86</a></span>
|
||||||
<span class="normal"><a href="#__codelineno-2-87">87</a></span>
|
<span class="normal"><a href="#__codelineno-2-87">87</a></span>
|
||||||
<span class="normal"><a href="#__codelineno-2-88">88</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1"></a><span class="gh"># Build a new application from the Aetos Standards blueprint</span>
|
<span class="normal"><a href="#__codelineno-2-88">88</a></span>
|
||||||
|
<span class="normal"><a href="#__codelineno-2-89">89</a></span>
|
||||||
|
<span class="normal"><a href="#__codelineno-2-90">90</a></span>
|
||||||
|
<span class="normal"><a href="#__codelineno-2-91">91</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1"></a><span class="gh"># Build a new application from the Aetos Standards blueprint</span>
|
||||||
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a>
|
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a>
|
||||||
</span><span id="__span-2-3"><a id="__codelineno-2-3" name="__codelineno-2-3"></a>You are coding a complete new backend application conforming to the Aetos
|
</span><span id="__span-2-3"><a id="__codelineno-2-3" name="__codelineno-2-3"></a>You are coding a complete new backend application conforming to the Aetos
|
||||||
</span><span id="__span-2-4"><a id="__codelineno-2-4" name="__codelineno-2-4"></a>standard app blueprint. Execute every step in order. Only steps 2–4 use the
|
</span><span id="__span-2-4"><a id="__codelineno-2-4" name="__codelineno-2-4"></a>standard app blueprint. Execute every step in order. Only steps 2–4 use the
|
||||||
@@ -848,57 +851,60 @@ environment-only; never commit them.</p>
|
|||||||
</span><span id="__span-2-35"><a id="__codelineno-2-35" name="__codelineno-2-35"></a><span class="sb"> ```bash</span>
|
</span><span id="__span-2-35"><a id="__codelineno-2-35" name="__codelineno-2-35"></a><span class="sb"> ```bash</span>
|
||||||
</span><span id="__span-2-36"><a id="__codelineno-2-36" name="__codelineno-2-36"></a><span class="w"> </span>openapi-first<span class="w"> </span>models<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/models.py
|
</span><span id="__span-2-36"><a id="__codelineno-2-36" name="__codelineno-2-36"></a><span class="w"> </span>openapi-first<span class="w"> </span>models<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/models.py
|
||||||
</span><span id="__span-2-37"><a id="__codelineno-2-37" name="__codelineno-2-37"></a><span class="w"> </span>openapi-first<span class="w"> </span>routes<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/routes<span class="w"> </span><span class="se">\</span>
|
</span><span id="__span-2-37"><a id="__codelineno-2-37" name="__codelineno-2-37"></a><span class="w"> </span>openapi-first<span class="w"> </span>routes<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/routes<span class="w"> </span><span class="se">\</span>
|
||||||
</span><span id="__span-2-38"><a id="__codelineno-2-38" name="__codelineno-2-38"></a><span class="w"> </span>--use-models<span class="w"> </span>--models-module<span class="w"> </span>_gen.models<span class="w"> </span>--inject-request<span class="w"> </span>--package
|
</span><span id="__span-2-38"><a id="__codelineno-2-38" name="__codelineno-2-38"></a><span class="w"> </span>--use-models<span class="w"> </span>--models-module<span class="w"> </span>_gen.models<span class="w"> </span>--inject-request<span class="w"> </span>--package<span class="w"> </span>--template-style
|
||||||
</span><span id="__span-2-39"><a id="__codelineno-2-39" name="__codelineno-2-39"></a><span class="sb"> ```</span>
|
</span><span id="__span-2-39"><a id="__codelineno-2-39" name="__codelineno-2-39"></a><span class="sb"> ```</span>
|
||||||
</span><span id="__span-2-40"><a id="__codelineno-2-40" name="__codelineno-2-40"></a> <span class="sb">`_gen/models.py`</span> is the <span class="gs">**wire contract**</span> (Pydantic, one class per
|
</span><span id="__span-2-40"><a id="__codelineno-2-40" name="__codelineno-2-40"></a> <span class="sb">`_gen/models.py`</span> is the <span class="gs">**wire contract**</span> (Pydantic, one class per
|
||||||
</span><span id="__span-2-41"><a id="__codelineno-2-41" name="__codelineno-2-41"></a> <span class="sb">`components.schemas`</span> entry). <span class="sb">`_gen/routes/<resource>/routes.py`</span> lists every
|
</span><span id="__span-2-41"><a id="__codelineno-2-41" name="__codelineno-2-41"></a> <span class="sb">`components.schemas`</span> entry). <span class="sb">`_gen/routes/<resource>/routes.py`</span> lists every
|
||||||
</span><span id="__span-2-42"><a id="__codelineno-2-42" name="__codelineno-2-42"></a> <span class="sb">`operationId`</span> with its exact parameter signature. Treat <span class="sb">`_gen/`</span> as generated
|
</span><span id="__span-2-42"><a id="__codelineno-2-42" name="__codelineno-2-42"></a> <span class="sb">`operationId`</span> with its exact parameter signature. <span class="sb">`--template-style`</span> makes
|
||||||
</span><span id="__span-2-43"><a id="__codelineno-2-43" name="__codelineno-2-43"></a> (never hand-edit it) — the canonical handlers live in <span class="sb">`interfaces/`</span>.
|
</span><span id="__span-2-43"><a id="__codelineno-2-43" name="__codelineno-2-43"></a> those stubs <span class="sb">`async def`</span> with a <span class="sb">`body`</span> parameter, matching the handlers you
|
||||||
</span><span id="__span-2-44"><a id="__codelineno-2-44" name="__codelineno-2-44"></a>
|
</span><span id="__span-2-44"><a id="__codelineno-2-44" name="__codelineno-2-44"></a> keep in <span class="sb">`interfaces/`</span> (without it they are sync <span class="sb">`def`</span> stubs taking
|
||||||
</span><span id="__span-2-45"><a id="__codelineno-2-45" name="__codelineno-2-45"></a><span class="k">4.</span> <span class="gs">**Write each CRUD slice (app-specific)**</span> — per resource create the four-file
|
</span><span id="__span-2-45"><a id="__codelineno-2-45" name="__codelineno-2-45"></a> <span class="sb">`payload`</span>; both bind the same way). Treat <span class="sb">`_gen/`</span> as generated
|
||||||
</span><span id="__span-2-46"><a id="__codelineno-2-46" name="__codelineno-2-46"></a> slice: <span class="sb">`models.py`</span> (domain model, <span class="gs">**no `id`**</span>), <span class="sb">`ports/db.py`</span>
|
</span><span id="__span-2-46"><a id="__codelineno-2-46" name="__codelineno-2-46"></a> (never hand-edit it) — the canonical handlers live in <span class="sb">`interfaces/`</span>.
|
||||||
</span><span id="__span-2-47"><a id="__codelineno-2-47" name="__codelineno-2-47"></a> (<span class="sb">`class XDocument(BaseDocument, X)`</span> — id comes from <span class="sb">`BaseDocument`</span>),
|
</span><span id="__span-2-47"><a id="__codelineno-2-47" name="__codelineno-2-47"></a>
|
||||||
</span><span id="__span-2-48"><a id="__codelineno-2-48" name="__codelineno-2-48"></a> <span class="sb">`ports/service.py`</span>, and optional <span class="sb">`converters.py`</span>. Follow the CRUD template.
|
</span><span id="__span-2-48"><a id="__codelineno-2-48" name="__codelineno-2-48"></a><span class="k">4.</span> <span class="gs">**Write each CRUD slice (app-specific)**</span> — per resource create the four-file
|
||||||
</span><span id="__span-2-49"><a id="__codelineno-2-49" name="__codelineno-2-49"></a>
|
</span><span id="__span-2-49"><a id="__codelineno-2-49" name="__codelineno-2-49"></a> slice: <span class="sb">`models.py`</span> (domain model, <span class="gs">**no `id`**</span>), <span class="sb">`ports/db.py`</span>
|
||||||
</span><span id="__span-2-50"><a id="__codelineno-2-50" name="__codelineno-2-50"></a><span class="k">5.</span> <span class="gs">**Wire the composition root**</span> — in <span class="sb">`init.py`</span>, build leaf repositories, then
|
</span><span id="__span-2-50"><a id="__codelineno-2-50" name="__codelineno-2-50"></a> (<span class="sb">`class XDocument(BaseDocument, X)`</span> — id comes from <span class="sb">`BaseDocument`</span>),
|
||||||
</span><span id="__span-2-51"><a id="__codelineno-2-51" name="__codelineno-2-51"></a> dependent repositories, then services; add infra adapters (db, cache, stores,
|
</span><span id="__span-2-51"><a id="__codelineno-2-51" name="__codelineno-2-51"></a> <span class="sb">`ports/service.py`</span>, and optional <span class="sb">`converters.py`</span>. Follow the CRUD template.
|
||||||
</span><span id="__span-2-52"><a id="__codelineno-2-52" name="__codelineno-2-52"></a> progress bus, fetchers). Expose <span class="sb">`get_svc`</span> overloads.
|
</span><span id="__span-2-52"><a id="__codelineno-2-52" name="__codelineno-2-52"></a>
|
||||||
</span><span id="__span-2-53"><a id="__codelineno-2-53" name="__codelineno-2-53"></a>
|
</span><span id="__span-2-53"><a id="__codelineno-2-53" name="__codelineno-2-53"></a><span class="k">5.</span> <span class="gs">**Wire the composition root**</span> — in <span class="sb">`init.py`</span>, build leaf repositories, then
|
||||||
</span><span id="__span-2-54"><a id="__codelineno-2-54" name="__codelineno-2-54"></a><span class="k">6.</span> <span class="gs">**Add routes**</span> — create <span class="sb">`interfaces/<resource>/routes.py`</span> thin handlers whose
|
</span><span id="__span-2-54"><a id="__codelineno-2-54" name="__codelineno-2-54"></a> dependent repositories, then services; add infra adapters (db, cache, stores,
|
||||||
</span><span id="__span-2-55"><a id="__codelineno-2-55" name="__codelineno-2-55"></a> names match the <span class="sb">`operationId`</span>s (use the signatures from step 3 as your
|
</span><span id="__span-2-55"><a id="__codelineno-2-55" name="__codelineno-2-55"></a> progress bus, fetchers). Expose <span class="sb">`get_svc`</span> overloads.
|
||||||
</span><span id="__span-2-56"><a id="__codelineno-2-56" name="__codelineno-2-56"></a> checklist) and re-export them in <span class="sb">`interfaces/openapi.py`</span>. Add the cache
|
</span><span id="__span-2-56"><a id="__codelineno-2-56" name="__codelineno-2-56"></a>
|
||||||
</span><span id="__span-2-57"><a id="__codelineno-2-57" name="__codelineno-2-57"></a> decorator to read endpoints.
|
</span><span id="__span-2-57"><a id="__codelineno-2-57" name="__codelineno-2-57"></a><span class="k">6.</span> <span class="gs">**Add routes**</span> — create <span class="sb">`interfaces/<resource>/routes.py`</span> thin handlers whose
|
||||||
</span><span id="__span-2-58"><a id="__codelineno-2-58" name="__codelineno-2-58"></a>
|
</span><span id="__span-2-58"><a id="__codelineno-2-58" name="__codelineno-2-58"></a> names match the <span class="sb">`operationId`</span>s (use the signatures from step 3 as your
|
||||||
</span><span id="__span-2-59"><a id="__codelineno-2-59" name="__codelineno-2-59"></a><span class="k">7.</span> <span class="gs">**Bootstrap app and worker**</span> — confirm <span class="sb">`main.py`</span> runs the OpenAPI-first app
|
</span><span id="__span-2-59"><a id="__codelineno-2-59" name="__codelineno-2-59"></a> checklist) and re-export them in <span class="sb">`interfaces/openapi.py`</span>. Add the cache
|
||||||
</span><span id="__span-2-60"><a id="__codelineno-2-60" name="__codelineno-2-60"></a> with its lifespan. If the app has long-running jobs, adapt <span class="sb">`worker.py`</span> to poll
|
</span><span id="__span-2-60"><a id="__codelineno-2-60" name="__codelineno-2-60"></a> decorator to read endpoints.
|
||||||
</span><span id="__span-2-61"><a id="__codelineno-2-61" name="__codelineno-2-61"></a> the job collection, run the pipeline, and publish progress.
|
</span><span id="__span-2-61"><a id="__codelineno-2-61" name="__codelineno-2-61"></a>
|
||||||
</span><span id="__span-2-62"><a id="__codelineno-2-62" name="__codelineno-2-62"></a>
|
</span><span id="__span-2-62"><a id="__codelineno-2-62" name="__codelineno-2-62"></a><span class="k">7.</span> <span class="gs">**Bootstrap app and worker**</span> — confirm <span class="sb">`main.py`</span> runs the OpenAPI-first app
|
||||||
</span><span id="__span-2-63"><a id="__codelineno-2-63" name="__codelineno-2-63"></a><span class="k">8.</span> <span class="gs">**Add pipeline families (only if data-driven)**</span> — copy <span class="sb">`pipelines/example/`</span>
|
</span><span id="__span-2-63"><a id="__codelineno-2-63" name="__codelineno-2-63"></a> with its lifespan. If the app has long-running jobs, adapt <span class="sb">`worker.py`</span> to poll
|
||||||
</span><span id="__span-2-64"><a id="__codelineno-2-64" name="__codelineno-2-64"></a> to <span class="sb">`pipelines/<family>/`</span> with <span class="sb">`abc.py`</span>, <span class="sb">`impl.py`</span>, <span class="sb">`pipeline.yaml`</span>,
|
</span><span id="__span-2-64"><a id="__codelineno-2-64" name="__codelineno-2-64"></a> the job collection, run the pipeline, and publish progress.
|
||||||
</span><span id="__span-2-65"><a id="__codelineno-2-65" name="__codelineno-2-65"></a> <span class="sb">`resources/`</span>, and reuse <span class="sb">`pipelines/shared/`</span> (loader + registry). Register the
|
</span><span id="__span-2-65"><a id="__codelineno-2-65" name="__codelineno-2-65"></a>
|
||||||
</span><span id="__span-2-66"><a id="__codelineno-2-66" name="__codelineno-2-66"></a> root. Add fixtures and a parity test.
|
</span><span id="__span-2-66"><a id="__codelineno-2-66" name="__codelineno-2-66"></a><span class="k">8.</span> <span class="gs">**Add pipeline families (only if data-driven)**</span> — copy <span class="sb">`pipelines/example/`</span>
|
||||||
</span><span id="__span-2-67"><a id="__codelineno-2-67" name="__codelineno-2-67"></a>
|
</span><span id="__span-2-67"><a id="__codelineno-2-67" name="__codelineno-2-67"></a> to <span class="sb">`pipelines/<family>/`</span> with <span class="sb">`abc.py`</span>, <span class="sb">`impl.py`</span>, <span class="sb">`pipeline.yaml`</span>,
|
||||||
</span><span id="__span-2-68"><a id="__codelineno-2-68" name="__codelineno-2-68"></a><span class="k">9.</span> <span class="gs">**Add infrastructure only where needed**</span> — Tier A capability: use the
|
</span><span id="__span-2-68"><a id="__codelineno-2-68" name="__codelineno-2-68"></a> <span class="sb">`resources/`</span>, and reuse <span class="sb">`pipelines/shared/`</span> (loader + registry). Register the
|
||||||
</span><span id="__span-2-69"><a id="__codelineno-2-69" name="__codelineno-2-69"></a> library. Tier B capability: copy the template. Never hand-write a Tier A
|
</span><span id="__span-2-69"><a id="__codelineno-2-69" name="__codelineno-2-69"></a> root. Add fixtures and a parity test.
|
||||||
</span><span id="__span-2-70"><a id="__codelineno-2-70" name="__codelineno-2-70"></a> library.
|
</span><span id="__span-2-70"><a id="__codelineno-2-70" name="__codelineno-2-70"></a>
|
||||||
</span><span id="__span-2-71"><a id="__codelineno-2-71" name="__codelineno-2-71"></a>
|
</span><span id="__span-2-71"><a id="__codelineno-2-71" name="__codelineno-2-71"></a><span class="k">9.</span> <span class="gs">**Add infrastructure only where needed**</span> — Tier A capability: use the
|
||||||
</span><span id="__span-2-72"><a id="__codelineno-2-72" name="__codelineno-2-72"></a><span class="k">10.</span> <span class="gs">**Tests**</span> — integration tests with fakes at the HTTP edge; unit tests for
|
</span><span id="__span-2-72"><a id="__codelineno-2-72" name="__codelineno-2-72"></a> library. Tier B capability: copy the template. Never hand-write a Tier A
|
||||||
</span><span id="__span-2-73"><a id="__codelineno-2-73" name="__codelineno-2-73"></a> domain and pipeline logic; a config-consistency test for rule-driven
|
</span><span id="__span-2-73"><a id="__codelineno-2-73" name="__codelineno-2-73"></a> library.
|
||||||
</span><span id="__span-2-74"><a id="__codelineno-2-74" name="__codelineno-2-74"></a> pipelines. Include <span class="sb">`tests/integration/test_openapi_app_builds.py`</span> (imports
|
</span><span id="__span-2-74"><a id="__codelineno-2-74" name="__codelineno-2-74"></a>
|
||||||
</span><span id="__span-2-75"><a id="__codelineno-2-75" name="__codelineno-2-75"></a> the app, asserts <span class="sb">`import main`</span> binds every operation).
|
</span><span id="__span-2-75"><a id="__codelineno-2-75" name="__codelineno-2-75"></a><span class="k">10.</span> <span class="gs">**Tests**</span> — integration tests with fakes at the HTTP edge; unit tests for
|
||||||
</span><span id="__span-2-76"><a id="__codelineno-2-76" name="__codelineno-2-76"></a>
|
</span><span id="__span-2-76"><a id="__codelineno-2-76" name="__codelineno-2-76"></a> domain and pipeline logic; a config-consistency test for rule-driven
|
||||||
</span><span id="__span-2-77"><a id="__codelineno-2-77" name="__codelineno-2-77"></a><span class="k">11.</span> <span class="gs">**Scripts and seeds**</span> — operator scripts under <span class="sb">`scripts/`</span> using
|
</span><span id="__span-2-77"><a id="__codelineno-2-77" name="__codelineno-2-77"></a> pipelines. Include <span class="sb">`tests/integration/test_openapi_app_builds.py`</span> (imports
|
||||||
</span><span id="__span-2-78"><a id="__codelineno-2-78" name="__codelineno-2-78"></a> <span class="sb">`init()`</span> / <span class="sb">`get_svc()`</span> / <span class="sb">`shutdown()`</span>; run as <span class="sb">`python -m scripts.<name>`</span>.
|
</span><span id="__span-2-78"><a id="__codelineno-2-78" name="__codelineno-2-78"></a> the app, asserts <span class="sb">`import main`</span> binds every operation).
|
||||||
</span><span id="__span-2-79"><a id="__codelineno-2-79" name="__codelineno-2-79"></a>
|
</span><span id="__span-2-79"><a id="__codelineno-2-79" name="__codelineno-2-79"></a>
|
||||||
</span><span id="__span-2-80"><a id="__codelineno-2-80" name="__codelineno-2-80"></a><span class="k">12.</span> <span class="gs">**Docs**</span> — author GSDFC docstrings, generate <span class="sb">`.pyi`</span> stubs, mark the package
|
</span><span id="__span-2-80"><a id="__codelineno-2-80" name="__codelineno-2-80"></a><span class="k">11.</span> <span class="gs">**Scripts and seeds**</span> — operator scripts under <span class="sb">`scripts/`</span> using
|
||||||
</span><span id="__span-2-81"><a id="__codelineno-2-81" name="__codelineno-2-81"></a> <span class="sb">`py.typed`</span>, and build the docs.
|
</span><span id="__span-2-81"><a id="__codelineno-2-81" name="__codelineno-2-81"></a> <span class="sb">`init()`</span> / <span class="sb">`get_svc()`</span> / <span class="sb">`shutdown()`</span>; run as <span class="sb">`python -m scripts.<name>`</span>.
|
||||||
</span><span id="__span-2-82"><a id="__codelineno-2-82" name="__codelineno-2-82"></a>
|
</span><span id="__span-2-82"><a id="__codelineno-2-82" name="__codelineno-2-82"></a>
|
||||||
</span><span id="__span-2-83"><a id="__codelineno-2-83" name="__codelineno-2-83"></a><span class="gs">**Definition of done:**</span>
|
</span><span id="__span-2-83"><a id="__codelineno-2-83" name="__codelineno-2-83"></a><span class="k">12.</span> <span class="gs">**Docs**</span> — author GSDFC docstrings, generate <span class="sb">`.pyi`</span> stubs, mark the package
|
||||||
</span><span id="__span-2-84"><a id="__codelineno-2-84" name="__codelineno-2-84"></a><span class="k">-</span><span class="w"> </span><span class="sb">`python -c "import main"`</span> succeeds.
|
</span><span id="__span-2-84"><a id="__codelineno-2-84" name="__codelineno-2-84"></a> <span class="sb">`py.typed`</span>, and build the docs.
|
||||||
</span><span id="__span-2-85"><a id="__codelineno-2-85" name="__codelineno-2-85"></a><span class="k">-</span><span class="w"> </span><span class="sb">`pytest`</span> passes.
|
</span><span id="__span-2-85"><a id="__codelineno-2-85" name="__codelineno-2-85"></a>
|
||||||
</span><span id="__span-2-86"><a id="__codelineno-2-86" name="__codelineno-2-86"></a><span class="k">-</span><span class="w"> </span>The app serves every operation in the spec; the worker drains its queue.
|
</span><span id="__span-2-86"><a id="__codelineno-2-86" name="__codelineno-2-86"></a><span class="gs">**Definition of done:**</span>
|
||||||
</span><span id="__span-2-87"><a id="__codelineno-2-87" name="__codelineno-2-87"></a><span class="k">-</span><span class="w"> </span>No domain module imports a web framework.
|
</span><span id="__span-2-87"><a id="__codelineno-2-87" name="__codelineno-2-87"></a><span class="k">-</span><span class="w"> </span><span class="sb">`python -c "import main"`</span> succeeds.
|
||||||
</span><span id="__span-2-88"><a id="__codelineno-2-88" name="__codelineno-2-88"></a><span class="k">-</span><span class="w"> </span>No Tier A library was reimplemented.
|
</span><span id="__span-2-88"><a id="__codelineno-2-88" name="__codelineno-2-88"></a><span class="k">-</span><span class="w"> </span><span class="sb">`pytest`</span> passes.
|
||||||
|
</span><span id="__span-2-89"><a id="__codelineno-2-89" name="__codelineno-2-89"></a><span class="k">-</span><span class="w"> </span>The app serves every operation in the spec; the worker drains its queue.
|
||||||
|
</span><span id="__span-2-90"><a id="__codelineno-2-90" name="__codelineno-2-90"></a><span class="k">-</span><span class="w"> </span>No domain module imports a web framework.
|
||||||
|
</span><span id="__span-2-91"><a id="__codelineno-2-91" name="__codelineno-2-91"></a><span class="k">-</span><span class="w"> </span>No Tier A library was reimplemented.
|
||||||
</span></code></pre></div></td></tr></table></div>
|
</span></code></pre></div></td></tr></table></div>
|
||||||
<hr />
|
<hr />
|
||||||
<h2 id="where-the-steps-land">🧭 Where the steps land</h2>
|
<h2 id="where-the-steps-land">🧭 Where the steps land</h2>
|
||||||
@@ -1005,6 +1011,10 @@ the two mechanical parts of the build:</p>
|
|||||||
<code>domain/<resource>/models.py</code> is your business shape. If the spec's schema has
|
<code>domain/<resource>/models.py</code> is your business shape. If the spec's schema has
|
||||||
a required <code>id: str</code>, keep it off the domain model and let the document get its
|
a required <code>id: str</code>, keep it off the domain model and let the document get its
|
||||||
id from <code>BaseDocument</code>.</li>
|
id from <code>BaseDocument</code>.</li>
|
||||||
|
<li><strong>Use <code>--template-style</code>.</strong> Stubs are then <code>async def ...(request, body: ...)</code>
|
||||||
|
matching <code>interfaces/</code>; without it they are sync <code>def ...(payload: ...)</code>.
|
||||||
|
Either binds (the router introspects the signature), but <code>--template-style</code>
|
||||||
|
keeps the scaffold consistent with the code you keep.</li>
|
||||||
<li><strong>Resource names are sanitized.</strong> <code>/fetch-requests</code> → <code>fetch_requests.py</code>.</li>
|
<li><strong>Resource names are sanitized.</strong> <code>/fetch-requests</code> → <code>fetch_requests.py</code>.</li>
|
||||||
<li><strong>Multipart bodies expand</strong> to <code>UploadFile = File(...)</code> / <code>Form(...)</code> params.</li>
|
<li><strong>Multipart bodies expand</strong> to <code>UploadFile = File(...)</code> / <code>Form(...)</code> params.</li>
|
||||||
<li><strong><code>_gen/</code> is disposable.</strong> Regenerate on every spec change; <code>git diff</code> shows
|
<li><strong><code>_gen/</code> is disposable.</strong> Regenerate on every spec change; <code>git diff</code> shows
|
||||||
|
|||||||
@@ -772,6 +772,13 @@ can resolve <code>operationId</code> across the whole app:</p>
|
|||||||
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2"></a> <span class="n">svc</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span><span class="p">[</span><span class="s2">"item_svc"</span><span class="p">]</span>
|
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2"></a> <span class="n">svc</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span><span class="p">[</span><span class="s2">"item_svc"</span><span class="p">]</span>
|
||||||
</span><span id="__span-3-3"><a id="__codelineno-3-3" name="__codelineno-3-3"></a> <span class="k">return</span> <span class="k">await</span> <span class="n">svc</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">Item</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">body</span><span class="p">))</span>
|
</span><span id="__span-3-3"><a id="__codelineno-3-3" name="__codelineno-3-3"></a> <span class="k">return</span> <span class="k">await</span> <span class="n">svc</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">Item</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">body</span><span class="p">))</span>
|
||||||
</span></code></pre></div></td></tr></table></div>
|
</span></code></pre></div></td></tr></table></div>
|
||||||
|
<p><strong>Generated stubs.</strong> <code>openapi-first routes</code> emits this exact shape (<code>async def</code>
|
||||||
|
handlers taking <code>body</code>) when given <code>--template-style</code>; without that flag the
|
||||||
|
stub is <code>def create_item(payload: ItemInput)</code> — sync, with a <code>payload</code>
|
||||||
|
parameter. Both shapes bind identically: the router resolves handlers by
|
||||||
|
<code>operationId</code> and introspects the FastAPI signature, and it never inspects the
|
||||||
|
request body. Pass <code>--template-style</code> so the generated signatures match the
|
||||||
|
handlers you keep in <code>interfaces/</code>.</p>
|
||||||
<h2 id="conventions">Conventions</h2>
|
<h2 id="conventions">Conventions</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>operationId</code> naming: <code>list_<resource></code>, <code>create_<resource></code>,
|
<li><code>operationId</code> naming: <code>list_<resource></code>, <code>create_<resource></code>,
|
||||||
@@ -794,6 +801,10 @@ just omit the security keys:</p>
|
|||||||
global <code>security</code> block exists. Leaving every operation without a <code>security</code>
|
global <code>security</code> block exists. Leaving every operation without a <code>security</code>
|
||||||
key is the cleanest authless spec.</li>
|
key is the cleanest authless spec.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>Profile endpoints (<code>/users/me</code>) and profile headers (<code>x-profile</code>) still exist in
|
||||||
|
an authless app. Define a stand-in identity in the composition root — for
|
||||||
|
example <code>get_current_user</code> returns or lazily creates a fixed "Demo User" — so
|
||||||
|
handlers stay auth-agnostic and only the wiring changes when real auth arrives.</p>
|
||||||
<p>The handlers, services, and composition root are unchanged. When auth is needed
|
<p>The handlers, services, and composition root are unchanged. When auth is needed
|
||||||
later, add the global <code>security</code> block and an introspecting dependency; only
|
later, add the global <code>security</code> block and an introspecting dependency; only
|
||||||
handlers that must opt out get <code>security: []</code>.</p>
|
handlers that must opt out get <code>security: []</code>.</p>
|
||||||
@@ -804,7 +815,7 @@ handlers that must opt out get <code>security: []</code>.</p>
|
|||||||
<span class="normal"><a href="#__codelineno-4-2">2</a></span>
|
<span class="normal"><a href="#__codelineno-4-2">2</a></span>
|
||||||
<span class="normal"><a href="#__codelineno-4-3">3</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1"></a>openapi-first<span class="w"> </span>models<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/models.py
|
<span class="normal"><a href="#__codelineno-4-3">3</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1"></a>openapi-first<span class="w"> </span>models<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/models.py
|
||||||
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2"></a>openapi-first<span class="w"> </span>routes<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/routes<span class="w"> </span><span class="se">\</span>
|
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2"></a>openapi-first<span class="w"> </span>routes<span class="w"> </span>openapi.yaml<span class="w"> </span>-o<span class="w"> </span>_gen/routes<span class="w"> </span><span class="se">\</span>
|
||||||
</span><span id="__span-4-3"><a id="__codelineno-4-3" name="__codelineno-4-3"></a><span class="w"> </span>--use-models<span class="w"> </span>--models-module<span class="w"> </span>_gen.models<span class="w"> </span>--inject-request<span class="w"> </span>--package
|
</span><span id="__span-4-3"><a id="__codelineno-4-3" name="__codelineno-4-3"></a><span class="w"> </span>--use-models<span class="w"> </span>--models-module<span class="w"> </span>_gen.models<span class="w"> </span>--inject-request<span class="w"> </span>--package<span class="w"> </span>--template-style
|
||||||
</span></code></pre></div></td></tr></table></div>
|
</span></code></pre></div></td></tr></table></div>
|
||||||
<p>Prefer codegen over hand-writing boilerplate. The result is the wire contract
|
<p>Prefer codegen over hand-writing boilerplate. The result is the wire contract
|
||||||
and a set of <code>operationId</code>-named signatures — not the canonical handlers.</p>
|
and a set of <code>operationId</code>-named signatures — not the canonical handlers.</p>
|
||||||
|
|||||||
@@ -427,6 +427,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="#nested-references" class="md-nav__link">
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
Nested references
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="md-nav__item">
|
<li class="md-nav__item">
|
||||||
@@ -665,6 +674,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="#nested-references" class="md-nav__link">
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
Nested references
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="md-nav__item">
|
<li class="md-nav__item">
|
||||||
@@ -802,10 +820,23 @@ The repository factory selects the right repository flavour.</p>
|
|||||||
</span><span id="__span-2-6"><a id="__codelineno-2-6" name="__codelineno-2-6"></a> <span class="k">return</span> <span class="n">repo_factory</span><span class="p">(</span>
|
</span><span id="__span-2-6"><a id="__codelineno-2-6" name="__codelineno-2-6"></a> <span class="k">return</span> <span class="n">repo_factory</span><span class="p">(</span>
|
||||||
</span><span id="__span-2-7"><a id="__codelineno-2-7" name="__codelineno-2-7"></a> <span class="n">collection</span><span class="o">=</span><span class="s2">"items"</span><span class="p">,</span>
|
</span><span id="__span-2-7"><a id="__codelineno-2-7" name="__codelineno-2-7"></a> <span class="n">collection</span><span class="o">=</span><span class="s2">"items"</span><span class="p">,</span>
|
||||||
</span><span id="__span-2-8"><a id="__codelineno-2-8" name="__codelineno-2-8"></a> <span class="n">document</span><span class="o">=</span><span class="n">ItemDocument</span><span class="p">,</span>
|
</span><span id="__span-2-8"><a id="__codelineno-2-8" name="__codelineno-2-8"></a> <span class="n">document</span><span class="o">=</span><span class="n">ItemDocument</span><span class="p">,</span>
|
||||||
</span><span id="__span-2-9"><a id="__codelineno-2-9" name="__codelineno-2-9"></a> <span class="n">populate_rules</span><span class="o">=</span><span class="p">[</span><span class="n">PopulateRule</span><span class="p">(</span><span class="n">field</span><span class="o">=</span><span class="s2">"owner"</span><span class="p">,</span> <span class="n">collection</span><span class="o">=</span><span class="s2">"users"</span><span class="p">)],</span>
|
</span><span id="__span-2-9"><a id="__codelineno-2-9" name="__codelineno-2-9"></a> <span class="n">populate_rules</span><span class="o">=</span><span class="p">[</span><span class="n">PopulateRule</span><span class="p">(</span><span class="n">field_name</span><span class="o">=</span><span class="s2">"owner"</span><span class="p">,</span> <span class="n">collection_name</span><span class="o">=</span><span class="s2">"users"</span><span class="p">)],</span>
|
||||||
</span><span id="__span-2-10"><a id="__codelineno-2-10" name="__codelineno-2-10"></a> <span class="n">cache</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="c1"># or a CachedBaseRepository config</span>
|
</span><span id="__span-2-10"><a id="__codelineno-2-10" name="__codelineno-2-10"></a> <span class="n">cache</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="c1"># or a CachedBaseRepository config</span>
|
||||||
</span><span id="__span-2-11"><a id="__codelineno-2-11" name="__codelineno-2-11"></a> <span class="p">)</span>
|
</span><span id="__span-2-11"><a id="__codelineno-2-11" name="__codelineno-2-11"></a> <span class="p">)</span>
|
||||||
</span></code></pre></div></td></tr></table></div>
|
</span></code></pre></div></td></tr></table></div>
|
||||||
|
<h2 id="nested-references">Nested references</h2>
|
||||||
|
<p>The wire contract often declares a reference as an expanded object
|
||||||
|
(<code>expense.entity: Entity</code>), while the domain model stays id-free. Pick one
|
||||||
|
convention and apply it consistently:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Embed (default for small, stable refs)</strong> — store the object as a <code>dict</code> /
|
||||||
|
<code>list[dict]</code> on the document; the API returns it verbatim. No populate wiring.</li>
|
||||||
|
<li><strong>Populate</strong> — store an id and declare a <code>PopulateRule</code>; mongo-ops expands it
|
||||||
|
on read. The referenced repo must be registered with the <code>PopulationEngine</code>.</li>
|
||||||
|
<li><strong>Id only</strong> — store the id and let the client resolve it.</li>
|
||||||
|
</ul>
|
||||||
|
<p>For populate-backed repositories change a reference with <code>update</code>, not <code>patch</code>:
|
||||||
|
<code>patch</code> rejects changes to populated FK fields by design.</p>
|
||||||
<h2 id="portsservicepy">ports/service.py</h2>
|
<h2 id="portsservicepy">ports/service.py</h2>
|
||||||
<p>The service is built through the DI library, injecting the repository. The
|
<p>The service is built through the DI library, injecting the repository. The
|
||||||
generic CRUD base provides <code>create/read/update/patch/delete/search/...</code>.</p>
|
generic CRUD base provides <code>create/read/update/patch/delete/search/...</code>.</p>
|
||||||
|
|||||||
@@ -738,7 +738,7 @@ Tier A / B / C split.</p>
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span>
|
<div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span>
|
||||||
<span class="normal"><a href="#__codelineno-1-2">2</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a><span class="n">PopulateRule</span><span class="p">(</span><span class="n">field</span><span class="o">=</span><span class="s2">"owner"</span><span class="p">,</span> <span class="n">collection</span><span class="o">=</span><span class="s2">"users"</span><span class="p">,</span>
|
<span class="normal"><a href="#__codelineno-1-2">2</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a><span class="n">PopulateRule</span><span class="p">(</span><span class="n">field_name</span><span class="o">=</span><span class="s2">"owner"</span><span class="p">,</span> <span class="n">collection_name</span><span class="o">=</span><span class="s2">"users"</span><span class="p">,</span>
|
||||||
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2"></a> <span class="n">nested_rules</span><span class="o">=</span><span class="p">[],</span> <span class="n">max_depth</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">projection</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span>
|
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2"></a> <span class="n">nested_rules</span><span class="o">=</span><span class="p">[],</span> <span class="n">max_depth</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">projection</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span>
|
||||||
</span></code></pre></div></td></tr></table></div>
|
</span></code></pre></div></td></tr></table></div>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -192,6 +192,12 @@
|
|||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": "Docker build-context exclusions."
|
"description": "Docker build-context exclusions."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "gitignore",
|
||||||
|
"path": ".gitignore",
|
||||||
|
"placeholder": "",
|
||||||
|
"description": "VCS exclusions (caches, venv, generated _gen/)."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "compose",
|
"kind": "compose",
|
||||||
"path": "docker-compose.yaml",
|
"path": "docker-compose.yaml",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
|||||||
"module": "crud_template",
|
"module": "crud_template",
|
||||||
"content": {
|
"content": {
|
||||||
"path": "crud_template",
|
"path": "crud_template",
|
||||||
"docstring": "# CRUD Template\n\n## Summary\n\nEvery resource follows the same four-file slice. Repetition is intentional: it\nmakes the codebase predictable and generatable.\n\n---\n\n## The slice\n\n```\ndomain/<resource>/\n models.py # pure Pydantic model (no DB fields)\n ports/\n db.py # document model + repository factory\n service.py # service class + factory\n converters.py # (optional) transforms\n```\n\n## The id rule (model vs. document)\n\nTwo different objects, two different jobs:\n\n| Object | Lives in | Has `id`? | Purpose |\n|---|---|---|---|\n| **Domain model** | `domain/<resource>/models.py` | **No** | business shape, framework-free |\n| **Document** | `domain/<resource>/ports/db.py` | **Yes** (from `BaseDocument`) | Mongo persistence shape |\n\n`BaseDocument` (mongo-ops) supplies `id: Optional[PyObjectId]` (aliased to\n`_id`) plus `created_at` / `updated_at`. The domain model must **not** declare\n`id`; Mongo owns it.\n\n> **Trap — generated wire models.** `openapi-first models` produces the HTTP\n> *contract* (see the [Build Recipe](01_build_recipe.md), step 3). Response\n> schemas frequently declare a required `id: str`, so a generated class is\n> **not** a domain model. If you write\n> `class ItemDocument(Item, BaseDocument)` and `Item` re-exports a required\n> `id: str`, that field shadows `BaseDocument.id` and no Mongo `_id` can ever be\n> set. Fix it one of three ways:\n>\n> 1. **Preferred:** keep the domain model id-free (hand-author it from the CRUD\n> template), and keep the required `id` only on the wire model.\n> 2. Order the bases so the document wins:\n> `class ItemDocument(BaseDocument, Item)`.\n> 3. Keep a separate document type and translate with `ports/converters.py`.\n\n## models.py\n\nA framework-free Pydantic model describing the business shape (no `id`).\n\n```python\nclass Item(BaseModel):\n name: str\n quantity: int = 0\n```\n\n## ports/db.py\n\nThe persistence model extends the business model and adds database concerns.\nThe repository factory selects the right repository flavour.\n\n```python\n# Item is id-free; BaseDocument adds id/created_at/updated_at.\nclass ItemDocument(Item, BaseDocument):\n pass\n\ndef create_item_repo(*, owner_repo=None) -> ItemRepository:\n return repo_factory(\n collection=\"items\",\n document=ItemDocument,\n populate_rules=[PopulateRule(field=\"owner\", collection=\"users\")],\n cache=None, # or a CachedBaseRepository config\n )\n```\n\n## ports/service.py\n\nThe service is built through the DI library, injecting the repository. The\ngeneric CRUD base provides `create/read/update/patch/delete/search/...`.\n\n```python\nclass ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n model = Item\n schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n return build(ItemService, instances={\"repo\": repo})\n```\n\n## Naming conventions\n\n| Concept | Convention |\n|---|---|\n| collection | plural snake case (`items`) |\n| document | `<Resource>Document` |\n| repository factory | `create_<resource>_repo` |\n| service | `<Resource>Service` |\n| service factory | `get_<resource>_service` |\n| resource key | `<resource>_svc` |\n| operationId | `list_items`, `create_item`, ... |\n\n## Optional files\n\n- `converters.py` — pure functions mapping between domain types.\n- `ports/enrichment.py` — adapts a pipeline into an async callable the service\n can invoke (used when a resource is populated by a pipeline).\n",
|
"docstring": "# CRUD Template\n\n## Summary\n\nEvery resource follows the same four-file slice. Repetition is intentional: it\nmakes the codebase predictable and generatable.\n\n---\n\n## The slice\n\n```\ndomain/<resource>/\n models.py # pure Pydantic model (no DB fields)\n ports/\n db.py # document model + repository factory\n service.py # service class + factory\n converters.py # (optional) transforms\n```\n\n## The id rule (model vs. document)\n\nTwo different objects, two different jobs:\n\n| Object | Lives in | Has `id`? | Purpose |\n|---|---|---|---|\n| **Domain model** | `domain/<resource>/models.py` | **No** | business shape, framework-free |\n| **Document** | `domain/<resource>/ports/db.py` | **Yes** (from `BaseDocument`) | Mongo persistence shape |\n\n`BaseDocument` (mongo-ops) supplies `id: Optional[PyObjectId]` (aliased to\n`_id`) plus `created_at` / `updated_at`. The domain model must **not** declare\n`id`; Mongo owns it.\n\n> **Trap — generated wire models.** `openapi-first models` produces the HTTP\n> *contract* (see the [Build Recipe](01_build_recipe.md), step 3). Response\n> schemas frequently declare a required `id: str`, so a generated class is\n> **not** a domain model. If you write\n> `class ItemDocument(Item, BaseDocument)` and `Item` re-exports a required\n> `id: str`, that field shadows `BaseDocument.id` and no Mongo `_id` can ever be\n> set. Fix it one of three ways:\n>\n> 1. **Preferred:** keep the domain model id-free (hand-author it from the CRUD\n> template), and keep the required `id` only on the wire model.\n> 2. Order the bases so the document wins:\n> `class ItemDocument(BaseDocument, Item)`.\n> 3. Keep a separate document type and translate with `ports/converters.py`.\n\n## models.py\n\nA framework-free Pydantic model describing the business shape (no `id`).\n\n```python\nclass Item(BaseModel):\n name: str\n quantity: int = 0\n```\n\n## ports/db.py\n\nThe persistence model extends the business model and adds database concerns.\nThe repository factory selects the right repository flavour.\n\n```python\n# Item is id-free; BaseDocument adds id/created_at/updated_at.\nclass ItemDocument(Item, BaseDocument):\n pass\n\ndef create_item_repo(*, owner_repo=None) -> ItemRepository:\n return repo_factory(\n collection=\"items\",\n document=ItemDocument,\n populate_rules=[PopulateRule(field_name=\"owner\", collection_name=\"users\")],\n cache=None, # or a CachedBaseRepository config\n )\n```\n\n## Nested references\n\nThe wire contract often declares a reference as an expanded object\n(`expense.entity: Entity`), while the domain model stays id-free. Pick one\nconvention and apply it consistently:\n\n- **Embed (default for small, stable refs)** — store the object as a `dict` /\n `list[dict]` on the document; the API returns it verbatim. No populate wiring.\n- **Populate** — store an id and declare a `PopulateRule`; mongo-ops expands it\n on read. The referenced repo must be registered with the `PopulationEngine`.\n- **Id only** — store the id and let the client resolve it.\n\nFor populate-backed repositories change a reference with `update`, not `patch`:\n`patch` rejects changes to populated FK fields by design.\n\n## ports/service.py\n\nThe service is built through the DI library, injecting the repository. The\ngeneric CRUD base provides `create/read/update/patch/delete/search/...`.\n\n```python\nclass ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n model = Item\n schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n return build(ItemService, instances={\"repo\": repo})\n```\n\n## Naming conventions\n\n| Concept | Convention |\n|---|---|\n| collection | plural snake case (`items`) |\n| document | `<Resource>Document` |\n| repository factory | `create_<resource>_repo` |\n| service | `<Resource>Service` |\n| service factory | `get_<resource>_service` |\n| resource key | `<resource>_svc` |\n| operationId | `list_items`, `create_item`, ... |\n\n## Optional files\n\n- `converters.py` — pure functions mapping between domain types.\n- `ports/enrichment.py` — adapts a pipeline into an async callable the service\n can invoke (used when a resource is populated by a pipeline).\n",
|
||||||
"objects": {}
|
"objects": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"module": "infra_adaptors",
|
"module": "infra_adaptors",
|
||||||
"content": {
|
"content": {
|
||||||
"path": "infra_adaptors",
|
"path": "infra_adaptors",
|
||||||
"docstring": "# Infra Adapters\n\n## Summary\n\nAll external systems are reached through adapters in `infra/`. Prefer an\nexisting internal library over writing a new adapter; where no library exists,\ncopy the Tier B scaffold from `templates/app`. See the Genericity Matrix for the\nTier A / B / C split.\n\n---\n\n## Database (MongoDB)\n\n- A single connection manager owns the client lifecycle.\n `setup_database(uri, db_name)` / `shutdown_database()`.\n- A repository **factory** selects the repository flavour:\n\n ```python\n repo_factory(collection, document, engine=None,\n populate_rules=None, cache=None)\n ```\n\n - base repository — plain CRUD;\n - populating repository — resolves referenced documents (foreign keys);\n - cached repository — an LRU/TTL cache in front of reads.\n\n- Referenced documents are described declaratively:\n\n ```python\n PopulateRule(field=\"owner\", collection=\"users\",\n nested_rules=[], max_depth=2, projection=None)\n ```\n\n- Transactions use a session context manager provided by the library.\n\n**Tier A — Library (Mongo Ops).** Do not write raw Motor calls in the domain;\nconsult the registry and the library's MCP before touching the driver.\n\n## HTTP response cache\n\n**Tier B — template.** Copy from `templates/app`; not a standalone library.\n\nA generic GET cache sits behind a decorator.\n\n```python\n@cache_response(ttl=60)\nasync def list_items(request): ...\n```\n\n- Keyed on path + query + user.\n- No-op when no store is wired.\n- `invalidate(*resources)` bumps a generation counter for write-triggered\n invalidation.\n\n## Event / progress bus\n\n**Tier B — template.** Copy from `templates/app`.\n\nA port with an in-memory default adapter (`publish`, `get_events`). Swap for a\ndurable broker in production (see Worker & Progress).\n\n## External clients (ingestion)\n\nExternal fetchers (files, mail, HTTP) are adapters that wrap ingestion\nlibraries. Construct them in the composition root and inject into\norchestrators.\n\n**Tier A — Libraries:** Omniread (formats) and Mail Intake (email).\n\n## LLM clients\n\n**Tier B — template.** Copy the pool scaffold from `templates/app`.\n\nWhen a pipeline calls a model provider, route through a pool adaptor that\nhandles provider selection, retries, and client lifecycle:\n\n```python\npool = setup_async_pool(provider=settings.llm_provider)\nresult = await pool.complete(model=\"...\", messages=[...])\n```\n\nProviders are selected by `LLM_PROVIDER` and configured by environment.\n\n## Key-value store\n\n**Tier B — template.** Copy from `templates/app`.\n\nFor report/snapshot storage, provide a pluggable store port with a Redis\nadapter and an in-memory fallback.\n\n## Rule\n\n- **Tier A (libraries: DB, DI, ingestion, mail, auth, docs, pipelines)** — use\n the library; writing a private copy is a defect. Query `find_library` first.\n- **Tier B (cache, progress bus, LLM pool, KV store, composition shape, worker,\n tests)** — these are template code, not libraries: copy them from\n `templates/app` rather than searching the registry.\n- **Tier C (models, business logic, pipeline families, rules)** — author them\n yourself.\n",
|
"docstring": "# Infra Adapters\n\n## Summary\n\nAll external systems are reached through adapters in `infra/`. Prefer an\nexisting internal library over writing a new adapter; where no library exists,\ncopy the Tier B scaffold from `templates/app`. See the Genericity Matrix for the\nTier A / B / C split.\n\n---\n\n## Database (MongoDB)\n\n- A single connection manager owns the client lifecycle.\n `setup_database(uri, db_name)` / `shutdown_database()`.\n- A repository **factory** selects the repository flavour:\n\n ```python\n repo_factory(collection, document, engine=None,\n populate_rules=None, cache=None)\n ```\n\n - base repository — plain CRUD;\n - populating repository — resolves referenced documents (foreign keys);\n - cached repository — an LRU/TTL cache in front of reads.\n\n- Referenced documents are described declaratively:\n\n ```python\n PopulateRule(field_name=\"owner\", collection_name=\"users\",\n nested_rules=[], max_depth=2, projection=None)\n ```\n\n- Transactions use a session context manager provided by the library.\n\n**Tier A — Library (Mongo Ops).** Do not write raw Motor calls in the domain;\nconsult the registry and the library's MCP before touching the driver.\n\n## HTTP response cache\n\n**Tier B — template.** Copy from `templates/app`; not a standalone library.\n\nA generic GET cache sits behind a decorator.\n\n```python\n@cache_response(ttl=60)\nasync def list_items(request): ...\n```\n\n- Keyed on path + query + user.\n- No-op when no store is wired.\n- `invalidate(*resources)` bumps a generation counter for write-triggered\n invalidation.\n\n## Event / progress bus\n\n**Tier B — template.** Copy from `templates/app`.\n\nA port with an in-memory default adapter (`publish`, `get_events`). Swap for a\ndurable broker in production (see Worker & Progress).\n\n## External clients (ingestion)\n\nExternal fetchers (files, mail, HTTP) are adapters that wrap ingestion\nlibraries. Construct them in the composition root and inject into\norchestrators.\n\n**Tier A — Libraries:** Omniread (formats) and Mail Intake (email).\n\n## LLM clients\n\n**Tier B — template.** Copy the pool scaffold from `templates/app`.\n\nWhen a pipeline calls a model provider, route through a pool adaptor that\nhandles provider selection, retries, and client lifecycle:\n\n```python\npool = setup_async_pool(provider=settings.llm_provider)\nresult = await pool.complete(model=\"...\", messages=[...])\n```\n\nProviders are selected by `LLM_PROVIDER` and configured by environment.\n\n## Key-value store\n\n**Tier B — template.** Copy from `templates/app`.\n\nFor report/snapshot storage, provide a pluggable store port with a Redis\nadapter and an in-memory fallback.\n\n## Rule\n\n- **Tier A (libraries: DB, DI, ingestion, mail, auth, docs, pipelines)** — use\n the library; writing a private copy is a defect. Query `find_library` first.\n- **Tier B (cache, progress bus, LLM pool, KV store, composition shape, worker,\n tests)** — these are template code, not libraries: copy them from\n `templates/app` rather than searching the registry.\n- **Tier C (models, business logic, pipeline families, rules)** — author them\n yourself.\n",
|
||||||
"objects": {}
|
"objects": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"module": "spec_first_routing",
|
"module": "spec_first_routing",
|
||||||
"content": {
|
"content": {
|
||||||
"path": "spec_first_routing",
|
"path": "spec_first_routing",
|
||||||
"docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n openapi_path=\"openapi.yaml\",\n routes_module=interfaces.openapi, # module whose namespace holds handlers\n title=\"<App> API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.db_resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.db_resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n## Conventions\n\n- `operationId` naming: `list_<resource>`, `create_<resource>`,\n `get_<resource>`, `update_<resource>`, `delete_<resource>`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Authless apps\n\nAuth is **deferred** in the standard: the same spec-first app runs with or\nwithout it, because security lives entirely in the spec, never in handlers.\nFor an app that needs no auth yet, do **not** invent per-handler auth checks —\njust omit the security keys:\n\n- Remove the top-level `security:` block.\n- Remove `components.securitySchemes`.\n- Do **not** add `security: []` to operations. An explicit empty array is a\n per-operation *override* that means \"public\", and it only matters when a\n global `security` block exists. Leaving every operation without a `security`\n key is the cleanest authless spec.\n\nThe handlers, services, and composition root are unchanged. When auth is needed\nlater, add the global `security` block and an introspecting dependency; only\nhandlers that must opt out get `security: []`.\n\n## Codegen\n\n`openapi_first` scaffolds models and route stubs from the spec (see the\n[Build Recipe](01_build_recipe.md), step 3):\n\n```bash\nopenapi-first models openapi.yaml -o _gen/models.py\nopenapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package\n```\n\nPrefer codegen over hand-writing boilerplate. The result is the wire contract\nand a set of `operationId`-named signatures — not the canonical handlers.\n",
|
"docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n openapi_path=\"openapi.yaml\",\n routes_module=interfaces.openapi, # module whose namespace holds handlers\n title=\"<App> API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.db_resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.db_resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n**Generated stubs.** `openapi-first routes` emits this exact shape (`async def`\nhandlers taking `body`) when given `--template-style`; without that flag the\nstub is `def create_item(payload: ItemInput)` — sync, with a `payload`\nparameter. Both shapes bind identically: the router resolves handlers by\n`operationId` and introspects the FastAPI signature, and it never inspects the\nrequest body. Pass `--template-style` so the generated signatures match the\nhandlers you keep in `interfaces/`.\n\n## Conventions\n\n- `operationId` naming: `list_<resource>`, `create_<resource>`,\n `get_<resource>`, `update_<resource>`, `delete_<resource>`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Authless apps\n\nAuth is **deferred** in the standard: the same spec-first app runs with or\nwithout it, because security lives entirely in the spec, never in handlers.\nFor an app that needs no auth yet, do **not** invent per-handler auth checks —\njust omit the security keys:\n\n- Remove the top-level `security:` block.\n- Remove `components.securitySchemes`.\n- Do **not** add `security: []` to operations. An explicit empty array is a\n per-operation *override* that means \"public\", and it only matters when a\n global `security` block exists. Leaving every operation without a `security`\n key is the cleanest authless spec.\n\nProfile endpoints (`/users/me`) and profile headers (`x-profile`) still exist in\nan authless app. Define a stand-in identity in the composition root — for\nexample `get_current_user` returns or lazily creates a fixed \"Demo User\" — so\nhandlers stay auth-agnostic and only the wiring changes when real auth arrives.\n\nThe handlers, services, and composition root are unchanged. When auth is needed\nlater, add the global `security` block and an introspecting dependency; only\nhandlers that must opt out get `security: []`.\n\n## Codegen\n\n`openapi_first` scaffolds models and route stubs from the spec (see the\n[Build Recipe](01_build_recipe.md), step 3):\n\n```bash\nopenapi-first models openapi.yaml -o _gen/models.py\nopenapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package --template-style\n```\n\nPrefer codegen over hand-writing boilerplate. The result is the wire contract\nand a set of `operationId`-named signatures — not the canonical handlers.\n",
|
||||||
"objects": {}
|
"objects": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
23
standards/mcp/templates/app/.gitignore
vendored
Normal file
23
standards/mcp/templates/app/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Byte-compiled / caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Generated wire contract (regenerate from openapi.yaml)
|
||||||
|
_gen/
|
||||||
|
|
||||||
|
# Packaging / coverage
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Mongo persistence for items: document schema and repository factory.
|
Mongo persistence for items: document schema and repository factory.
|
||||||
"""
|
"""
|
||||||
|
from mongo_ops import BaseDocument, BaseRepository
|
||||||
|
|
||||||
from domain.item.models import Item
|
from domain.item.models import Item
|
||||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||||
from mongo_ops import BaseDocument, BaseRepository
|
|
||||||
|
|
||||||
|
|
||||||
class ItemDocument(Item, BaseDocument):
|
class ItemDocument(Item, BaseDocument):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Job domain model — a pipeline work item consumed by the background worker.
|
Job domain model — a pipeline work item consumed by the background worker.
|
||||||
"""
|
"""
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Mongo persistence for jobs: document schema and repository factory.
|
Mongo persistence for jobs: document schema and repository factory.
|
||||||
"""
|
"""
|
||||||
|
from mongo_ops import BaseDocument, BaseRepository
|
||||||
|
|
||||||
from domain.job.models import Job
|
from domain.job.models import Job
|
||||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||||
from mongo_ops import BaseDocument, BaseRepository
|
|
||||||
|
|
||||||
|
|
||||||
class JobDocument(Job, BaseDocument):
|
class JobDocument(Job, BaseDocument):
|
||||||
|
|||||||
@@ -21,18 +21,17 @@ Example:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class KVStore(ABC):
|
class KVStore(ABC):
|
||||||
"""Abstract key-value store port."""
|
"""Abstract key-value store port."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Return the value at ``key``, or ``None``."""
|
"""Return the value at ``key``, or ``None``."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -40,7 +39,7 @@ class KVStore(ABC):
|
|||||||
"""Delete ``key`` if present."""
|
"""Delete ``key`` if present."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def scan(self, pattern: str) -> List[str]:
|
async def scan(self, pattern: str) -> list[str]:
|
||||||
"""Return keys matching a glob ``pattern``."""
|
"""Return keys matching a glob ``pattern``."""
|
||||||
|
|
||||||
async def clear(self, pattern: str = "*") -> None:
|
async def clear(self, pattern: str = "*") -> None:
|
||||||
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize an empty store with no imports or connections."""
|
"""Initialize an empty store with no imports or connections."""
|
||||||
self._data: Dict[str, str] = {}
|
self._data: dict[str, str] = {}
|
||||||
self._expiry: Dict[str, float] = {}
|
self._expiry: dict[str, float] = {}
|
||||||
|
|
||||||
def _expired(self, key: str) -> bool:
|
def _expired(self, key: str) -> bool:
|
||||||
import time
|
import time
|
||||||
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Return the value at ``key``, or ``None`` when absent/expired."""
|
"""Return the value at ``key``, or ``None`` when absent/expired."""
|
||||||
if self._expired(key):
|
if self._expired(key):
|
||||||
return None
|
return None
|
||||||
return self._data.get(key)
|
return self._data.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
|
|||||||
self._data.pop(key, None)
|
self._data.pop(key, None)
|
||||||
self._expiry.pop(key, None)
|
self._expiry.pop(key, None)
|
||||||
|
|
||||||
async def scan(self, pattern: str) -> List[str]:
|
async def scan(self, pattern: str) -> list[str]:
|
||||||
"""Return live keys matching a glob ``pattern``."""
|
"""Return live keys matching a glob ``pattern``."""
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
|
||||||
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
|
|||||||
|
|
||||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Return the value at ``key``, or ``None``."""
|
"""Return the value at ``key``, or ``None``."""
|
||||||
return await self._redis.get(key)
|
return await self._redis.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||||
await self._redis.set(key, value, ex=ttl)
|
await self._redis.set(key, value, ex=ttl)
|
||||||
|
|
||||||
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
|
|||||||
"""Delete ``key`` if present."""
|
"""Delete ``key`` if present."""
|
||||||
await self._redis.delete(key)
|
await self._redis.delete(key)
|
||||||
|
|
||||||
async def scan(self, pattern: str) -> List[str]:
|
async def scan(self, pattern: str) -> list[str]:
|
||||||
"""Return keys matching a glob ``pattern``."""
|
"""Return keys matching a glob ``pattern``."""
|
||||||
return [key async for key in self._redis.scan_iter(match=pattern)]
|
return [key async for key in self._redis.scan_iter(match=pattern)]
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ Generic CRUD service adaptor over a Mongo repository.
|
|||||||
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
|
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
|
||||||
patch, delete, and search operations for every domain that needs them.
|
patch, delete, and search operations for every domain that needs them.
|
||||||
"""
|
"""
|
||||||
from typing import Generic, Type, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from mongo_ops import BaseDocument, PopulatingRepository
|
||||||
|
from mongo_ops.models import PyObjectId
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from infra.db.repos import mongo
|
from infra.db.repos import mongo
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from mongo_ops import BaseDocument
|
|
||||||
from mongo_ops.models import PyObjectId
|
|
||||||
|
|
||||||
Model = TypeVar("Model", bound=BaseModel)
|
Model = TypeVar("Model", bound=BaseModel)
|
||||||
Schema = TypeVar("Schema", bound=BaseDocument)
|
Schema = TypeVar("Schema", bound=BaseDocument)
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
repo: mongo.BaseRepository
|
repo: mongo.BaseRepository
|
||||||
model: Type[Model]
|
model: type[Model]
|
||||||
schema: Type[Schema]
|
schema: type[Schema]
|
||||||
|
|
||||||
def __init__(self, *, repo=None):
|
def __init__(self, *, repo=None):
|
||||||
"""Initialize the adaptor with a backing repository.
|
"""Initialize the adaptor with a backing repository.
|
||||||
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Schema | None: Updated document, or ``None`` when absent.
|
Schema | None: Updated document, or ``None`` when absent.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
A populating repository expects a document *model* (so FK fields can
|
||||||
|
be depopulated), while a base repository expects a raw mapping. This
|
||||||
|
method normalizes both inputs to whatever the repo requires.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(self.repo, PopulatingRepository):
|
||||||
|
if not isinstance(data, self.schema):
|
||||||
|
fields = data if isinstance(data, dict) else self.model_to_dict(data)
|
||||||
|
data = self.schema(**fields)
|
||||||
|
return await self.repo.update(id, data)
|
||||||
return await self.repo.update(
|
return await self.repo.update(
|
||||||
id,
|
id,
|
||||||
data if isinstance(data, dict) else self.model_to_dict(data),
|
data if isinstance(data, dict) else self.model_to_dict(data),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ SSEInMemoryBus — in-memory event bus for SSE progress events.
|
|||||||
Buffer published progress events so they can be replayed (and streamed) per
|
Buffer published progress events so they can be replayed (and streamed) per
|
||||||
correlation key, such as a job, upload, or fetch-request id.
|
correlation key, such as a job, upload, or fetch-request id.
|
||||||
"""
|
"""
|
||||||
from typing import Generic, List, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
|
|||||||
key_attr (str): Attribute name on each event holding the stream
|
key_attr (str): Attribute name on each event holding the stream
|
||||||
correlation key (for example ``"job_id"``).
|
correlation key (for example ``"job_id"``).
|
||||||
"""
|
"""
|
||||||
self._events: List[T] = []
|
self._events: list[T] = []
|
||||||
self._key_attr = key_attr
|
self._key_attr = key_attr
|
||||||
|
|
||||||
async def publish(self, event: T) -> None:
|
async def publish(self, event: T) -> None:
|
||||||
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
|
|||||||
"""
|
"""
|
||||||
self._events.append(event)
|
self._events.append(event)
|
||||||
|
|
||||||
def get_events(self, key: str) -> List[T]:
|
def get_events(self, key: str) -> list[T]:
|
||||||
"""Return buffered events for a correlation key.
|
"""Return buffered events for a correlation key.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -5,21 +5,20 @@ Generic MongoDB CRUD repo — single implementation for all domains.
|
|||||||
Select a concrete mongo-ops repository (base, cached, or populating) from the
|
Select a concrete mongo-ops repository (base, cached, or populating) from the
|
||||||
options supplied to the factory.
|
options supplied to the factory.
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional, Type
|
|
||||||
|
|
||||||
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
||||||
from mongo_ops.cache import CacheBackend, CacheConfig
|
from mongo_ops.cache import CacheBackend, CacheConfig
|
||||||
from mongo_ops.models import BaseDocument
|
from mongo_ops.models import BaseDocument
|
||||||
from mongo_ops.populate import PopulationEngine, PopulateRule
|
from mongo_ops.populate import PopulateRule, PopulationEngine
|
||||||
|
|
||||||
|
|
||||||
def _repo_factory(
|
def _repo_factory(
|
||||||
collection: str,
|
collection: str,
|
||||||
db_model: Type[BaseDocument],
|
db_model: type[BaseDocument],
|
||||||
cache_backend: Optional[CacheBackend] = None,
|
cache_backend: CacheBackend | None = None,
|
||||||
cache_config: Optional[CacheConfig] = None,
|
cache_config: CacheConfig | None = None,
|
||||||
engine: Optional[PopulationEngine] = None,
|
engine: PopulationEngine | None = None,
|
||||||
populate_rules: Optional[List[PopulateRule]] = None,
|
populate_rules: list[PopulateRule] | None = None,
|
||||||
) -> BaseRepository:
|
) -> BaseRepository:
|
||||||
"""Build the appropriate mongo-ops repository.
|
"""Build the appropriate mongo-ops repository.
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ Notes:
|
|||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
@@ -55,7 +56,7 @@ class HttpCacheStore:
|
|||||||
|
|
||||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Read the value stored at ``key``.
|
"""Read the value stored at ``key``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
|
|||||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def cache_response(ttl: Optional[int] = None) -> Callable:
|
def cache_response(ttl: int | None = None) -> Callable:
|
||||||
"""Decorate a GET-style route handler to cache its JSON response.
|
"""Decorate a GET-style route handler to cache its JSON response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
|
|||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
db_resources = getattr(request.app.state, "db_resources", None) or {}
|
db_resources = getattr(request.app.state, "db_resources", None) or {}
|
||||||
store: Optional[HttpCacheStore] = db_resources.get("http_cache_store")
|
store: HttpCacheStore | None = db_resources.get("http_cache_store")
|
||||||
if store is None:
|
if store is None:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Infrastructure initialization — connects MongoDB and wires all repos and servi
|
|||||||
Shared bootstrap for both the FastAPI app and the worker, guarded against
|
Shared bootstrap for both the FastAPI app and the worker, guarded against
|
||||||
repeated initialization via a module-level flag.
|
repeated initialization via a module-level flag.
|
||||||
"""
|
"""
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from domain.item.ports.db import create_item_repo
|
from domain.item.ports.db import create_item_repo
|
||||||
@@ -13,11 +13,11 @@ from domain.job.ports.db import create_job_repo
|
|||||||
from domain.job.ports.service import get_job_service
|
from domain.job.ports.service import get_job_service
|
||||||
from infra.db.setup import setup_database, shutdown_database
|
from infra.db.setup import setup_database, shutdown_database
|
||||||
|
|
||||||
_resources: Dict[str, Any] = {}
|
_resources: dict[str, Any] = {}
|
||||||
_initialized: bool = False
|
_initialized: bool = False
|
||||||
|
|
||||||
|
|
||||||
async def init() -> Dict[str, Any]:
|
async def init() -> dict[str, Any]:
|
||||||
"""Connect MongoDB, wire all repos and services, and return the resources dict.
|
"""Connect MongoDB, wire all repos and services, and return the resources dict.
|
||||||
|
|
||||||
Idempotent: repeated calls short-circuit and return the cached resources.
|
Idempotent: repeated calls short-circuit and return the cached resources.
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ All handler functions — names must match openapi.yaml operationId values. The
|
|||||||
OpenAPI-first router resolves each operation to a handler by name, so this module
|
OpenAPI-first router resolves each operation to a handler by name, so this module
|
||||||
re-exports every route handler from the subpackages into one flat namespace.
|
re-exports every route handler from the subpackages into one flat namespace.
|
||||||
"""
|
"""
|
||||||
|
# ruff: noqa: F403
|
||||||
from interfaces.item.routes import *
|
from interfaces.item.routes import *
|
||||||
from interfaces.job.routes import *
|
from interfaces.job.routes import *
|
||||||
@@ -4,6 +4,7 @@ FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
|
|||||||
Handlers are looked up by operationId in interfaces.openapi, so no router tables
|
Handlers are looked up by operationId in interfaces.openapi, so no router tables
|
||||||
are declared here; the lifespan initializes and shuts down app resources.
|
are declared here; the lifespan initializes and shuts down app resources.
|
||||||
"""
|
"""
|
||||||
|
# ruff: noqa: E402
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from hexa import build
|
from hexa import build
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Family registry — resolve a ``(kind, variant, mode)`` triple to a root class.
|
|||||||
|
|
||||||
A single lookup replaces hand-rolled ``if/else`` family selection.
|
A single lookup replaces hand-rolled ``if/else`` family selection.
|
||||||
"""
|
"""
|
||||||
from typing import Dict, Type
|
|
||||||
|
|
||||||
# Populated by ``register`` as families import.
|
# Populated by ``register`` as families import.
|
||||||
_FAMILIES: Dict[str, Dict[str, Dict[str, Type]]] = {}
|
_FAMILIES: dict[str, dict[str, dict[str, type]]] = {}
|
||||||
|
|
||||||
|
|
||||||
def register(kind: str, variant: str, root_cls: Type, mode: str = "default") -> Type:
|
def register(kind: str, variant: str, root_cls: type, mode: str = "default") -> type:
|
||||||
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
|
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
|
|||||||
return root_cls
|
return root_cls
|
||||||
|
|
||||||
|
|
||||||
def resolve_root(kind: str, variant: str, mode: str = "default") -> Type:
|
def resolve_root(kind: str, variant: str, mode: str = "default") -> type:
|
||||||
"""Return the root class for a ``(kind, variant, mode)`` triple.
|
"""Return the root class for a ``(kind, variant, mode)`` triple.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ testpaths = ["tests"]
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
extend-exclude = ["_gen"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP"]
|
select = ["E", "F", "I", "UP"]
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import interfaces.openapi as routes_module
|
|
||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
|
||||||
|
import interfaces.openapi as routes_module
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_app_builds():
|
def test_openapi_app_builds():
|
||||||
"""The spec-first app must build and plant each route handler."""
|
"""The spec-first app must build and plant each route handler."""
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
|
||||||
import interfaces.openapi as routes_module
|
import interfaces.openapi as routes_module
|
||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
|
||||||
|
|
||||||
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,14 @@ Then:
|
|||||||
```bash
|
```bash
|
||||||
openapi-first models openapi.yaml -o _gen/models.py
|
openapi-first models openapi.yaml -o _gen/models.py
|
||||||
openapi-first routes openapi.yaml -o _gen/routes \
|
openapi-first routes openapi.yaml -o _gen/routes \
|
||||||
--use-models --models-module _gen.models --inject-request --package
|
--use-models --models-module _gen.models --inject-request --package --template-style
|
||||||
```
|
```
|
||||||
`_gen/models.py` is the **wire contract** (Pydantic, one class per
|
`_gen/models.py` is the **wire contract** (Pydantic, one class per
|
||||||
`components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every
|
`components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every
|
||||||
`operationId` with its exact parameter signature. Treat `_gen/` as generated
|
`operationId` with its exact parameter signature. `--template-style` makes
|
||||||
|
those stubs `async def` with a `body` parameter, matching the handlers you
|
||||||
|
keep in `interfaces/` (without it they are sync `def` stubs taking
|
||||||
|
`payload`; both bind the same way). Treat `_gen/` as generated
|
||||||
(never hand-edit it) — the canonical handlers live in `interfaces/`.
|
(never hand-edit it) — the canonical handlers live in `interfaces/`.
|
||||||
|
|
||||||
4. **Write each CRUD slice (app-specific)** — per resource create the four-file
|
4. **Write each CRUD slice (app-specific)** — per resource create the four-file
|
||||||
@@ -168,6 +171,10 @@ Rules of the road:
|
|||||||
`domain/<resource>/models.py` is your business shape. If the spec's schema has
|
`domain/<resource>/models.py` is your business shape. If the spec's schema has
|
||||||
a required `id: str`, keep it off the domain model and let the document get its
|
a required `id: str`, keep it off the domain model and let the document get its
|
||||||
id from `BaseDocument`.
|
id from `BaseDocument`.
|
||||||
|
- **Use `--template-style`.** Stubs are then `async def ...(request, body: ...)`
|
||||||
|
matching `interfaces/`; without it they are sync `def ...(payload: ...)`.
|
||||||
|
Either binds (the router introspects the signature), but `--template-style`
|
||||||
|
keeps the scaffold consistent with the code you keep.
|
||||||
- **Resource names are sanitized.** `/fetch-requests` → `fetch_requests.py`.
|
- **Resource names are sanitized.** `/fetch-requests` → `fetch_requests.py`.
|
||||||
- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.
|
- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.
|
||||||
- **`_gen/` is disposable.** Regenerate on every spec change; `git diff` shows
|
- **`_gen/` is disposable.** Regenerate on every spec change; `git diff` shows
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ async def create_item(request: Request, body: dict):
|
|||||||
return await svc.create(Item.model_validate(body))
|
return await svc.create(Item.model_validate(body))
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Generated stubs.** `openapi-first routes` emits this exact shape (`async def`
|
||||||
|
handlers taking `body`) when given `--template-style`; without that flag the
|
||||||
|
stub is `def create_item(payload: ItemInput)` — sync, with a `payload`
|
||||||
|
parameter. Both shapes bind identically: the router resolves handlers by
|
||||||
|
`operationId` and introspects the FastAPI signature, and it never inspects the
|
||||||
|
request body. Pass `--template-style` so the generated signatures match the
|
||||||
|
handlers you keep in `interfaces/`.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- `operationId` naming: `list_<resource>`, `create_<resource>`,
|
- `operationId` naming: `list_<resource>`, `create_<resource>`,
|
||||||
@@ -84,6 +92,11 @@ just omit the security keys:
|
|||||||
global `security` block exists. Leaving every operation without a `security`
|
global `security` block exists. Leaving every operation without a `security`
|
||||||
key is the cleanest authless spec.
|
key is the cleanest authless spec.
|
||||||
|
|
||||||
|
Profile endpoints (`/users/me`) and profile headers (`x-profile`) still exist in
|
||||||
|
an authless app. Define a stand-in identity in the composition root — for
|
||||||
|
example `get_current_user` returns or lazily creates a fixed "Demo User" — so
|
||||||
|
handlers stay auth-agnostic and only the wiring changes when real auth arrives.
|
||||||
|
|
||||||
The handlers, services, and composition root are unchanged. When auth is needed
|
The handlers, services, and composition root are unchanged. When auth is needed
|
||||||
later, add the global `security` block and an introspecting dependency; only
|
later, add the global `security` block and an introspecting dependency; only
|
||||||
handlers that must opt out get `security: []`.
|
handlers that must opt out get `security: []`.
|
||||||
@@ -96,7 +109,7 @@ handlers that must opt out get `security: []`.
|
|||||||
```bash
|
```bash
|
||||||
openapi-first models openapi.yaml -o _gen/models.py
|
openapi-first models openapi.yaml -o _gen/models.py
|
||||||
openapi-first routes openapi.yaml -o _gen/routes \
|
openapi-first routes openapi.yaml -o _gen/routes \
|
||||||
--use-models --models-module _gen.models --inject-request --package
|
--use-models --models-module _gen.models --inject-request --package --template-style
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer codegen over hand-writing boilerplate. The result is the wire contract
|
Prefer codegen over hand-writing boilerplate. The result is the wire contract
|
||||||
|
|||||||
@@ -69,11 +69,26 @@ def create_item_repo(*, owner_repo=None) -> ItemRepository:
|
|||||||
return repo_factory(
|
return repo_factory(
|
||||||
collection="items",
|
collection="items",
|
||||||
document=ItemDocument,
|
document=ItemDocument,
|
||||||
populate_rules=[PopulateRule(field="owner", collection="users")],
|
populate_rules=[PopulateRule(field_name="owner", collection_name="users")],
|
||||||
cache=None, # or a CachedBaseRepository config
|
cache=None, # or a CachedBaseRepository config
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Nested references
|
||||||
|
|
||||||
|
The wire contract often declares a reference as an expanded object
|
||||||
|
(`expense.entity: Entity`), while the domain model stays id-free. Pick one
|
||||||
|
convention and apply it consistently:
|
||||||
|
|
||||||
|
- **Embed (default for small, stable refs)** — store the object as a `dict` /
|
||||||
|
`list[dict]` on the document; the API returns it verbatim. No populate wiring.
|
||||||
|
- **Populate** — store an id and declare a `PopulateRule`; mongo-ops expands it
|
||||||
|
on read. The referenced repo must be registered with the `PopulationEngine`.
|
||||||
|
- **Id only** — store the id and let the client resolve it.
|
||||||
|
|
||||||
|
For populate-backed repositories change a reference with `update`, not `patch`:
|
||||||
|
`patch` rejects changes to populated FK fields by design.
|
||||||
|
|
||||||
## ports/service.py
|
## ports/service.py
|
||||||
|
|
||||||
The service is built through the DI library, injecting the repository. The
|
The service is built through the DI library, injecting the repository. The
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Tier A / B / C split.
|
|||||||
- Referenced documents are described declaratively:
|
- Referenced documents are described declaratively:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
PopulateRule(field="owner", collection="users",
|
PopulateRule(field_name="owner", collection_name="users",
|
||||||
nested_rules=[], max_depth=2, projection=None)
|
nested_rules=[], max_depth=2, projection=None)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
23
standards/src/templates/app/.gitignore
vendored
Normal file
23
standards/src/templates/app/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Byte-compiled / caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Generated wire contract (regenerate from openapi.yaml)
|
||||||
|
_gen/
|
||||||
|
|
||||||
|
# Packaging / coverage
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Mongo persistence for items: document schema and repository factory.
|
Mongo persistence for items: document schema and repository factory.
|
||||||
"""
|
"""
|
||||||
|
from mongo_ops import BaseDocument, BaseRepository
|
||||||
|
|
||||||
from domain.item.models import Item
|
from domain.item.models import Item
|
||||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||||
from mongo_ops import BaseDocument, BaseRepository
|
|
||||||
|
|
||||||
|
|
||||||
class ItemDocument(Item, BaseDocument):
|
class ItemDocument(Item, BaseDocument):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Job domain model — a pipeline work item consumed by the background worker.
|
Job domain model — a pipeline work item consumed by the background worker.
|
||||||
"""
|
"""
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Mongo persistence for jobs: document schema and repository factory.
|
Mongo persistence for jobs: document schema and repository factory.
|
||||||
"""
|
"""
|
||||||
|
from mongo_ops import BaseDocument, BaseRepository
|
||||||
|
|
||||||
from domain.job.models import Job
|
from domain.job.models import Job
|
||||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||||
from mongo_ops import BaseDocument, BaseRepository
|
|
||||||
|
|
||||||
|
|
||||||
class JobDocument(Job, BaseDocument):
|
class JobDocument(Job, BaseDocument):
|
||||||
|
|||||||
@@ -21,18 +21,17 @@ Example:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class KVStore(ABC):
|
class KVStore(ABC):
|
||||||
"""Abstract key-value store port."""
|
"""Abstract key-value store port."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Return the value at ``key``, or ``None``."""
|
"""Return the value at ``key``, or ``None``."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -40,7 +39,7 @@ class KVStore(ABC):
|
|||||||
"""Delete ``key`` if present."""
|
"""Delete ``key`` if present."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def scan(self, pattern: str) -> List[str]:
|
async def scan(self, pattern: str) -> list[str]:
|
||||||
"""Return keys matching a glob ``pattern``."""
|
"""Return keys matching a glob ``pattern``."""
|
||||||
|
|
||||||
async def clear(self, pattern: str = "*") -> None:
|
async def clear(self, pattern: str = "*") -> None:
|
||||||
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize an empty store with no imports or connections."""
|
"""Initialize an empty store with no imports or connections."""
|
||||||
self._data: Dict[str, str] = {}
|
self._data: dict[str, str] = {}
|
||||||
self._expiry: Dict[str, float] = {}
|
self._expiry: dict[str, float] = {}
|
||||||
|
|
||||||
def _expired(self, key: str) -> bool:
|
def _expired(self, key: str) -> bool:
|
||||||
import time
|
import time
|
||||||
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Return the value at ``key``, or ``None`` when absent/expired."""
|
"""Return the value at ``key``, or ``None`` when absent/expired."""
|
||||||
if self._expired(key):
|
if self._expired(key):
|
||||||
return None
|
return None
|
||||||
return self._data.get(key)
|
return self._data.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
|
|||||||
self._data.pop(key, None)
|
self._data.pop(key, None)
|
||||||
self._expiry.pop(key, None)
|
self._expiry.pop(key, None)
|
||||||
|
|
||||||
async def scan(self, pattern: str) -> List[str]:
|
async def scan(self, pattern: str) -> list[str]:
|
||||||
"""Return live keys matching a glob ``pattern``."""
|
"""Return live keys matching a glob ``pattern``."""
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
|
||||||
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
|
|||||||
|
|
||||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Return the value at ``key``, or ``None``."""
|
"""Return the value at ``key``, or ``None``."""
|
||||||
return await self._redis.get(key)
|
return await self._redis.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||||
await self._redis.set(key, value, ex=ttl)
|
await self._redis.set(key, value, ex=ttl)
|
||||||
|
|
||||||
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
|
|||||||
"""Delete ``key`` if present."""
|
"""Delete ``key`` if present."""
|
||||||
await self._redis.delete(key)
|
await self._redis.delete(key)
|
||||||
|
|
||||||
async def scan(self, pattern: str) -> List[str]:
|
async def scan(self, pattern: str) -> list[str]:
|
||||||
"""Return keys matching a glob ``pattern``."""
|
"""Return keys matching a glob ``pattern``."""
|
||||||
return [key async for key in self._redis.scan_iter(match=pattern)]
|
return [key async for key in self._redis.scan_iter(match=pattern)]
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ Generic CRUD service adaptor over a Mongo repository.
|
|||||||
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
|
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
|
||||||
patch, delete, and search operations for every domain that needs them.
|
patch, delete, and search operations for every domain that needs them.
|
||||||
"""
|
"""
|
||||||
from typing import Generic, Type, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from mongo_ops import BaseDocument, PopulatingRepository
|
||||||
|
from mongo_ops.models import PyObjectId
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from infra.db.repos import mongo
|
from infra.db.repos import mongo
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from mongo_ops import BaseDocument
|
|
||||||
from mongo_ops.models import PyObjectId
|
|
||||||
|
|
||||||
Model = TypeVar("Model", bound=BaseModel)
|
Model = TypeVar("Model", bound=BaseModel)
|
||||||
Schema = TypeVar("Schema", bound=BaseDocument)
|
Schema = TypeVar("Schema", bound=BaseDocument)
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
repo: mongo.BaseRepository
|
repo: mongo.BaseRepository
|
||||||
model: Type[Model]
|
model: type[Model]
|
||||||
schema: Type[Schema]
|
schema: type[Schema]
|
||||||
|
|
||||||
def __init__(self, *, repo=None):
|
def __init__(self, *, repo=None):
|
||||||
"""Initialize the adaptor with a backing repository.
|
"""Initialize the adaptor with a backing repository.
|
||||||
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Schema | None: Updated document, or ``None`` when absent.
|
Schema | None: Updated document, or ``None`` when absent.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
A populating repository expects a document *model* (so FK fields can
|
||||||
|
be depopulated), while a base repository expects a raw mapping. This
|
||||||
|
method normalizes both inputs to whatever the repo requires.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(self.repo, PopulatingRepository):
|
||||||
|
if not isinstance(data, self.schema):
|
||||||
|
fields = data if isinstance(data, dict) else self.model_to_dict(data)
|
||||||
|
data = self.schema(**fields)
|
||||||
|
return await self.repo.update(id, data)
|
||||||
return await self.repo.update(
|
return await self.repo.update(
|
||||||
id,
|
id,
|
||||||
data if isinstance(data, dict) else self.model_to_dict(data),
|
data if isinstance(data, dict) else self.model_to_dict(data),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ SSEInMemoryBus — in-memory event bus for SSE progress events.
|
|||||||
Buffer published progress events so they can be replayed (and streamed) per
|
Buffer published progress events so they can be replayed (and streamed) per
|
||||||
correlation key, such as a job, upload, or fetch-request id.
|
correlation key, such as a job, upload, or fetch-request id.
|
||||||
"""
|
"""
|
||||||
from typing import Generic, List, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
|
|||||||
key_attr (str): Attribute name on each event holding the stream
|
key_attr (str): Attribute name on each event holding the stream
|
||||||
correlation key (for example ``"job_id"``).
|
correlation key (for example ``"job_id"``).
|
||||||
"""
|
"""
|
||||||
self._events: List[T] = []
|
self._events: list[T] = []
|
||||||
self._key_attr = key_attr
|
self._key_attr = key_attr
|
||||||
|
|
||||||
async def publish(self, event: T) -> None:
|
async def publish(self, event: T) -> None:
|
||||||
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
|
|||||||
"""
|
"""
|
||||||
self._events.append(event)
|
self._events.append(event)
|
||||||
|
|
||||||
def get_events(self, key: str) -> List[T]:
|
def get_events(self, key: str) -> list[T]:
|
||||||
"""Return buffered events for a correlation key.
|
"""Return buffered events for a correlation key.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -5,21 +5,20 @@ Generic MongoDB CRUD repo — single implementation for all domains.
|
|||||||
Select a concrete mongo-ops repository (base, cached, or populating) from the
|
Select a concrete mongo-ops repository (base, cached, or populating) from the
|
||||||
options supplied to the factory.
|
options supplied to the factory.
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional, Type
|
|
||||||
|
|
||||||
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
||||||
from mongo_ops.cache import CacheBackend, CacheConfig
|
from mongo_ops.cache import CacheBackend, CacheConfig
|
||||||
from mongo_ops.models import BaseDocument
|
from mongo_ops.models import BaseDocument
|
||||||
from mongo_ops.populate import PopulationEngine, PopulateRule
|
from mongo_ops.populate import PopulateRule, PopulationEngine
|
||||||
|
|
||||||
|
|
||||||
def _repo_factory(
|
def _repo_factory(
|
||||||
collection: str,
|
collection: str,
|
||||||
db_model: Type[BaseDocument],
|
db_model: type[BaseDocument],
|
||||||
cache_backend: Optional[CacheBackend] = None,
|
cache_backend: CacheBackend | None = None,
|
||||||
cache_config: Optional[CacheConfig] = None,
|
cache_config: CacheConfig | None = None,
|
||||||
engine: Optional[PopulationEngine] = None,
|
engine: PopulationEngine | None = None,
|
||||||
populate_rules: Optional[List[PopulateRule]] = None,
|
populate_rules: list[PopulateRule] | None = None,
|
||||||
) -> BaseRepository:
|
) -> BaseRepository:
|
||||||
"""Build the appropriate mongo-ops repository.
|
"""Build the appropriate mongo-ops repository.
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ Notes:
|
|||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
@@ -55,7 +56,7 @@ class HttpCacheStore:
|
|||||||
|
|
||||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||||
|
|
||||||
async def get(self, key: str) -> Optional[str]:
|
async def get(self, key: str) -> str | None:
|
||||||
"""Read the value stored at ``key``.
|
"""Read the value stored at ``key``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
|
|||||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def cache_response(ttl: Optional[int] = None) -> Callable:
|
def cache_response(ttl: int | None = None) -> Callable:
|
||||||
"""Decorate a GET-style route handler to cache its JSON response.
|
"""Decorate a GET-style route handler to cache its JSON response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
|
|||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
db_resources = getattr(request.app.state, "db_resources", None) or {}
|
db_resources = getattr(request.app.state, "db_resources", None) or {}
|
||||||
store: Optional[HttpCacheStore] = db_resources.get("http_cache_store")
|
store: HttpCacheStore | None = db_resources.get("http_cache_store")
|
||||||
if store is None:
|
if store is None:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Infrastructure initialization — connects MongoDB and wires all repos and servi
|
|||||||
Shared bootstrap for both the FastAPI app and the worker, guarded against
|
Shared bootstrap for both the FastAPI app and the worker, guarded against
|
||||||
repeated initialization via a module-level flag.
|
repeated initialization via a module-level flag.
|
||||||
"""
|
"""
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from domain.item.ports.db import create_item_repo
|
from domain.item.ports.db import create_item_repo
|
||||||
@@ -13,11 +13,11 @@ from domain.job.ports.db import create_job_repo
|
|||||||
from domain.job.ports.service import get_job_service
|
from domain.job.ports.service import get_job_service
|
||||||
from infra.db.setup import setup_database, shutdown_database
|
from infra.db.setup import setup_database, shutdown_database
|
||||||
|
|
||||||
_resources: Dict[str, Any] = {}
|
_resources: dict[str, Any] = {}
|
||||||
_initialized: bool = False
|
_initialized: bool = False
|
||||||
|
|
||||||
|
|
||||||
async def init() -> Dict[str, Any]:
|
async def init() -> dict[str, Any]:
|
||||||
"""Connect MongoDB, wire all repos and services, and return the resources dict.
|
"""Connect MongoDB, wire all repos and services, and return the resources dict.
|
||||||
|
|
||||||
Idempotent: repeated calls short-circuit and return the cached resources.
|
Idempotent: repeated calls short-circuit and return the cached resources.
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ All handler functions — names must match openapi.yaml operationId values. The
|
|||||||
OpenAPI-first router resolves each operation to a handler by name, so this module
|
OpenAPI-first router resolves each operation to a handler by name, so this module
|
||||||
re-exports every route handler from the subpackages into one flat namespace.
|
re-exports every route handler from the subpackages into one flat namespace.
|
||||||
"""
|
"""
|
||||||
|
# ruff: noqa: F403
|
||||||
from interfaces.item.routes import *
|
from interfaces.item.routes import *
|
||||||
from interfaces.job.routes import *
|
from interfaces.job.routes import *
|
||||||
@@ -4,6 +4,7 @@ FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
|
|||||||
Handlers are looked up by operationId in interfaces.openapi, so no router tables
|
Handlers are looked up by operationId in interfaces.openapi, so no router tables
|
||||||
are declared here; the lifespan initializes and shuts down app resources.
|
are declared here; the lifespan initializes and shuts down app resources.
|
||||||
"""
|
"""
|
||||||
|
# ruff: noqa: E402
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from hexa import build
|
from hexa import build
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Family registry — resolve a ``(kind, variant, mode)`` triple to a root class.
|
|||||||
|
|
||||||
A single lookup replaces hand-rolled ``if/else`` family selection.
|
A single lookup replaces hand-rolled ``if/else`` family selection.
|
||||||
"""
|
"""
|
||||||
from typing import Dict, Type
|
|
||||||
|
|
||||||
# Populated by ``register`` as families import.
|
# Populated by ``register`` as families import.
|
||||||
_FAMILIES: Dict[str, Dict[str, Dict[str, Type]]] = {}
|
_FAMILIES: dict[str, dict[str, dict[str, type]]] = {}
|
||||||
|
|
||||||
|
|
||||||
def register(kind: str, variant: str, root_cls: Type, mode: str = "default") -> Type:
|
def register(kind: str, variant: str, root_cls: type, mode: str = "default") -> type:
|
||||||
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
|
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
|
|||||||
return root_cls
|
return root_cls
|
||||||
|
|
||||||
|
|
||||||
def resolve_root(kind: str, variant: str, mode: str = "default") -> Type:
|
def resolve_root(kind: str, variant: str, mode: str = "default") -> type:
|
||||||
"""Return the root class for a ``(kind, variant, mode)`` triple.
|
"""Return the root class for a ``(kind, variant, mode)`` triple.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ testpaths = ["tests"]
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
extend-exclude = ["_gen"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP"]
|
select = ["E", "F", "I", "UP"]
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import interfaces.openapi as routes_module
|
|
||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
|
||||||
|
import interfaces.openapi as routes_module
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_app_builds():
|
def test_openapi_app_builds():
|
||||||
"""The spec-first app must build and plant each route handler."""
|
"""The spec-first app must build and plant each route handler."""
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from openapi_first.app import OpenAPIFirstApp
|
||||||
|
|
||||||
import interfaces.openapi as routes_module
|
import interfaces.openapi as routes_module
|
||||||
|
|
||||||
from openapi_first.app import OpenAPIFirstApp
|
|
||||||
|
|
||||||
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
58
trial.md
Normal file
58
trial.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Trial: build an Aetos app from the Standards blueprint (self-run, no auth)
|
||||||
|
|
||||||
|
You are a zero-context builder. Test the Aetos *standards* by reproducing the
|
||||||
|
reference app "khata-app" WITHOUT auth into `apps/khata-trial`, using ONLY the
|
||||||
|
standard. You may consult the existing khata-app repo ONLY as the answer key to
|
||||||
|
the intake questions below. Never copy its implementation code.
|
||||||
|
|
||||||
|
## Sources of truth (read these first)
|
||||||
|
- Standards MCP: http://localhost:8009/mcp
|
||||||
|
tools: find_library, list_app_components, get_app_component
|
||||||
|
resources: docs://nav, docs://modules/{module}, registry://libraries, registry://components
|
||||||
|
prompt: start_app
|
||||||
|
- Tier A library MCPs (read a library's own docs before using it):
|
||||||
|
openapi-first :8004, mongo-ops :8007, hexa :8008, dagpipe :8006,
|
||||||
|
omniread :8003, mail-intake :8002, py-jwt :8005, doc-forge :8001
|
||||||
|
- Standard docs: docs://modules/build_recipe, docs://modules/genericity_matrix,
|
||||||
|
docs://modules/layering_rules
|
||||||
|
- If a server is not running, start all of them from the `aetos/docs` checkout:
|
||||||
|
`.venv\Scripts\python.exe main.py`
|
||||||
|
|
||||||
|
## Step 0 — Intake (ask yourself the standard's questions)
|
||||||
|
1. App name + one-line purpose
|
||||||
|
2. Axis 1 — resources: each resource, fields, relationships (or paste openapi.yaml)
|
||||||
|
3. Axis 2 — pipelines: multi-step inputs? families + formats (or none)
|
||||||
|
4. Auth needed now? (yes/no)
|
||||||
|
5. Integrations: Mongo / ingestion formats / email / LLM provider
|
||||||
|
|
||||||
|
## Step 1 — Answer the intake from khata-app ONLY (answers only)
|
||||||
|
Resolve each answer from these artifacts, and nothing else:
|
||||||
|
- resources + fields + relationships + operationIds <- openapi.yaml (schemas + operationId names)
|
||||||
|
- pipeline families + input formats <- directory layout under pipelines/ (+ each pipeline.yaml name)
|
||||||
|
- integrations <- pyproject.toml deps + .env.example variable names
|
||||||
|
- auth <- the GLOBAL `security` block in openapi.yaml (this trial: NONE)
|
||||||
|
Do NOT read or copy khata-app implementation modules. Write the answers down first.
|
||||||
|
|
||||||
|
## Step 2 — Build (follow the standard)
|
||||||
|
Follow docs://modules/build_recipe step by step:
|
||||||
|
- Scaffold from the standard template. Prefer pulling files via get_app_component;
|
||||||
|
fallback: copy <docs>/standards/src/templates/app into apps/khata-trial.
|
||||||
|
- Author openapi.yaml for the resolved resources, OMITTING auth (no global
|
||||||
|
`security`, no bearer scheme).
|
||||||
|
- Author every CRUD slice; wire the composition root; add thin routes; bootstrap
|
||||||
|
app + worker; add pipeline families; add tests and operator scripts.
|
||||||
|
- Tier A = call the library (never reimplement); Tier B = copy the template;
|
||||||
|
Tier C = author.
|
||||||
|
|
||||||
|
## Step 3 — Verify and report
|
||||||
|
- python -c "import main" succeeds
|
||||||
|
- pytest passes
|
||||||
|
- every no-auth khata-app operationId is served -> report matched / missing
|
||||||
|
- no domain/ module imports fastapi/starlette; no Tier A library reimplemented
|
||||||
|
|
||||||
|
## Step 4 — Report standard gaps (primary output)
|
||||||
|
List every point where the standard was insufficient, wrong, or missing: a file
|
||||||
|
the docs said to copy but that didn't exist, an MCP tool that couldn't retrieve
|
||||||
|
a needed file, an intake question that wasn't answerable, etc.
|
||||||
|
|
||||||
|
Output: apps/khata-trial
|
||||||
130
trial_findings.md
Normal file
130
trial_findings.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Trial findings — build a khata app (no auth) from the Standards blueprint
|
||||||
|
|
||||||
|
Scope: reproduce `apps/khata-app` WITHOUT auth into `apps/khata-trial`, using ONLY
|
||||||
|
the standards (MCP bundle + library MCPs + `docs://`). khata-app consulted as the
|
||||||
|
answer key for intake only; implementation never copied.
|
||||||
|
|
||||||
|
Date: 2026-10-13. Status: trial build GREEN (imports, tests, live HTTP, pipeline).
|
||||||
|
|
||||||
|
## Verification evidence (what the standard successfully produced)
|
||||||
|
|
||||||
|
- `python -c "import main"` — OK; app builds with 46 routes (42 operationIds + 4
|
||||||
|
FastAPI defaults). Every no-auth operationId resolved to a handler.
|
||||||
|
- `pytest tests -q` — 6 passed, incl. the generic
|
||||||
|
`tests/integration/test_openapi_app_builds.py` that asserts each spec
|
||||||
|
operationId resolves against `interfaces.openapi`.
|
||||||
|
- Live end-to-end (real MongoDB, `TestClient`): 19 endpoints across all 8
|
||||||
|
resources returned 2xx — users(create/list/me/get/put), tags, accounts,
|
||||||
|
entities(+logo), expenses(create/list), fetch-requests(create/list/get/
|
||||||
|
ambiguities), reports(create/list). `FAILURES: none`.
|
||||||
|
- Deletes: `DELETE` returns 204 for entities/tags/accounts/expenses/
|
||||||
|
fetch-requests/reports; the follow-up `GET` correctly returns 404.
|
||||||
|
- Pipeline axis: `run_fetch_request` advanced status pending→completed and
|
||||||
|
published 7 progress events on the bus (`raw_lines`, `txn_blocks`,
|
||||||
|
`raw_expenses`, `enrich_entity`, `enrich_tags`, `save_expenses`).
|
||||||
|
- Layering respected: no `domain/` module imports fastapi/starlette; no Tier A
|
||||||
|
library reimplemented.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
Severity: **H**igh (blocks a 1-shot build), **M**edium (agent must work around),
|
||||||
|
**L**ow (rough edge).
|
||||||
|
|
||||||
|
| # | Area | Sev | Finding | Proposed standard fix |
|
||||||
|
|---|------|-----|---------|-----------------------|
|
||||||
|
| F1 | build_recipe | H | Recipe never mentioned the spec→code step; agent had to discover `python -m openapi_first.cli {models,routes}` unaided. | FIXED (P1): codegen codified in recipe + prompt + wiki. |
|
||||||
|
| F2 | codegen | H | `openapi-first` CLI output is not consumable as-is: models land in one file with globally renumbered enums (`Type`, `Type1`, `Type2`…), routes land in per-tag packages with `payload:`/sync signatures. | FIXED (P3): `--inject-request`, `--package`, `--use-models`, `--models-module`; documented shape. |
|
||||||
|
| F3 | wire vs doc | M | Generated wire models require top-level `id` + `created_at`/`updated_at`, but Mongo docs own those via `BaseDocument`; `class XDoc(X, BaseDocument)` keeps `id` required and fights the repo. | FIXED (P4): API-model→document convention documented; keep domain models id-free. |
|
||||||
|
| F4 | spec→slices | M | No documented bridge from spec tags/operationIds to domain slices; agent inferred the 9-slice split. | Build recipe now shows tag→slice mapping (P1). |
|
||||||
|
| F5 | authless | M | Authless path (strip global security + per-op `security: []` + `securitySchemes`) and the `/users/me` behaviour were manual/undefined. | FIXED (P5): authless guidance added. |
|
||||||
|
| F6 | components | H | `get_app_component` omitted `__init__.py` files and `interfaces/openapi.py`; no tool returned the whole tree. | FIXED (P6a): `get_app_template` returns the full tree; package-inits registered. |
|
||||||
|
| F7 | docs:// | H | Library MCP resource URIs regressed to `doc://`. | FIXED (P0): restored `docs://`. |
|
||||||
|
| F8 | Tier B | M | Tier-B files referenced by the docs (`http_cache`, progress bus, KV store) were not shipped. | FIXED (P6b): shipped `infra/http_cache.py`, `infra/adapters/{sse,kv}.py`. |
|
||||||
|
| F9 | prompts | M | Only `start_app` existed; no build/intake/trial prompts. | FIXED (P7): `build`, `intake`, `trial` added. |
|
||||||
|
| **F10** | codegen shape | L | Generated route stubs use `payload: <Model>`, sync `def`, body `raise NotImplementedError`; the template convention is `body: dict` + async. Both bind (binder introspects FastAPI signatures) but the mismatch is undocumented. | Document both accepted handler shapes, or emit template-style stubs. |
|
||||||
|
| **F11** | docs defect | H | `05_crud_template.md` and `09_infra_adaptors.md` show `PopulateRule(field="owner", collection="users")`, but the real dataclass fields are **`field_name`** / **`collection_name`** (`mongo_ops/populate/rules.py:37`). Copying the doc raises `TypeError`. | Correct the two doc examples to `field_name=`/`collection_name=`. **FIXED.** |
|
||||||
|
| **F12** | lib interop | M | `CrudsServiceAdaptor.update` (`infra/adapters/service.py`) always forwards a **dict**, but `PopulatingRepository.update(id, data: T)` calls `_depopulate(data)` → `document.model_dump(...)`, requiring a **model** (`mongo_ops/repository.py:404,384`). Populate + update through the adaptor raises `AttributeError`. | Adaptor now detects `PopulatingRepository` and builds the document model before calling `update`. **FIXED.** |
|
||||||
|
| **F13** | modeling | M | Wire contract declares refs expanded (`expense.entity: Entity`, `account.owner: User`), while id-free domain models store FKs. Trial chose embedded dicts / string FKs. No single documented convention. | Added a "Nested references" section to `05_crud_template.md` (embed / populate / id). **FIXED.** |
|
||||||
|
| **F14** | authless | L | `/users/me` (`get_current_user`, `update_current_user`, `x-profile: true`) has no defined authless semantics; agent invented "auto-create Demo User". | Added authless profile-endpoint guidance to `04_spec_first_routing.md`. **FIXED.** |
|
||||||
|
| **F15** | template lint | M | The template's own `pyproject.toml` sets `ruff select = ["E","F","I","UP"]` but template-shipped files violate it: `main.py` E402, `interfaces/openapi.py` F403, `infra/adapters/{kv,sse,service}.py` + `infra/http_cache.py` UP/I, `pipelines/shared/{registry,loader}.py` UP/I. | Fixed typing/imports, added file-level `noqa`s, and `extend-exclude = ["_gen"]`; template now `ruff check` clean. **FIXED.** |
|
||||||
|
| **F16** | template ship | M | The template vendors a `.ruff_cache/` directory into every generated app (`build.py` `copytree` ignore list only skipped `__pycache__`/`*.pyc`), and ships no `.gitignore`. | Hardened the `copytree` ignore list (`.ruff_cache`, `.pytest_cache`, `.mypy_cache`, `.venv`, `venv`, `.git`, `*.egg-info`, `htmlcov`), added a first-class `gitignore` component + template `.gitignore`. Component count 42→**43**. **FIXED.** |
|
||||||
|
|
||||||
|
## F10 resolution (emitter) + re-trial evidence
|
||||||
|
|
||||||
|
`openapi-first` now emits template-convention stubs when asked:
|
||||||
|
|
||||||
|
- `generate_routes(..., template_style=True)` / CLI `--template-style` emits
|
||||||
|
`async def`, a JSON body parameter named `body` (both `body: dict` and
|
||||||
|
`body: <Model>`), and (with `--inject-request`) a service-lookup hint comment
|
||||||
|
`# Resolve the service: request.app.state.db_resources["<resource>_svc"]`
|
||||||
|
before `raise NotImplementedError`. Default behaviour is unchanged
|
||||||
|
(`def` / `payload`).
|
||||||
|
- Tests: `tests/test_codegen_routes.py` gained
|
||||||
|
`test_template_style_async_body_model` and
|
||||||
|
`test_template_style_dict_body_without_models`; suite now **32 passed** (was
|
||||||
|
30); `ruff check` clean. Wiki `03_use_cases/04_codegen.md` documents the flag.
|
||||||
|
MCP bundle regenerated (`docs/mcp/modules/openapi_first.json` contains
|
||||||
|
`template_style`). Standards `spec_first_routing`/`build_recipe` modules
|
||||||
|
updated to reference `--template-style`.
|
||||||
|
|
||||||
|
**Re-trial A (template-level):** scaffolded the template into `apps/_tpl-check`,
|
||||||
|
`pytest` → 7 passed; `import main` → 11 routes; live `TestClient` Mongo smoke
|
||||||
|
→ `FAILURES: none`; targeted F12 populate+update check → `FAILURES: none`.
|
||||||
|
Scratch dir + DB removed.
|
||||||
|
|
||||||
|
**Re-trial B (full khata rebuild):** scaffolded `apps/khata-trial`, stripped auth
|
||||||
|
(2286-line spec), ran codegen with `--template-style` → 9 route files + `_gen`
|
||||||
|
models, authored all 9 domain slices, 42 handlers, composition root, worker, and
|
||||||
|
fetch pipeline. Then:
|
||||||
|
|
||||||
|
- `import main` → 46 routes (42 operationIds + 4 FastAPI defaults); every
|
||||||
|
operationId resolved.
|
||||||
|
- `pytest` → 2 passed.
|
||||||
|
- Live end-to-end (real MongoDB, `httpx.ASGITransport`): **43 checks, 0
|
||||||
|
failures** — all 9 resources CRUD, entity-logo multipart, `/users/me` authless
|
||||||
|
auto-create, deletes 204 + follow-up 404, pipeline pending→completed, SSE
|
||||||
|
events replay, raw octet-stream upload + download, report create/get/delete.
|
||||||
|
- One emitter-adjacent trap re-confirmed: a handler parameter named `response`
|
||||||
|
must be annotated (`response: Response`) or FastAPI binds it as a query param;
|
||||||
|
returning `Response(status_code=204)` from deletes is the safe template idiom.
|
||||||
|
- `apps/khata-trial` + `khata_trial` DB removed after verification.
|
||||||
|
|
||||||
|
## Recommended standard changes (from F10–F16)
|
||||||
|
|
||||||
|
1. Fix the `PopulateRule` examples (F11) — highest value, one-line corrections.
|
||||||
|
2. Resolve the populate/update interop gap (F12) — either fix the adaptor or
|
||||||
|
document the incompatibility.
|
||||||
|
3. Add a "nested refs" modeling subsection (F13) and authless `/users/me`
|
||||||
|
behavior (F14).
|
||||||
|
4. Make the shipped template pass its own ruff config and exclude generated code
|
||||||
|
(F15).
|
||||||
|
5. Document the two accepted codegen handler shapes (F10).
|
||||||
|
6. Stop vendoring `.ruff_cache` into apps and ship a `.gitignore` (F16).
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
F11–F16 applied to the standard sources and the MCP bundle rebuilt
|
||||||
|
(`build.py --name standards`: 12 modules, **43 components**, lib + wiki HTML):
|
||||||
|
|
||||||
|
- F11 — `field=`/`collection=` → `field_name=`/`collection_name=` in
|
||||||
|
`05_crud_template.md`, `09_infra_adaptors.md`.
|
||||||
|
- F12 — `infra/adapters/service.py` `update` handles `PopulatingRepository`.
|
||||||
|
- F13 — new "Nested references" section in `05_crud_template.md`.
|
||||||
|
- F14 — authless profile-endpoint guidance in `04_spec_first_routing.md`.
|
||||||
|
- F15 — template `ruff check` clean (`extend-exclude = ["_gen"]`, file-level
|
||||||
|
`noqa`s, typing/import fixes); template recompiles.
|
||||||
|
- F16 — `build.py` ignore list hardened; `gitignore` component + template
|
||||||
|
`.gitignore` added.
|
||||||
|
|
||||||
|
The running `docs` image bakes the bundle, so it was rebuilt
|
||||||
|
(`aetos-docs:standards5`) and the `docs` container swapped onto it; MCP
|
||||||
|
`:8009` live-verified (tools/prompts/resources unchanged, 43 components,
|
||||||
|
`gitignore` present).
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Trials (original + re-trial A + re-trial B) proved the standard buildable
|
||||||
|
end-to-end; F1–F16 recorded and F11–F16 fixed. F10 resolved in the
|
||||||
|
`openapi-first` emitter. `apps/khata-trial` removed after verification. Remaining
|
||||||
|
items in "Recommended standard changes" are cosmetic/documentation-only and will
|
||||||
|
not meaningfully change build outcome quality.
|
||||||
Reference in New Issue
Block a user