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:
@@ -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-86">86</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-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
|
||||
@@ -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-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-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-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-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-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-44"><a id="__codelineno-2-44" name="__codelineno-2-44"></a>
|
||||
</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-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-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-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-49"><a id="__codelineno-2-49" name="__codelineno-2-49"></a>
|
||||
</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-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-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-53"><a id="__codelineno-2-53" name="__codelineno-2-53"></a>
|
||||
</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-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-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-57"><a id="__codelineno-2-57" name="__codelineno-2-57"></a> decorator to read endpoints.
|
||||
</span><span id="__span-2-58"><a id="__codelineno-2-58" name="__codelineno-2-58"></a>
|
||||
</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-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-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-62"><a id="__codelineno-2-62" name="__codelineno-2-62"></a>
|
||||
</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-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-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-66"><a id="__codelineno-2-66" name="__codelineno-2-66"></a> root. Add fixtures and a parity test.
|
||||
</span><span id="__span-2-67"><a id="__codelineno-2-67" name="__codelineno-2-67"></a>
|
||||
</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-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-70"><a id="__codelineno-2-70" name="__codelineno-2-70"></a> library.
|
||||
</span><span id="__span-2-71"><a id="__codelineno-2-71" name="__codelineno-2-71"></a>
|
||||
</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-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-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-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-76"><a id="__codelineno-2-76" name="__codelineno-2-76"></a>
|
||||
</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-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-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> 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> 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="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> (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><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> 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="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> <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>
|
||||
</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> 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> 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>
|
||||
</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> 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> 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> decorator to read endpoints.
|
||||
</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 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> 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> 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><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> 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="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> root. Add fixtures and a parity test.
|
||||
</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 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> 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> library.
|
||||
</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><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> 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> 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> 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-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-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-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">`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-83"><a id="__codelineno-2-83" name="__codelineno-2-83"></a><span class="gs">**Definition of done:**</span>
|
||||
</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-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-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-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-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-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="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><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><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><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>
|
||||
<hr />
|
||||
<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
|
||||
a required <code>id: str</code>, keep it off the domain model and let the document get its
|
||||
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>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
|
||||
|
||||
@@ -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-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>
|
||||
<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>
|
||||
<ul>
|
||||
<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>
|
||||
key is the cleanest authless spec.</li>
|
||||
</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
|
||||
later, add the global <code>security</code> block and an introspecting dependency; only
|
||||
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-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-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>
|
||||
<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>
|
||||
|
||||
@@ -427,6 +427,15 @@
|
||||
</span>
|
||||
</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 class="md-nav__item">
|
||||
@@ -665,6 +674,15 @@
|
||||
</span>
|
||||
</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 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-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-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-11"><a id="__codelineno-2-11" name="__codelineno-2-11"></a> <span class="p">)</span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -738,7 +738,7 @@ Tier A / B / C split.</p>
|
||||
</li>
|
||||
</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>
|
||||
<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></code></pre></div></td></tr></table></div>
|
||||
<ul>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -192,6 +192,12 @@
|
||||
"placeholder": "",
|
||||
"description": "Docker build-context exclusions."
|
||||
},
|
||||
{
|
||||
"kind": "gitignore",
|
||||
"path": ".gitignore",
|
||||
"placeholder": "",
|
||||
"description": "VCS exclusions (caches, venv, generated _gen/)."
|
||||
},
|
||||
{
|
||||
"kind": "compose",
|
||||
"path": "docker-compose.yaml",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"module": "crud_template",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "infra_adaptors",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "spec_first_routing",
|
||||
"content": {
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
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.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
from domain.item.models import Item
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
|
||||
class ItemDocument(Item, BaseDocument):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Job domain model — a pipeline work item consumed by the background worker.
|
||||
"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
Mongo persistence for jobs: document schema and repository factory.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
from domain.job.models import Job
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
|
||||
class JobDocument(Job, BaseDocument):
|
||||
|
||||
@@ -21,18 +21,17 @@ Example:
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class KVStore(ABC):
|
||||
"""Abstract key-value store port."""
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the value at ``key``, or ``None``."""
|
||||
|
||||
@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."""
|
||||
|
||||
@abstractmethod
|
||||
@@ -40,7 +39,7 @@ class KVStore(ABC):
|
||||
"""Delete ``key`` if present."""
|
||||
|
||||
@abstractmethod
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
async def scan(self, pattern: str) -> list[str]:
|
||||
"""Return keys matching a glob ``pattern``."""
|
||||
|
||||
async def clear(self, pattern: str = "*") -> None:
|
||||
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty store with no imports or connections."""
|
||||
self._data: Dict[str, str] = {}
|
||||
self._expiry: Dict[str, float] = {}
|
||||
self._data: dict[str, str] = {}
|
||||
self._expiry: dict[str, float] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
import time
|
||||
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
|
||||
return True
|
||||
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."""
|
||||
if self._expired(key):
|
||||
return None
|
||||
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."""
|
||||
import time
|
||||
|
||||
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
|
||||
self._data.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``."""
|
||||
import fnmatch
|
||||
|
||||
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
|
||||
|
||||
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 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."""
|
||||
await self._redis.set(key, value, ex=ttl)
|
||||
|
||||
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
|
||||
"""Delete ``key`` if present."""
|
||||
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 [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,
|
||||
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 pydantic import BaseModel
|
||||
from mongo_ops import BaseDocument
|
||||
from mongo_ops.models import PyObjectId
|
||||
|
||||
Model = TypeVar("Model", bound=BaseModel)
|
||||
Schema = TypeVar("Schema", bound=BaseDocument)
|
||||
|
||||
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
||||
"""
|
||||
|
||||
repo: mongo.BaseRepository
|
||||
model: Type[Model]
|
||||
schema: Type[Schema]
|
||||
model: type[Model]
|
||||
schema: type[Schema]
|
||||
|
||||
def __init__(self, *, repo=None):
|
||||
"""Initialize the adaptor with a backing repository.
|
||||
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
||||
|
||||
Returns:
|
||||
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(
|
||||
id,
|
||||
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
|
||||
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")
|
||||
|
||||
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
|
||||
key_attr (str): Attribute name on each event holding the stream
|
||||
correlation key (for example ``"job_id"``).
|
||||
"""
|
||||
self._events: List[T] = []
|
||||
self._events: list[T] = []
|
||||
self._key_attr = key_attr
|
||||
|
||||
async def publish(self, event: T) -> None:
|
||||
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
options supplied to the factory.
|
||||
"""
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
||||
from mongo_ops.cache import CacheBackend, CacheConfig
|
||||
from mongo_ops.models import BaseDocument
|
||||
from mongo_ops.populate import PopulationEngine, PopulateRule
|
||||
from mongo_ops.populate import PopulateRule, PopulationEngine
|
||||
|
||||
|
||||
def _repo_factory(
|
||||
collection: str,
|
||||
db_model: Type[BaseDocument],
|
||||
cache_backend: Optional[CacheBackend] = None,
|
||||
cache_config: Optional[CacheConfig] = None,
|
||||
engine: Optional[PopulationEngine] = None,
|
||||
populate_rules: Optional[List[PopulateRule]] = None,
|
||||
db_model: type[BaseDocument],
|
||||
cache_backend: CacheBackend | None = None,
|
||||
cache_config: CacheConfig | None = None,
|
||||
engine: PopulationEngine | None = None,
|
||||
populate_rules: list[PopulateRule] | None = None,
|
||||
) -> BaseRepository:
|
||||
"""Build the appropriate mongo-ops repository.
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ Notes:
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
@@ -55,7 +56,7 @@ class HttpCacheStore:
|
||||
|
||||
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``.
|
||||
|
||||
Args:
|
||||
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
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:
|
||||
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
|
||||
repeated initialization via a module-level flag.
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
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 infra.db.setup import setup_database, shutdown_database
|
||||
|
||||
_resources: Dict[str, Any] = {}
|
||||
_resources: dict[str, Any] = {}
|
||||
_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.
|
||||
|
||||
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
|
||||
re-exports every route handler from the subpackages into one flat namespace.
|
||||
"""
|
||||
# ruff: noqa: F403
|
||||
from interfaces.item.routes import *
|
||||
from interfaces.job.routes import *
|
||||
@@ -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
|
||||
are declared here; the lifespan initializes and shuts down app resources.
|
||||
"""
|
||||
# ruff: noqa: E402
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
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.
|
||||
"""
|
||||
from typing import Dict, Type
|
||||
|
||||
# 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.
|
||||
|
||||
Returns:
|
||||
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
|
||||
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.
|
||||
|
||||
Raises:
|
||||
|
||||
@@ -40,6 +40,7 @@ testpaths = ["tests"]
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
extend-exclude = ["_gen"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
@@ -1,9 +1,9 @@
|
||||
import importlib
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
|
||||
def test_openapi_app_builds():
|
||||
"""The spec-first app must build and plant each route handler."""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import importlib
|
||||
|
||||
import yaml
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||
|
||||
|
||||
|
||||
@@ -76,11 +76,14 @@ Then:
|
||||
```bash
|
||||
openapi-first models openapi.yaml -o _gen/models.py
|
||||
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
|
||||
`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/`.
|
||||
|
||||
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
|
||||
a required `id: str`, keep it off the domain model and let the document get its
|
||||
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`.
|
||||
- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.
|
||||
- **`_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))
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
- `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`
|
||||
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
|
||||
later, add the global `security` block and an introspecting dependency; only
|
||||
handlers that must opt out get `security: []`.
|
||||
@@ -96,7 +109,7 @@ handlers that must opt out get `security: []`.
|
||||
```bash
|
||||
openapi-first models openapi.yaml -o _gen/models.py
|
||||
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
|
||||
|
||||
@@ -69,11 +69,26 @@ def create_item_repo(*, owner_repo=None) -> ItemRepository:
|
||||
return repo_factory(
|
||||
collection="items",
|
||||
document=ItemDocument,
|
||||
populate_rules=[PopulateRule(field="owner", collection="users")],
|
||||
populate_rules=[PopulateRule(field_name="owner", collection_name="users")],
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
PopulateRule(field="owner", collection="users",
|
||||
PopulateRule(field_name="owner", collection_name="users",
|
||||
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.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
from domain.item.models import Item
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
|
||||
class ItemDocument(Item, BaseDocument):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Job domain model — a pipeline work item consumed by the background worker.
|
||||
"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
Mongo persistence for jobs: document schema and repository factory.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
from domain.job.models import Job
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
|
||||
class JobDocument(Job, BaseDocument):
|
||||
|
||||
@@ -21,18 +21,17 @@ Example:
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class KVStore(ABC):
|
||||
"""Abstract key-value store port."""
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the value at ``key``, or ``None``."""
|
||||
|
||||
@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."""
|
||||
|
||||
@abstractmethod
|
||||
@@ -40,7 +39,7 @@ class KVStore(ABC):
|
||||
"""Delete ``key`` if present."""
|
||||
|
||||
@abstractmethod
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
async def scan(self, pattern: str) -> list[str]:
|
||||
"""Return keys matching a glob ``pattern``."""
|
||||
|
||||
async def clear(self, pattern: str = "*") -> None:
|
||||
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty store with no imports or connections."""
|
||||
self._data: Dict[str, str] = {}
|
||||
self._expiry: Dict[str, float] = {}
|
||||
self._data: dict[str, str] = {}
|
||||
self._expiry: dict[str, float] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
import time
|
||||
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
|
||||
return True
|
||||
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."""
|
||||
if self._expired(key):
|
||||
return None
|
||||
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."""
|
||||
import time
|
||||
|
||||
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
|
||||
self._data.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``."""
|
||||
import fnmatch
|
||||
|
||||
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
|
||||
|
||||
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 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."""
|
||||
await self._redis.set(key, value, ex=ttl)
|
||||
|
||||
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
|
||||
"""Delete ``key`` if present."""
|
||||
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 [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,
|
||||
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 pydantic import BaseModel
|
||||
from mongo_ops import BaseDocument
|
||||
from mongo_ops.models import PyObjectId
|
||||
|
||||
Model = TypeVar("Model", bound=BaseModel)
|
||||
Schema = TypeVar("Schema", bound=BaseDocument)
|
||||
|
||||
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
||||
"""
|
||||
|
||||
repo: mongo.BaseRepository
|
||||
model: Type[Model]
|
||||
schema: Type[Schema]
|
||||
model: type[Model]
|
||||
schema: type[Schema]
|
||||
|
||||
def __init__(self, *, repo=None):
|
||||
"""Initialize the adaptor with a backing repository.
|
||||
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
||||
|
||||
Returns:
|
||||
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(
|
||||
id,
|
||||
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
|
||||
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")
|
||||
|
||||
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
|
||||
key_attr (str): Attribute name on each event holding the stream
|
||||
correlation key (for example ``"job_id"``).
|
||||
"""
|
||||
self._events: List[T] = []
|
||||
self._events: list[T] = []
|
||||
self._key_attr = key_attr
|
||||
|
||||
async def publish(self, event: T) -> None:
|
||||
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
options supplied to the factory.
|
||||
"""
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
||||
from mongo_ops.cache import CacheBackend, CacheConfig
|
||||
from mongo_ops.models import BaseDocument
|
||||
from mongo_ops.populate import PopulationEngine, PopulateRule
|
||||
from mongo_ops.populate import PopulateRule, PopulationEngine
|
||||
|
||||
|
||||
def _repo_factory(
|
||||
collection: str,
|
||||
db_model: Type[BaseDocument],
|
||||
cache_backend: Optional[CacheBackend] = None,
|
||||
cache_config: Optional[CacheConfig] = None,
|
||||
engine: Optional[PopulationEngine] = None,
|
||||
populate_rules: Optional[List[PopulateRule]] = None,
|
||||
db_model: type[BaseDocument],
|
||||
cache_backend: CacheBackend | None = None,
|
||||
cache_config: CacheConfig | None = None,
|
||||
engine: PopulationEngine | None = None,
|
||||
populate_rules: list[PopulateRule] | None = None,
|
||||
) -> BaseRepository:
|
||||
"""Build the appropriate mongo-ops repository.
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ Notes:
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
@@ -55,7 +56,7 @@ class HttpCacheStore:
|
||||
|
||||
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``.
|
||||
|
||||
Args:
|
||||
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
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:
|
||||
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
|
||||
repeated initialization via a module-level flag.
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
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 infra.db.setup import setup_database, shutdown_database
|
||||
|
||||
_resources: Dict[str, Any] = {}
|
||||
_resources: dict[str, Any] = {}
|
||||
_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.
|
||||
|
||||
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
|
||||
re-exports every route handler from the subpackages into one flat namespace.
|
||||
"""
|
||||
# ruff: noqa: F403
|
||||
from interfaces.item.routes import *
|
||||
from interfaces.job.routes import *
|
||||
@@ -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
|
||||
are declared here; the lifespan initializes and shuts down app resources.
|
||||
"""
|
||||
# ruff: noqa: E402
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
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.
|
||||
"""
|
||||
from typing import Dict, Type
|
||||
|
||||
# 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.
|
||||
|
||||
Returns:
|
||||
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
|
||||
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.
|
||||
|
||||
Raises:
|
||||
|
||||
@@ -40,6 +40,7 @@ testpaths = ["tests"]
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
extend-exclude = ["_gen"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
@@ -1,9 +1,9 @@
|
||||
import importlib
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
|
||||
def test_openapi_app_builds():
|
||||
"""The spec-first app must build and plant each route handler."""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import importlib
|
||||
|
||||
import yaml
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user