feat(standards): codify codegen, ship generic Tier-B infra, add get_app_template + prompts; fix docs:// regressions
P0: fix doc:// -> docs:// across standards src/server/registry and regenerate stale mongo-ops bundle. P1: build recipe + spec-first routing now document the openapi-first codegen fast path (models/routes with --inject-request/--package). P4: CRUD template documents the model-vs-document id rule (BaseDocument owns id). P5: authless guidance (omit security keys). P6: template gains generic infra (http_cache, keyed SSE bus, KV port+in-memory+Redis), interfaces/openapi.py + .dockerignore + generic build test registered as components, and a get_app_template tool; genericity matrix marks LLM pool + human-in-loop as reference-app patterns. P7: intake/build/start_app/trial prompts. P8: template ships tests/integration/test_openapi_app_builds.py. Rebuilt standards + openapi-first bundles and re-collected.
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
## Summary
|
||||
|
||||
The ordered, deterministic checklist for generating a new application of the
|
||||
standard shape. Every step is fixed; only the resource model (step 2) and the
|
||||
pipeline family (step 7) need app-specific authoring.
|
||||
standard shape. Every step is fixed; only the resource model (step 2), the
|
||||
codegen pass (step 3), and the pipeline family (step 8) need app-specific work.
|
||||
|
||||
This page is served by the `standards` MCP as `doc://modules/build_recipe`. The
|
||||
This page is served by the `standards` MCP as `docs://modules/build_recipe`. The
|
||||
`start_app` prompt on that server collects the two axes and hands off here; pull
|
||||
exact files with `get_app_component`.
|
||||
|
||||
@@ -42,15 +42,15 @@ pip install --extra-index-url \
|
||||
# Build a new application from the Aetos Standards blueprint
|
||||
|
||||
You are coding a complete new backend application conforming to the Aetos
|
||||
standard app blueprint. Execute every step in order. Only steps 2 and 3 use the
|
||||
standard app blueprint. Execute every step in order. Only steps 2–4 use the
|
||||
app-specific details provided by the user; everything else is deterministic.
|
||||
|
||||
## Before writing any code
|
||||
|
||||
Connect to the `standards` MCP server and read:
|
||||
- `doc://nav` (the module list)
|
||||
- `doc://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)
|
||||
- `doc://modules/layering_rules` (the dependency rules)
|
||||
- `docs://nav` (the module list)
|
||||
- `docs://modules/genericity_matrix` (Tier A library / Tier B template / Tier C authored)
|
||||
- `docs://modules/layering_rules` (the dependency rules)
|
||||
|
||||
Then:
|
||||
- call `find_library` with `db`, `di`, `routing`, `ingestion`, `pipeline`, `docs`
|
||||
@@ -61,48 +61,64 @@ Then:
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Scaffold** — copy `standards/src/templates/app/` into `apps/<your-app>/`.
|
||||
Keep the tree: `domain/`, `infra/`, `interfaces/`, `pipelines/`, `tests/`,
|
||||
`scripts/`, plus `pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`,
|
||||
`main.py`, `worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.
|
||||
1. **Scaffold** — copy `standards/src/templates/app/` into `apps/<your-app>/`
|
||||
(or pull the same tree via `get_app_template`). Keep the tree: `domain/`,
|
||||
`infra/`, `interfaces/`, `pipelines/`, `tests/`, `scripts/`, plus
|
||||
`pyproject.toml`, `openapi.yaml`, `config.py`, `init.py`, `main.py`,
|
||||
`worker.py`, `Dockerfile`, `docker-compose.yaml`, `.env.example`.
|
||||
|
||||
2. **Author the spec (app-specific)** — write `openapi.yaml`: resources,
|
||||
schemas, `operationId`s, security, and extension metadata. This is the
|
||||
contract and drives all routing.
|
||||
contract and drives all routing. Every operation MUST have an `operationId`.
|
||||
|
||||
3. **Write each CRUD slice (app-specific)** — per resource create the four-file
|
||||
slice: `models.py`, `ports/db.py`, `ports/service.py`, and optional
|
||||
`converters.py`. Follow the CRUD template.
|
||||
3. **Generate the scaffold (codegen)** — from the spec, generate the wire
|
||||
models and the exact handler signatures:
|
||||
```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
|
||||
```
|
||||
`_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
|
||||
(never hand-edit it) — the canonical handlers live in `interfaces/`.
|
||||
|
||||
4. **Wire the composition root** — in `init.py`, build leaf repositories, then
|
||||
4. **Write each CRUD slice (app-specific)** — per resource create the four-file
|
||||
slice: `models.py` (domain model, **no `id`**), `ports/db.py`
|
||||
(`class XDocument(BaseDocument, X)` — id comes from `BaseDocument`),
|
||||
`ports/service.py`, and optional `converters.py`. Follow the CRUD template.
|
||||
|
||||
5. **Wire the composition root** — in `init.py`, build leaf repositories, then
|
||||
dependent repositories, then services; add infra adapters (db, cache, stores,
|
||||
progress bus, fetchers). Expose `get_svc` overloads.
|
||||
|
||||
5. **Add routes** — create `interfaces/<resource>/routes.py` thin handlers bound
|
||||
to `operationId`s and re-export them in `interfaces/openapi.py`. Add the cache
|
||||
6. **Add routes** — create `interfaces/<resource>/routes.py` thin handlers whose
|
||||
names match the `operationId`s (use the signatures from step 3 as your
|
||||
checklist) and re-export them in `interfaces/openapi.py`. Add the cache
|
||||
decorator to read endpoints.
|
||||
|
||||
6. **Bootstrap app and worker** — confirm `main.py` runs the OpenAPI-first app
|
||||
7. **Bootstrap app and worker** — confirm `main.py` runs the OpenAPI-first app
|
||||
with its lifespan. If the app has long-running jobs, adapt `worker.py` to poll
|
||||
the job collection, run the pipeline, and publish progress.
|
||||
|
||||
7. **Add pipeline families (only if data-driven)** — copy `pipelines/example/`
|
||||
8. **Add pipeline families (only if data-driven)** — copy `pipelines/example/`
|
||||
to `pipelines/<family>/` with `abc.py`, `impl.py`, `pipeline.yaml`,
|
||||
`resources/`, and reuse `pipelines/shared/` (loader + registry). Register the
|
||||
root. Add fixtures and a parity test.
|
||||
|
||||
8. **Add infrastructure only where needed** — Tier A capability: use the
|
||||
9. **Add infrastructure only where needed** — Tier A capability: use the
|
||||
library. Tier B capability: copy the template. Never hand-write a Tier A
|
||||
library.
|
||||
|
||||
9. **Tests** — integration tests with fakes at the HTTP edge; unit tests for
|
||||
domain and pipeline logic; a config-consistency test for rule-driven
|
||||
pipelines.
|
||||
10. **Tests** — integration tests with fakes at the HTTP edge; unit tests for
|
||||
domain and pipeline logic; a config-consistency test for rule-driven
|
||||
pipelines. Include `tests/integration/test_openapi_app_builds.py` (imports
|
||||
the app, asserts `import main` binds every operation).
|
||||
|
||||
10. **Scripts and seeds** — operator scripts under `scripts/` using
|
||||
11. **Scripts and seeds** — operator scripts under `scripts/` using
|
||||
`init()` / `get_svc()` / `shutdown()`; run as `python -m scripts.<name>`.
|
||||
|
||||
11. **Docs** — author GSDFC docstrings, generate `.pyi` stubs, mark the package
|
||||
12. **Docs** — author GSDFC docstrings, generate `.pyi` stubs, mark the package
|
||||
`py.typed`, and build the docs.
|
||||
|
||||
**Definition of done:**
|
||||
@@ -123,15 +139,39 @@ The steps map onto the layers as follows:
|
||||
|---------------------|-----------------------------------|---------------------------------------------|
|
||||
| 1 · Scaffold | whole `templates/app/` | — |
|
||||
| 2 · Spec | `openapi.yaml` | — |
|
||||
| 3 · CRUD slices | `domain/<resource>/` | spec, CRUD template |
|
||||
| 4 · Wiring | `init.py` | every slice |
|
||||
| 5 · Routes | `interfaces/<resource>/` | services, openapi route registry |
|
||||
| 6 · Bootstrap | `main.py`, `worker.py` | composition root |
|
||||
| 7 · Pipelines | `pipelines/<family>/`, `shared/` | library pipeline engine |
|
||||
| 8 · Infra | `infra/` | domain ports |
|
||||
| 9 · Tests | `tests/` | app + fixtures |
|
||||
| 10 · Scripts | `scripts/` | composition root |
|
||||
| 11 · Docs | docstrings + docs build | code, Doc Forge |
|
||||
| 3 · Codegen | `_gen/` (generated) | spec, openapi-first |
|
||||
| 4 · CRUD slices | `domain/<resource>/` | spec, CRUD template |
|
||||
| 5 · Wiring | `init.py` | every slice |
|
||||
| 6 · Routes | `interfaces/<resource>/` | services, openapi route registry |
|
||||
| 7 · Bootstrap | `main.py`, `worker.py` | composition root |
|
||||
| 8 · Pipelines | `pipelines/<family>/`, `shared/` | library pipeline engine |
|
||||
| 9 · Infra | `infra/` | domain ports |
|
||||
| 10 · Tests | `tests/` | app + fixtures |
|
||||
| 11 · Scripts | `scripts/` | composition root |
|
||||
| 12 · Docs | docstrings + docs build | code, Doc Forge |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 The codegen fast path, in detail
|
||||
|
||||
`openapi-first` is spec → code only (it never generates a spec). Use it to erase
|
||||
the two mechanical parts of the build:
|
||||
|
||||
| Generated artifact | Replaces | Notes |
|
||||
|--------------------|----------|-------|
|
||||
| `_gen/models.py` | hand-writing Pydantic wire models | one class per `components.schemas` entry |
|
||||
| `_gen/routes/<resource>/routes.py` | hand-typing handler signatures | names == `operationId`s; `request: Request` injected |
|
||||
|
||||
Rules of the road:
|
||||
|
||||
- **Wire models are not domain models.** `_gen/models.py` is the HTTP contract;
|
||||
`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`.
|
||||
- **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
|
||||
exactly what moved.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,8 +70,34 @@ async def create_item(request: Request, body: dict):
|
||||
- Security is declared once in the spec and enforced by the framework; handlers
|
||||
contain no auth code.
|
||||
|
||||
## Authless apps
|
||||
|
||||
Auth is **deferred** in the standard: the same spec-first app runs with or
|
||||
without it, because security lives entirely in the spec, never in handlers.
|
||||
For an app that needs no auth yet, do **not** invent per-handler auth checks —
|
||||
just omit the security keys:
|
||||
|
||||
- Remove the top-level `security:` block.
|
||||
- Remove `components.securitySchemes`.
|
||||
- Do **not** add `security: []` to operations. An explicit empty array is a
|
||||
per-operation *override* that means "public", and it only matters when a
|
||||
global `security` block exists. Leaving every operation without a `security`
|
||||
key is the cleanest authless spec.
|
||||
|
||||
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: []`.
|
||||
|
||||
## Codegen
|
||||
|
||||
The `openapi_first` library can scaffold models and route stubs from the spec,
|
||||
and ships app templates (CRUD app, model app, service). Prefer codegen over
|
||||
hand-writing boilerplate.
|
||||
`openapi_first` scaffolds models and route stubs from the spec (see the
|
||||
[Build Recipe](01_build_recipe.md), step 3):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Prefer codegen over hand-writing boilerplate. The result is the wire contract
|
||||
and a set of `operationId`-named signatures — not the canonical handlers.
|
||||
|
||||
@@ -18,9 +18,36 @@ domain/<resource>/
|
||||
converters.py # (optional) transforms
|
||||
```
|
||||
|
||||
## The id rule (model vs. document)
|
||||
|
||||
Two different objects, two different jobs:
|
||||
|
||||
| Object | Lives in | Has `id`? | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Domain model** | `domain/<resource>/models.py` | **No** | business shape, framework-free |
|
||||
| **Document** | `domain/<resource>/ports/db.py` | **Yes** (from `BaseDocument`) | Mongo persistence shape |
|
||||
|
||||
`BaseDocument` (mongo-ops) supplies `id: Optional[PyObjectId]` (aliased to
|
||||
`_id`) plus `created_at` / `updated_at`. The domain model must **not** declare
|
||||
`id`; Mongo owns it.
|
||||
|
||||
> **Trap — generated wire models.** `openapi-first models` produces the HTTP
|
||||
> *contract* (see the [Build Recipe](01_build_recipe.md), step 3). Response
|
||||
> schemas frequently declare a required `id: str`, so a generated class is
|
||||
> **not** a domain model. If you write
|
||||
> `class ItemDocument(Item, BaseDocument)` and `Item` re-exports a required
|
||||
> `id: str`, that field shadows `BaseDocument.id` and no Mongo `_id` can ever be
|
||||
> set. Fix it one of three ways:
|
||||
>
|
||||
> 1. **Preferred:** keep the domain model id-free (hand-author it from the CRUD
|
||||
> template), and keep the required `id` only on the wire model.
|
||||
> 2. Order the bases so the document wins:
|
||||
> `class ItemDocument(BaseDocument, Item)`.
|
||||
> 3. Keep a separate document type and translate with `ports/converters.py`.
|
||||
|
||||
## models.py
|
||||
|
||||
A framework-free Pydantic model describing the business shape.
|
||||
A framework-free Pydantic model describing the business shape (no `id`).
|
||||
|
||||
```python
|
||||
class Item(BaseModel):
|
||||
@@ -34,7 +61,8 @@ The persistence model extends the business model and adds database concerns.
|
||||
The repository factory selects the right repository flavour.
|
||||
|
||||
```python
|
||||
class ItemDocument(Item, BaseDocument): # BaseDocument adds id/created_at/...
|
||||
# Item is id-free; BaseDocument adds id/created_at/updated_at.
|
||||
class ItemDocument(Item, BaseDocument):
|
||||
pass
|
||||
|
||||
def create_item_repo(*, owner_repo=None) -> ItemRepository:
|
||||
|
||||
@@ -31,25 +31,37 @@ for its API. Writing a private copy of any of these is a defect.
|
||||
These are reusable internal components shipped **inside** the template — they are
|
||||
not independent libraries. Copy them as-is, then rename/populate only what is
|
||||
app-specific. They give every app the same wiring shape as the reference app.
|
||||
Fetch them by kind with `get_app_component`, or the whole tree with
|
||||
`get_app_template`.
|
||||
|
||||
| Component | Template location | Action |
|
||||
|---|---|---|
|
||||
| Layering & dependency rule | the package layout + import rules | keep |
|
||||
| Composition root shape | `init.py` (`get_svc`/`get_db`, idempotency) | keep |
|
||||
| Spec-first web edge | `interfaces/openapi.py`, thin handlers bound to `operationId` | keep |
|
||||
| CRUD slice pattern | `domain/<resource>/{models.py, ports/db.py, ports/service.py, converters.py}` | rename + fill |
|
||||
| Generic CRUD service | `infra/adapters/service.py` (`CrudsServiceAdaptor`) | keep |
|
||||
| Repository factory | `infra/db/repos/mongo.py` (`_repo_factory`) | keep |
|
||||
| Worker poll + status loop | `worker.py` | keep |
|
||||
| Progress bus + streaming | bus port + in-memory adapter + SSE endpoint | keep |
|
||||
| Human-in-the-loop | durable pause + question document + resolve endpoint + resume map | keep |
|
||||
| HTTP response cache | `cache_response` decorator + generation invalidation | keep |
|
||||
| Key-value store | KV store port + Redis adapter + in-memory fallback | keep |
|
||||
| LLM client pool | provider-select + retry pool scaffold | keep |
|
||||
| Pipeline scaffold | `pipelines/shared/{ports,loader,registry}.py` + `pipelines/<family>/` | rename + fill |
|
||||
| Operator scripts | `scripts/` (`init()` / `get_svc()` / `shutdown()`) | rename |
|
||||
| Testing harness | integration fakes + fixture-parity pattern | keep |
|
||||
| Project scaffolding | `pyproject.toml`, `Dockerfile`, `docker-compose.yaml`, `.env.example`, `openapi.yaml` | rename + fill |
|
||||
| Component | Template location | Action | Shipped |
|
||||
|---|---|---|---|
|
||||
| Layering & dependency rule | the package layout + import rules | keep | ✅ |
|
||||
| Composition root shape | `init.py` (`get_svc`/`get_db`, idempotency) | keep | ✅ |
|
||||
| Spec-first web edge | `interfaces/openapi.py`, thin handlers bound to `operationId` | keep | ✅ |
|
||||
| CRUD slice pattern | `domain/<resource>/{models.py, ports/db.py, ports/service.py, converters.py}` | rename + fill | ✅ |
|
||||
| Generic CRUD service | `infra/adapters/service.py` (`CrudsServiceAdaptor`) | keep | ✅ |
|
||||
| Repository factory | `infra/db/repos/mongo.py` (`_repo_factory`) | keep | ✅ |
|
||||
| Worker poll + status loop | `worker.py` | keep | ✅ |
|
||||
| Progress bus | `infra/adapters/sse.py` (keyed in-memory bus) + an app SSE endpoint | keep | ✅ |
|
||||
| HTTP response cache | `infra/http_cache.py` (`cache_response` + generation invalidation) | keep | ✅ |
|
||||
| Key-value store | `infra/adapters/kv.py` (port + in-memory + Redis adaptor) | keep | ✅ |
|
||||
| Pipeline scaffold | `pipelines/shared/{ports,loader,registry}.py` + `pipelines/<family>/` | rename + fill | ✅ |
|
||||
| Operator scripts | `scripts/` (`init()` / `get_svc()` / `shutdown()`) | rename | ✅ |
|
||||
| Testing harness | integration fakes + fixture-parity pattern | keep | ✅ |
|
||||
| Project scaffolding | `pyproject.toml`, `Dockerfile`, `docker-compose.yaml`, `.env.example`, `openapi.yaml` | rename + fill | ✅ |
|
||||
|
||||
## Tier B (patterns) — port from the reference app
|
||||
|
||||
A few Tier B capabilities are real and reusable, but are **shaped by the app or
|
||||
the provider**, so the template does not ship them verbatim — copying them
|
||||
blindly would carry app-specific names. Treat them as patterns: lift the shape
|
||||
from the reference app and rename per app.
|
||||
|
||||
| Component | Reference location | Action | Shipped |
|
||||
|---|---|---|---|
|
||||
| Human-in-the-loop | durable pause + question document + resolve endpoint + resume map | port + reshape | ✗ |
|
||||
| LLM client pool | provider-select + retry/rotation pool scaffold | port + reshape | ✗ |
|
||||
|
||||
## Tier C — App-specific (must be authored)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ is a defect.
|
||||
|
||||
Each library also exposes its own documentation as an MCP server. An AI agent
|
||||
can read a library's full API by connecting to its MCP and reading
|
||||
`doc://index`, `doc://nav`, and `doc://modules/{module}`.
|
||||
`docs://index`, `docs://nav`, and `docs://modules/{module}`.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,8 +48,8 @@ endpoint. Query it before writing any infrastructure.
|
||||
## Notes
|
||||
|
||||
- **Hexa** is a full library with its own MCP server on `:8008`: read
|
||||
`doc://index`, `doc://nav`, and `doc://modules/{module}` (e.g.
|
||||
`doc://modules/hexa.container`) to discover the DI surface — `build(cls, *,
|
||||
`docs://index`, `docs://nav`, and `docs://modules/{module}` (e.g.
|
||||
`docs://modules/hexa.container`) to discover the DI surface — `build(cls, *,
|
||||
instances, config)` and the `PortNode` model.
|
||||
- **Auth Server** is deferred: it has no `pyproject.toml`/importable package and
|
||||
no MCP bundle. Do not reach for it yet; `py-jwt` covers token primitives.
|
||||
|
||||
147
standards/src/templates/app/infra/adapters/kv.py
Normal file
147
standards/src/templates/app/infra/adapters/kv.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""# Summary
|
||||
|
||||
Key-value store port with in-memory and Redis adaptors.
|
||||
|
||||
---
|
||||
|
||||
Reserve infrastructure for caching small values by key (sessions, locks,
|
||||
result blobs, rate counters). The in-memory adaptor needs no services and is a
|
||||
safe default; the Redis adaptor is opt-in and imports ``redis`` lazily so it
|
||||
never becomes a hard dependency.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from infra.adapters.kv import InMemoryKV
|
||||
|
||||
kv = InMemoryKV()
|
||||
await kv.set("greeting", "hello", ttl=30)
|
||||
assert await kv.get("greeting") == "hello"
|
||||
```
|
||||
"""
|
||||
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]:
|
||||
"""Return the value at ``key``, or ``None``."""
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete ``key`` if present."""
|
||||
|
||||
@abstractmethod
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
"""Return keys matching a glob ``pattern``."""
|
||||
|
||||
async def clear(self, pattern: str = "*") -> None:
|
||||
"""Delete all keys matching ``pattern``.
|
||||
|
||||
Args:
|
||||
pattern (str): Glob-style key pattern.
|
||||
"""
|
||||
for key in await self.scan(pattern):
|
||||
await self.delete(key)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release any underlying resources. Default is a no-op."""
|
||||
|
||||
|
||||
class InMemoryKV(KVStore):
|
||||
"""Process-local key-value store used as a dependency-free fallback.
|
||||
|
||||
Attributes:
|
||||
_data (Dict[str, str]): In-memory value map.
|
||||
_expiry (Dict[str, float]): Absolute expiry timestamps by key; ``0.0``
|
||||
means "no expiry".
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty store with no imports or connections."""
|
||||
self._data: Dict[str, str] = {}
|
||||
self._expiry: Dict[str, float] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
import time
|
||||
|
||||
deadline = self._expiry.get(key, 0.0)
|
||||
if deadline and deadline < time.time():
|
||||
self._data.pop(key, None)
|
||||
self._expiry.pop(key, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""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:
|
||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||
import time
|
||||
|
||||
self._data[key] = value
|
||||
self._expiry[key] = (time.time() + ttl) if ttl else 0.0
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete ``key`` if present."""
|
||||
self._data.pop(key, None)
|
||||
self._expiry.pop(key, None)
|
||||
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
"""Return live keys matching a glob ``pattern``."""
|
||||
import fnmatch
|
||||
|
||||
return [k for k in list(self._data) if fnmatch.fnmatch(k, pattern) and not self._expired(k)]
|
||||
|
||||
|
||||
class RedisKV(KVStore):
|
||||
"""Redis-backed key-value store.
|
||||
|
||||
Attributes:
|
||||
_redis: Asynchronous Redis client (imported lazily).
|
||||
"""
|
||||
|
||||
def __init__(self, url: str = "redis://localhost:6379/0", decode_responses: bool = True):
|
||||
"""Initialize the store with a Redis connection.
|
||||
|
||||
Args:
|
||||
url (str): Redis connection URL.
|
||||
decode_responses (bool): Whether Redis returns decoded strings.
|
||||
|
||||
Raises:
|
||||
ImportError: When the optional ``redis`` package is not installed.
|
||||
"""
|
||||
from redis.asyncio import Redis
|
||||
|
||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""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:
|
||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||
await self._redis.set(key, value, ex=ttl)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete ``key`` if present."""
|
||||
await self._redis.delete(key)
|
||||
|
||||
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)]
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying Redis connection."""
|
||||
await self._redis.aclose()
|
||||
54
standards/src/templates/app/infra/adapters/sse.py
Normal file
54
standards/src/templates/app/infra/adapters/sse.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""# Summary
|
||||
|
||||
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
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class SSEInMemoryBus(Generic[T]):
|
||||
"""Buffer SSE progress events in memory, keyed by an event attribute.
|
||||
|
||||
Attributes:
|
||||
_events (List[T]): Events published since the last clear.
|
||||
_key_attr (str): Event attribute used to correlate a stream.
|
||||
"""
|
||||
|
||||
def __init__(self, key_attr: str = "id"):
|
||||
"""Initialize an empty event buffer.
|
||||
|
||||
Args:
|
||||
key_attr (str): Attribute name on each event holding the stream
|
||||
correlation key (for example ``"job_id"``).
|
||||
"""
|
||||
self._events: List[T] = []
|
||||
self._key_attr = key_attr
|
||||
|
||||
async def publish(self, event: T) -> None:
|
||||
"""Append an event to the buffer.
|
||||
|
||||
Args:
|
||||
event (T): Event to append.
|
||||
"""
|
||||
self._events.append(event)
|
||||
|
||||
def get_events(self, key: str) -> List[T]:
|
||||
"""Return buffered events for a correlation key.
|
||||
|
||||
Args:
|
||||
key (str): Value matched against the configured ``key_attr``.
|
||||
|
||||
Returns:
|
||||
List[T]: Events whose ``key_attr`` equals ``key``.
|
||||
"""
|
||||
return [e for e in self._events if getattr(e, self._key_attr, None) == key]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all buffered events."""
|
||||
self._events.clear()
|
||||
229
standards/src/templates/app/infra/http_cache.py
Normal file
229
standards/src/templates/app/infra/http_cache.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""# Summary
|
||||
|
||||
Domain-agnostic HTTP response cache.
|
||||
|
||||
---
|
||||
|
||||
A thin Redis-backed response cache that wraps route handlers as a decorator.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from infra.http_cache import cache_response
|
||||
|
||||
@cache_response(ttl=60)
|
||||
async def list_items(request, skip: int = 0, limit: int = 100):
|
||||
...
|
||||
```
|
||||
|
||||
Notes:
|
||||
The cache operates directly on the endpoint and is independent of any
|
||||
repository-level cache. Cached responses expire via TTL. When no
|
||||
``http_cache_store`` is wired on ``request.app.state.db_resources`` the
|
||||
decorator is a no-op pass-through, so applying/removing it is safe.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
CACHE_PREFIX = "apicache:"
|
||||
NS_PREFIX = CACHE_PREFIX + "ns:"
|
||||
DEFAULT_TTL = 60
|
||||
|
||||
|
||||
class HttpCacheStore:
|
||||
"""Store string responses in Redis with TTL under the ``apicache:`` key space.
|
||||
|
||||
Attributes:
|
||||
_redis (Redis): Asynchronous Redis client bound to the configured URL.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str = "redis://localhost:6379/0", decode_responses: bool = True):
|
||||
"""Initialize the store with a Redis connection.
|
||||
|
||||
Args:
|
||||
url (str): Redis connection URL.
|
||||
decode_responses (bool): Whether Redis returns decoded strings.
|
||||
|
||||
Raises:
|
||||
ImportError: When the optional ``redis`` package is not installed.
|
||||
"""
|
||||
from redis.asyncio import Redis
|
||||
|
||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""Read the value stored at ``key``.
|
||||
|
||||
Args:
|
||||
key (str): Cache key to read.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Cached value, or ``None`` when absent.
|
||||
"""
|
||||
return await self._redis.get(CACHE_PREFIX + key)
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int) -> None:
|
||||
"""Store ``value`` at ``key`` with a TTL.
|
||||
|
||||
Args:
|
||||
key (str): Cache key to write.
|
||||
value (str): Serialized value to persist.
|
||||
ttl (int): Lifetime in seconds.
|
||||
"""
|
||||
await self._redis.set(CACHE_PREFIX + key, value, ex=ttl)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete the value stored at ``key``.
|
||||
|
||||
Args:
|
||||
key (str): Cache key to remove.
|
||||
"""
|
||||
await self._redis.delete(CACHE_PREFIX + key)
|
||||
|
||||
async def generation(self, resource: str) -> str:
|
||||
"""Return the current generation counter for a resource namespace.
|
||||
|
||||
Args:
|
||||
resource (str): Resource namespace to read.
|
||||
|
||||
Returns:
|
||||
str: Generation counter, or ``"0"`` when unset.
|
||||
"""
|
||||
value = await self._redis.get(NS_PREFIX + resource)
|
||||
return value if value is not None else "0"
|
||||
|
||||
async def invalidate(self, *resources: str) -> None:
|
||||
"""Invalidate all cached responses for the given resource namespaces.
|
||||
|
||||
Args:
|
||||
*resources (str): Resource namespaces to invalidate.
|
||||
|
||||
Notes:
|
||||
Bumps each namespace's generation counter so existing entries become
|
||||
unreachable immediately; Redis reclaims them at TTL expiry.
|
||||
"""
|
||||
if not resources:
|
||||
return
|
||||
pipe = self._redis.pipeline(transaction=False)
|
||||
for resource in resources:
|
||||
pipe.incr(NS_PREFIX + resource)
|
||||
await pipe.execute()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying Redis connection."""
|
||||
await self._redis.aclose()
|
||||
|
||||
|
||||
def _user_scope(request: Request) -> str:
|
||||
"""Build a stable per-user cache scope.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
str: Scoped identifier, or ``""`` for auth-free endpoints.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if isinstance(user, dict):
|
||||
for field in ("username", "id", "email"):
|
||||
if user.get(field):
|
||||
return str(user[field])
|
||||
token = getattr(request.state, "token", None)
|
||||
if token:
|
||||
return hashlib.sha256(str(token).encode()).hexdigest()[:16]
|
||||
return ""
|
||||
|
||||
|
||||
def _resource_name(request: Request) -> str:
|
||||
"""Return the first path segment identifying the resource.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
str: First path segment (for example ``"items"``), or ``"root"`` when
|
||||
the path is empty.
|
||||
"""
|
||||
return request.url.path.strip("/").split("/", 1)[0] or "root"
|
||||
|
||||
|
||||
def _cache_key(request: Request, generation: str = "0") -> str:
|
||||
"""Compute a stable cache key for a request.
|
||||
|
||||
Args:
|
||||
request (Request): Incoming HTTP request.
|
||||
generation (str): Resource generation counter guarding stale entries.
|
||||
|
||||
Returns:
|
||||
str: SHA-256 digest of the request method, path, query and path
|
||||
parameters, user scope, and generation.
|
||||
"""
|
||||
parts = [
|
||||
request.method,
|
||||
request.url.path,
|
||||
"&".join(f"{k}={v}" for k, v in sorted(request.query_params.items())),
|
||||
"&".join(f"{k}={v}" for k, v in sorted(request.path_params.items())),
|
||||
_user_scope(request),
|
||||
generation,
|
||||
]
|
||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def cache_response(ttl: Optional[int] = None) -> Callable:
|
||||
"""Decorate a GET-style route handler to cache its JSON response.
|
||||
|
||||
Args:
|
||||
ttl (Optional[int]): Cache lifetime in seconds; defaults to
|
||||
``DEFAULT_TTL``.
|
||||
|
||||
Returns:
|
||||
Callable: Decorator that wraps the route handler.
|
||||
|
||||
Notes:
|
||||
The wrapped handler must accept a ``request: Request`` parameter (as
|
||||
every openapi_first handler does). Responses that are already a
|
||||
``fastapi.Response`` (for example 204) are returned untouched and
|
||||
never cached. When no ``http_cache_store`` is wired on
|
||||
``request.app.state.db_resources`` the decorator passes through to the
|
||||
handler without caching.
|
||||
"""
|
||||
ttl = ttl if ttl is not None else DEFAULT_TTL
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any):
|
||||
request = kwargs.get("request")
|
||||
if request is None:
|
||||
request = next((a for a in args if isinstance(a, Request)), None)
|
||||
if request is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
db_resources = getattr(request.app.state, "db_resources", None) or {}
|
||||
store: Optional[HttpCacheStore] = db_resources.get("http_cache_store")
|
||||
if store is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
key = _cache_key(request, await store.generation(_resource_name(request)))
|
||||
cached = await store.get(key)
|
||||
if cached is not None:
|
||||
return Response(content=cached, media_type="application/json")
|
||||
|
||||
result = await func(*args, **kwargs)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
|
||||
payload = json.dumps(
|
||||
jsonable_encoder(result),
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await store.set(key, payload, ttl)
|
||||
return Response(content=payload, media_type="application/json")
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"uvicorn[standard]",
|
||||
"pydantic>=2",
|
||||
"motor",
|
||||
"redis",
|
||||
"python-dotenv",
|
||||
"pyyaml",
|
||||
"openapi-first",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import importlib
|
||||
|
||||
import yaml
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||
|
||||
|
||||
def _operation_ids(spec: dict) -> list[str]:
|
||||
"""Collect every declared operationId from an OpenAPI spec.
|
||||
|
||||
Args:
|
||||
spec (dict): Parsed OpenAPI document.
|
||||
|
||||
Returns:
|
||||
list[str]: Operation ids in declaration order.
|
||||
"""
|
||||
ids: list[str] = []
|
||||
for path_item in (spec.get("paths") or {}).values():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() in _HTTP_METHODS and isinstance(operation, dict):
|
||||
if operation.get("operationId"):
|
||||
ids.append(operation["operationId"])
|
||||
return ids
|
||||
|
||||
|
||||
def test_openapi_app_builds():
|
||||
"""Every operationId in the spec must resolve to a handler, and the app must build."""
|
||||
spec = yaml.safe_load(open("openapi.yaml", encoding="utf-8"))
|
||||
module = importlib.import_module(routes_module.__name__)
|
||||
|
||||
missing = [oid for oid in _operation_ids(spec) if not hasattr(module, oid)]
|
||||
assert not missing, f"handlers missing for operationIds: {missing}"
|
||||
|
||||
app = OpenAPIFirstApp(
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=module,
|
||||
title="App API",
|
||||
version="0.1.0",
|
||||
)
|
||||
assert app is not None
|
||||
@@ -7,7 +7,7 @@ copying Tier B template components.
|
||||
|
||||
> **Doc model:** this page is the human entry point. The deterministic,
|
||||
> machine-facing checklist lives in the reference page
|
||||
> [Build Recipe](../lib/01_build_recipe/), served as `doc://modules/build_recipe`.
|
||||
> [Build Recipe](../lib/01_build_recipe/), served as `docs://modules/build_recipe`.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,9 @@ Gather these before starting — the `start_app` MCP prompt asks for them:
|
||||
`openapi.yaml` if you already have it.
|
||||
3. **Axis 2 — pipelines.** Only if an input needs a multi-step transformation:
|
||||
the families and their input formats (or "none").
|
||||
4. **Auth needed now?** Default no; auth is deferred (`py-jwt` only).
|
||||
4. **Auth needed now?** Default **no**. Auth is deferred (`py-jwt` primitives
|
||||
only), and an authless app is just a spec with no `security` keys — see
|
||||
[Spec-First Routing → Authless apps](../lib/04_spec_first_routing/#authless-apps).
|
||||
5. **Integrations.** Mongo, ingestion formats, email, LLM provider.
|
||||
|
||||
If you can state axes 1 and 2, the rest is mechanical.
|
||||
@@ -31,13 +33,18 @@ If you can state axes 1 and 2, the rest is mechanical.
|
||||
|
||||
From an MCP client connected to the `standards` server:
|
||||
|
||||
1. Invoke the **`start_app`** prompt with no arguments to get the intake
|
||||
questions.
|
||||
2. Answer them, then invoke
|
||||
`start_app(app_name=..., resources=..., pipelines=..., auth=...)`.
|
||||
3. The prompt hands off to `doc://modules/build_recipe`; the agent pulls exact
|
||||
files with `get_app_component(kind, resource=..., family=...)` and authors
|
||||
only the app-specific parts.
|
||||
1. Invoke the **`intake`** prompt (or `start_app` with no arguments) to get the
|
||||
intake questions.
|
||||
2. Answer them, then invoke **`build`**
|
||||
(`build(app_name=..., resources=..., pipelines=..., auth=...)`), or use
|
||||
**`start_app`** to intake-and-build in one call.
|
||||
3. The prompt hands off to `docs://modules/build_recipe`; the agent scaffolds
|
||||
with `get_app_template()` and pulls exact files with
|
||||
`get_app_component(kind, resource=..., family=...)`, authoring only the
|
||||
app-specific parts.
|
||||
4. To validate the standard without touching a real app, invoke **`trial`**
|
||||
(`trial(app_name=..., build_path=...)`) to build into a scratch directory,
|
||||
prove it builds, and report findings.
|
||||
|
||||
Manual alternative — copy the skeleton and follow the reference recipe:
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ Registry](../lib/13_library_registry/).
|
||||
|
||||
## 🔗 Related Resources
|
||||
|
||||
- **MCP bundle** — `standards` server on `:8009`: read `doc://index`,
|
||||
`doc://nav`, `doc://modules/{module}`; call `find_library(capability)`
|
||||
- **MCP bundle** — `standards` server on `:8009`: read `docs://index`,
|
||||
`docs://nav`, `docs://modules/{module}`; call `find_library(capability)`
|
||||
- **Library registry** — the catalog of internal libraries behind every app
|
||||
(`libraries.json`, served as `registry://libraries`)
|
||||
- **Template** — the runnable skeleton at `standards/src/templates/app/`
|
||||
|
||||
Reference in New Issue
Block a user