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:
2026-10-13 03:26:51 +05:30
parent ef951a1913
commit fced40af59
109 changed files with 4082 additions and 5885 deletions

View File

@@ -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.
---

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)

View File

@@ -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.

View 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()

View 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()

View 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

View File

@@ -11,6 +11,7 @@ dependencies = [
"uvicorn[standard]",
"pydantic>=2",
"motor",
"redis",
"python-dotenv",
"pyyaml",
"openapi-first",

View File

@@ -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

View File

@@ -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:

View File

@@ -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/`