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:
@@ -36,6 +36,12 @@
|
||||
"placeholder": "resource",
|
||||
"description": "Thin HTTP handlers bound to operationIds."
|
||||
},
|
||||
{
|
||||
"kind": "openapi-routes",
|
||||
"path": "interfaces/openapi.py",
|
||||
"placeholder": "",
|
||||
"description": "Route registry: flat re-export of every handler namespace."
|
||||
},
|
||||
{
|
||||
"kind": "test-model",
|
||||
"path": "tests/unit/test_item_models.py",
|
||||
@@ -48,6 +54,18 @@
|
||||
"placeholder": "resource",
|
||||
"description": "Integration test at the HTTP edge."
|
||||
},
|
||||
{
|
||||
"kind": "test-openapi-build",
|
||||
"path": "tests/integration/test_openapi_app_builds.py",
|
||||
"placeholder": "",
|
||||
"description": "Generic test: every spec operationId resolves and the app builds."
|
||||
},
|
||||
{
|
||||
"kind": "test-pipeline",
|
||||
"path": "tests/unit/test_item_sync.py",
|
||||
"placeholder": "",
|
||||
"description": "Unit test for the smallest pipeline form."
|
||||
},
|
||||
{
|
||||
"kind": "pipeline-abc",
|
||||
"path": "pipelines/example/abc.py",
|
||||
@@ -168,6 +186,12 @@
|
||||
"placeholder": "",
|
||||
"description": "API + worker image with private-index wiring."
|
||||
},
|
||||
{
|
||||
"kind": "dockerignore",
|
||||
"path": ".dockerignore",
|
||||
"placeholder": "",
|
||||
"description": "Docker build-context exclusions."
|
||||
},
|
||||
{
|
||||
"kind": "compose",
|
||||
"path": "docker-compose.yaml",
|
||||
@@ -198,6 +222,30 @@
|
||||
"placeholder": "",
|
||||
"description": "Generic CRUD service adaptor."
|
||||
},
|
||||
{
|
||||
"kind": "http-cache",
|
||||
"path": "infra/http_cache.py",
|
||||
"placeholder": "",
|
||||
"description": "Response cache decorator + Redis store (generation invalidation)."
|
||||
},
|
||||
{
|
||||
"kind": "progress-bus",
|
||||
"path": "infra/adapters/sse.py",
|
||||
"placeholder": "",
|
||||
"description": "In-memory SSE progress bus (keyed by an event attribute)."
|
||||
},
|
||||
{
|
||||
"kind": "kv-store",
|
||||
"path": "infra/adapters/kv.py",
|
||||
"placeholder": "",
|
||||
"description": "KV port + in-memory fallback + optional Redis adaptor."
|
||||
},
|
||||
{
|
||||
"kind": "package-init",
|
||||
"path": "domain/__init__.py",
|
||||
"placeholder": "",
|
||||
"description": "Empty package marker; add an __init__.py to every package dir."
|
||||
},
|
||||
{
|
||||
"kind": "script-seed",
|
||||
"path": "scripts/seed.py",
|
||||
|
||||
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## models.py\n\nA framework-free Pydantic model describing the business shape.\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\nclass ItemDocument(Item, BaseDocument): # BaseDocument adds id/created_at/...\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=\"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",
|
||||
"objects": {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "genericity_matrix",
|
||||
"content": {
|
||||
"path": "genericity_matrix",
|
||||
"docstring": "# Genericity Matrix\n\n## Summary\n\nThis page states precisely what is **library-backed**, what is\n**template-provided**, and what is **app-specific and must be authored**. Use it\nto scope a new build and to know, for any given capability, whether to call a\nlibrary, copy template code, or write it yourself.\n\n---\n\n## Tier A — Library-backed (use; never reimplement)\n\nThese capabilities already exist as internal libraries. Reach them through the\nlibrary registry MCP (`find_library(capability)`) and read the library's own MCP\nfor its API. Writing a private copy of any of these is a defect.\n\n| Capability | Library | MCP |\n|---|---|---|\n| Persistence: repositories, cache, populate, transactions | Mongo Ops (`mongo_ops`) | `:8007` |\n| Dependency injection / composition | Hexa (`hexa`) | `:8008` |\n| Deterministic multi-step transformation engine | DAG Pipe (`dagpipe`) | `:8006` |\n| Spec-first HTTP edge, client, codegen | OpenAPI First (`openapi_first`) | `:8004` |\n| Format-agnostic fetch + parse (HTML, PDF, CSV, XLSX) | Omniread (`omniread`) | `:8003` |\n| Read-only email ingestion | Mail Intake (`mail_intake`) | `:8002` |\n| JWT auth primitives (register, login, introspect) | Py JWT (`jwtlib`) | `:8005` |\n| Documentation build (MkDocs + MCP) | Doc Forge (`docforge`) | `:8001` |\n\n## Tier B — Template-provided (copy from `templates/app`, then adapt)\n\nThese are reusable internal components shipped **inside** the template — they are\nnot independent libraries. Copy them as-is, then rename/populate only what is\napp-specific. They give every app the same wiring shape as the reference app.\n\n| Component | Template location | Action |\n|---|---|---|\n| Layering & dependency rule | the package layout + import rules | keep |\n| Composition root shape | `init.py` (`get_svc`/`get_db`, idempotency) | keep |\n| Spec-first web edge | `interfaces/openapi.py`, thin handlers bound to `operationId` | keep |\n| CRUD slice pattern | `domain/<resource>/{models.py, ports/db.py, ports/service.py, converters.py}` | rename + fill |\n| Generic CRUD service | `infra/adapters/service.py` (`CrudsServiceAdaptor`) | keep |\n| Repository factory | `infra/db/repos/mongo.py` (`_repo_factory`) | keep |\n| Worker poll + status loop | `worker.py` | keep |\n| Progress bus + streaming | bus port + in-memory adapter + SSE endpoint | keep |\n| Human-in-the-loop | durable pause + question document + resolve endpoint + resume map | keep |\n| HTTP response cache | `cache_response` decorator + generation invalidation | keep |\n| Key-value store | KV store port + Redis adapter + in-memory fallback | keep |\n| LLM client pool | provider-select + retry pool scaffold | keep |\n| Pipeline scaffold | `pipelines/shared/{ports,loader,registry}.py` + `pipelines/<family>/` | rename + fill |\n| Operator scripts | `scripts/` (`init()` / `get_svc()` / `shutdown()`) | rename |\n| Testing harness | integration fakes + fixture-parity pattern | keep |\n| Project scaffolding | `pyproject.toml`, `Dockerfile`, `docker-compose.yaml`, `.env.example`, `openapi.yaml` | rename + fill |\n\n## Tier C — App-specific (must be authored)\n\n| Artifact | Why |\n|---|---|\n| Resource models | the business shape |\n| Repository populate/cache rules | the relationships |\n| Service business logic | domain behaviour |\n| Pipeline families | the transformation graph |\n| Pattern/rule resources | data-driven policy |\n| Fixtures & golden outputs | test data |\n\n---\n\n## The two axes\n\nEverything app-specific reduces to **two axes**:\n\n1. **Resource model** — the set of resources, their fields, relationships, and\n the OpenAPI contract.\n2. **Pipeline family** — only if inputs require multi-step transformation.\n\nIf you can specify those two axes, the rest of the app follows mechanically:\nTier A is imported, Tier B is copied, and Tier C is the only thing you author.\n",
|
||||
"docstring": "# Genericity Matrix\n\n## Summary\n\nThis page states precisely what is **library-backed**, what is\n**template-provided**, and what is **app-specific and must be authored**. Use it\nto scope a new build and to know, for any given capability, whether to call a\nlibrary, copy template code, or write it yourself.\n\n---\n\n## Tier A — Library-backed (use; never reimplement)\n\nThese capabilities already exist as internal libraries. Reach them through the\nlibrary registry MCP (`find_library(capability)`) and read the library's own MCP\nfor its API. Writing a private copy of any of these is a defect.\n\n| Capability | Library | MCP |\n|---|---|---|\n| Persistence: repositories, cache, populate, transactions | Mongo Ops (`mongo_ops`) | `:8007` |\n| Dependency injection / composition | Hexa (`hexa`) | `:8008` |\n| Deterministic multi-step transformation engine | DAG Pipe (`dagpipe`) | `:8006` |\n| Spec-first HTTP edge, client, codegen | OpenAPI First (`openapi_first`) | `:8004` |\n| Format-agnostic fetch + parse (HTML, PDF, CSV, XLSX) | Omniread (`omniread`) | `:8003` |\n| Read-only email ingestion | Mail Intake (`mail_intake`) | `:8002` |\n| JWT auth primitives (register, login, introspect) | Py JWT (`jwtlib`) | `:8005` |\n| Documentation build (MkDocs + MCP) | Doc Forge (`docforge`) | `:8001` |\n\n## Tier B — Template-provided (copy from `templates/app`, then adapt)\n\nThese are reusable internal components shipped **inside** the template — they are\nnot independent libraries. Copy them as-is, then rename/populate only what is\napp-specific. They give every app the same wiring shape as the reference app.\nFetch them by kind with `get_app_component`, or the whole tree with\n`get_app_template`.\n\n| Component | Template location | Action | Shipped |\n|---|---|---|---|\n| Layering & dependency rule | the package layout + import rules | keep | ✅ |\n| Composition root shape | `init.py` (`get_svc`/`get_db`, idempotency) | keep | ✅ |\n| Spec-first web edge | `interfaces/openapi.py`, thin handlers bound to `operationId` | keep | ✅ |\n| CRUD slice pattern | `domain/<resource>/{models.py, ports/db.py, ports/service.py, converters.py}` | rename + fill | ✅ |\n| Generic CRUD service | `infra/adapters/service.py` (`CrudsServiceAdaptor`) | keep | ✅ |\n| Repository factory | `infra/db/repos/mongo.py` (`_repo_factory`) | keep | ✅ |\n| Worker poll + status loop | `worker.py` | keep | ✅ |\n| Progress bus | `infra/adapters/sse.py` (keyed in-memory bus) + an app SSE endpoint | keep | ✅ |\n| HTTP response cache | `infra/http_cache.py` (`cache_response` + generation invalidation) | keep | ✅ |\n| Key-value store | `infra/adapters/kv.py` (port + in-memory + Redis adaptor) | keep | ✅ |\n| Pipeline scaffold | `pipelines/shared/{ports,loader,registry}.py` + `pipelines/<family>/` | rename + fill | ✅ |\n| Operator scripts | `scripts/` (`init()` / `get_svc()` / `shutdown()`) | rename | ✅ |\n| Testing harness | integration fakes + fixture-parity pattern | keep | ✅ |\n| Project scaffolding | `pyproject.toml`, `Dockerfile`, `docker-compose.yaml`, `.env.example`, `openapi.yaml` | rename + fill | ✅ |\n\n## Tier B (patterns) — port from the reference app\n\nA few Tier B capabilities are real and reusable, but are **shaped by the app or\nthe provider**, so the template does not ship them verbatim — copying them\nblindly would carry app-specific names. Treat them as patterns: lift the shape\nfrom the reference app and rename per app.\n\n| Component | Reference location | Action | Shipped |\n|---|---|---|---|\n| Human-in-the-loop | durable pause + question document + resolve endpoint + resume map | port + reshape | ✗ |\n| LLM client pool | provider-select + retry/rotation pool scaffold | port + reshape | ✗ |\n\n## Tier C — App-specific (must be authored)\n\n| Artifact | Why |\n|---|---|\n| Resource models | the business shape |\n| Repository populate/cache rules | the relationships |\n| Service business logic | domain behaviour |\n| Pipeline families | the transformation graph |\n| Pattern/rule resources | data-driven policy |\n| Fixtures & golden outputs | test data |\n\n---\n\n## The two axes\n\nEverything app-specific reduces to **two axes**:\n\n1. **Resource model** — the set of resources, their fields, relationships, and\n the OpenAPI contract.\n2. **Pipeline family** — only if inputs require multi-step transformation.\n\nIf you can specify those two axes, the rest of the app follows mechanically:\nTier A is imported, Tier B is copied, and Tier C is the only thing you author.\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## Codegen\n\nThe `openapi_first` library can scaffold models and route stubs from the spec,\nand ships app templates (CRUD app, model app, service). Prefer codegen over\nhand-writing boilerplate.\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## 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",
|
||||
"objects": {}
|
||||
}
|
||||
}
|
||||
147
standards/mcp/templates/app/infra/adapters/kv.py
Normal file
147
standards/mcp/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/mcp/templates/app/infra/adapters/sse.py
Normal file
54
standards/mcp/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/mcp/templates/app/infra/http_cache.py
Normal file
229
standards/mcp/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
|
||||
Reference in New Issue
Block a user