feat(standards): fix F11-F16 and rebuild bundle (43 components) + trial findings

- F11 PopulateRule field_name/collection_name corrections
- F12 service adaptor update handles PopulatingRepository
- F13 nested-references section; F14 authless /users/me guidance
- F15 template ruff-clean; F16 stop vendoring .ruff_cache, ship .gitignore
- document --template-style in build_recipe/spec_first_routing; refresh collected
  openapi-first + standards bundle (12 modules, 43 components)
- add trial.md and trial_findings.md (F1-F16, re-trial A/B evidence)
This commit is contained in:
2026-10-13 19:35:12 +05:30
parent fced40af59
commit 6c0d859a1e
56 changed files with 521 additions and 165 deletions

View File

@@ -76,11 +76,14 @@ Then:
```bash
openapi-first models openapi.yaml -o _gen/models.py
openapi-first routes openapi.yaml -o _gen/routes \
--use-models --models-module _gen.models --inject-request --package
--use-models --models-module _gen.models --inject-request --package --template-style
```
`_gen/models.py` is the **wire contract** (Pydantic, one class per
`components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every
`operationId` with its exact parameter signature. Treat `_gen/` as generated
`operationId` with its exact parameter signature. `--template-style` makes
those stubs `async def` with a `body` parameter, matching the handlers you
keep in `interfaces/` (without it they are sync `def` stubs taking
`payload`; both bind the same way). Treat `_gen/` as generated
(never hand-edit it) — the canonical handlers live in `interfaces/`.
4. **Write each CRUD slice (app-specific)** — per resource create the four-file
@@ -168,6 +171,10 @@ Rules of the road:
`domain/<resource>/models.py` is your business shape. If the spec's schema has
a required `id: str`, keep it off the domain model and let the document get its
id from `BaseDocument`.
- **Use `--template-style`.** Stubs are then `async def ...(request, body: ...)`
matching `interfaces/`; without it they are sync `def ...(payload: ...)`.
Either binds (the router introspects the signature), but `--template-style`
keeps the scaffold consistent with the code you keep.
- **Resource names are sanitized.** `/fetch-requests` → `fetch_requests.py`.
- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.
- **`_gen/` is disposable.** Regenerate on every spec change; `git diff` shows

View File

@@ -61,6 +61,14 @@ async def create_item(request: Request, body: dict):
return await svc.create(Item.model_validate(body))
```
**Generated stubs.** `openapi-first routes` emits this exact shape (`async def`
handlers taking `body`) when given `--template-style`; without that flag the
stub is `def create_item(payload: ItemInput)` — sync, with a `payload`
parameter. Both shapes bind identically: the router resolves handlers by
`operationId` and introspects the FastAPI signature, and it never inspects the
request body. Pass `--template-style` so the generated signatures match the
handlers you keep in `interfaces/`.
## Conventions
- `operationId` naming: `list_<resource>`, `create_<resource>`,
@@ -84,6 +92,11 @@ just omit the security keys:
global `security` block exists. Leaving every operation without a `security`
key is the cleanest authless spec.
Profile endpoints (`/users/me`) and profile headers (`x-profile`) still exist in
an authless app. Define a stand-in identity in the composition root — for
example `get_current_user` returns or lazily creates a fixed "Demo User" — so
handlers stay auth-agnostic and only the wiring changes when real auth arrives.
The handlers, services, and composition root are unchanged. When auth is needed
later, add the global `security` block and an introspecting dependency; only
handlers that must opt out get `security: []`.
@@ -96,7 +109,7 @@ handlers that must opt out get `security: []`.
```bash
openapi-first models openapi.yaml -o _gen/models.py
openapi-first routes openapi.yaml -o _gen/routes \
--use-models --models-module _gen.models --inject-request --package
--use-models --models-module _gen.models --inject-request --package --template-style
```
Prefer codegen over hand-writing boilerplate. The result is the wire contract

View File

@@ -69,11 +69,26 @@ def create_item_repo(*, owner_repo=None) -> ItemRepository:
return repo_factory(
collection="items",
document=ItemDocument,
populate_rules=[PopulateRule(field="owner", collection="users")],
populate_rules=[PopulateRule(field_name="owner", collection_name="users")],
cache=None, # or a CachedBaseRepository config
)
```
## Nested references
The wire contract often declares a reference as an expanded object
(`expense.entity: Entity`), while the domain model stays id-free. Pick one
convention and apply it consistently:
- **Embed (default for small, stable refs)** — store the object as a `dict` /
`list[dict]` on the document; the API returns it verbatim. No populate wiring.
- **Populate** — store an id and declare a `PopulateRule`; mongo-ops expands it
on read. The referenced repo must be registered with the `PopulationEngine`.
- **Id only** — store the id and let the client resolve it.
For populate-backed repositories change a reference with `update`, not `patch`:
`patch` rejects changes to populated FK fields by design.
## ports/service.py
The service is built through the DI library, injecting the repository. The

View File

@@ -27,7 +27,7 @@ Tier A / B / C split.
- Referenced documents are described declaratively:
```python
PopulateRule(field="owner", collection="users",
PopulateRule(field_name="owner", collection_name="users",
nested_rules=[], max_depth=2, projection=None)
```

23
standards/src/templates/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Byte-compiled / caches
__pycache__/
*.py[cod]
.ruff_cache/
.pytest_cache/
.mypy_cache/
# Virtual environments
.venv/
venv/
# Env
.env
# Generated wire contract (regenerate from openapi.yaml)
_gen/
# Packaging / coverage
dist/
build/
*.egg-info/
htmlcov/
.coverage

View File

@@ -2,9 +2,10 @@
Mongo persistence for items: document schema and repository factory.
"""
from mongo_ops import BaseDocument, BaseRepository
from domain.item.models import Item
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
from mongo_ops import BaseDocument, BaseRepository
class ItemDocument(Item, BaseDocument):

View File

@@ -3,6 +3,7 @@
Job domain model — a pipeline work item consumed by the background worker.
"""
from typing import Literal
from pydantic import BaseModel, Field

View File

@@ -2,9 +2,10 @@
Mongo persistence for jobs: document schema and repository factory.
"""
from mongo_ops import BaseDocument, BaseRepository
from domain.job.models import Job
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
from mongo_ops import BaseDocument, BaseRepository
class JobDocument(Job, BaseDocument):

View File

@@ -21,18 +21,17 @@ Example:
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Dict, List, Optional
class KVStore(ABC):
"""Abstract key-value store port."""
@abstractmethod
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Return the value at ``key``, or ``None``."""
@abstractmethod
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
@abstractmethod
@@ -40,7 +39,7 @@ class KVStore(ABC):
"""Delete ``key`` if present."""
@abstractmethod
async def scan(self, pattern: str) -> List[str]:
async def scan(self, pattern: str) -> list[str]:
"""Return keys matching a glob ``pattern``."""
async def clear(self, pattern: str = "*") -> None:
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
def __init__(self) -> None:
"""Initialize an empty store with no imports or connections."""
self._data: Dict[str, str] = {}
self._expiry: Dict[str, float] = {}
self._data: dict[str, str] = {}
self._expiry: dict[str, float] = {}
def _expired(self, key: str) -> bool:
import time
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
return True
return False
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Return the value at ``key``, or ``None`` when absent/expired."""
if self._expired(key):
return None
return self._data.get(key)
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
import time
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
self._data.pop(key, None)
self._expiry.pop(key, None)
async def scan(self, pattern: str) -> List[str]:
async def scan(self, pattern: str) -> list[str]:
"""Return live keys matching a glob ``pattern``."""
import fnmatch
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
self._redis = Redis.from_url(url, decode_responses=decode_responses)
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Return the value at ``key``, or ``None``."""
return await self._redis.get(key)
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
await self._redis.set(key, value, ex=ttl)
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
"""Delete ``key`` if present."""
await self._redis.delete(key)
async def scan(self, pattern: str) -> List[str]:
async def scan(self, pattern: str) -> list[str]:
"""Return keys matching a glob ``pattern``."""
return [key async for key in self._redis.scan_iter(match=pattern)]

View File

@@ -5,14 +5,14 @@ Generic CRUD service adaptor over a Mongo repository.
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
patch, delete, and search operations for every domain that needs them.
"""
from typing import Generic, Type, TypeVar
from typing import Generic, TypeVar
from mongo_ops import BaseDocument, PopulatingRepository
from mongo_ops.models import PyObjectId
from pydantic import BaseModel
from infra.db.repos import mongo
from pydantic import BaseModel
from mongo_ops import BaseDocument
from mongo_ops.models import PyObjectId
Model = TypeVar("Model", bound=BaseModel)
Schema = TypeVar("Schema", bound=BaseDocument)
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
"""
repo: mongo.BaseRepository
model: Type[Model]
schema: Type[Schema]
model: type[Model]
schema: type[Schema]
def __init__(self, *, repo=None):
"""Initialize the adaptor with a backing repository.
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
Returns:
Schema | None: Updated document, or ``None`` when absent.
Notes:
A populating repository expects a document *model* (so FK fields can
be depopulated), while a base repository expects a raw mapping. This
method normalizes both inputs to whatever the repo requires.
"""
if isinstance(self.repo, PopulatingRepository):
if not isinstance(data, self.schema):
fields = data if isinstance(data, dict) else self.model_to_dict(data)
data = self.schema(**fields)
return await self.repo.update(id, data)
return await self.repo.update(
id,
data if isinstance(data, dict) else self.model_to_dict(data),

View File

@@ -7,7 +7,7 @@ SSEInMemoryBus — in-memory event bus for SSE progress events.
Buffer published progress events so they can be replayed (and streamed) per
correlation key, such as a job, upload, or fetch-request id.
"""
from typing import Generic, List, TypeVar
from typing import Generic, TypeVar
T = TypeVar("T")
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
key_attr (str): Attribute name on each event holding the stream
correlation key (for example ``"job_id"``).
"""
self._events: List[T] = []
self._events: list[T] = []
self._key_attr = key_attr
async def publish(self, event: T) -> None:
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
"""
self._events.append(event)
def get_events(self, key: str) -> List[T]:
def get_events(self, key: str) -> list[T]:
"""Return buffered events for a correlation key.
Args:

View File

@@ -5,21 +5,20 @@ Generic MongoDB CRUD repo — single implementation for all domains.
Select a concrete mongo-ops repository (base, cached, or populating) from the
options supplied to the factory.
"""
from typing import List, Optional, Type
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
from mongo_ops.cache import CacheBackend, CacheConfig
from mongo_ops.models import BaseDocument
from mongo_ops.populate import PopulationEngine, PopulateRule
from mongo_ops.populate import PopulateRule, PopulationEngine
def _repo_factory(
collection: str,
db_model: Type[BaseDocument],
cache_backend: Optional[CacheBackend] = None,
cache_config: Optional[CacheConfig] = None,
engine: Optional[PopulationEngine] = None,
populate_rules: Optional[List[PopulateRule]] = None,
db_model: type[BaseDocument],
cache_backend: CacheBackend | None = None,
cache_config: CacheConfig | None = None,
engine: PopulationEngine | None = None,
populate_rules: list[PopulateRule] | None = None,
) -> BaseRepository:
"""Build the appropriate mongo-ops repository.

View File

@@ -23,8 +23,9 @@ Notes:
"""
import hashlib
import json
from collections.abc import Callable
from functools import wraps
from typing import Any, Callable, Optional
from typing import Any
from fastapi import Request, Response
from fastapi.encoders import jsonable_encoder
@@ -55,7 +56,7 @@ class HttpCacheStore:
self._redis = Redis.from_url(url, decode_responses=decode_responses)
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Read the value stored at ``key``.
Args:
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
return hashlib.sha256("|".join(parts).encode()).hexdigest()
def cache_response(ttl: Optional[int] = None) -> Callable:
def cache_response(ttl: int | None = None) -> Callable:
"""Decorate a GET-style route handler to cache its JSON response.
Args:
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
return await func(*args, **kwargs)
db_resources = getattr(request.app.state, "db_resources", None) or {}
store: Optional[HttpCacheStore] = db_resources.get("http_cache_store")
store: HttpCacheStore | None = db_resources.get("http_cache_store")
if store is None:
return await func(*args, **kwargs)

View File

@@ -4,7 +4,7 @@ Infrastructure initialization — connects MongoDB and wires all repos and servi
Shared bootstrap for both the FastAPI app and the worker, guarded against
repeated initialization via a module-level flag.
"""
from typing import Any, Dict
from typing import Any
from config import settings
from domain.item.ports.db import create_item_repo
@@ -13,11 +13,11 @@ from domain.job.ports.db import create_job_repo
from domain.job.ports.service import get_job_service
from infra.db.setup import setup_database, shutdown_database
_resources: Dict[str, Any] = {}
_resources: dict[str, Any] = {}
_initialized: bool = False
async def init() -> Dict[str, Any]:
async def init() -> dict[str, Any]:
"""Connect MongoDB, wire all repos and services, and return the resources dict.
Idempotent: repeated calls short-circuit and return the cached resources.

View File

@@ -4,5 +4,6 @@ All handler functions — names must match openapi.yaml operationId values. The
OpenAPI-first router resolves each operation to a handler by name, so this module
re-exports every route handler from the subpackages into one flat namespace.
"""
# ruff: noqa: F403
from interfaces.item.routes import *
from interfaces.job.routes import *

View File

@@ -4,6 +4,7 @@ FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
Handlers are looked up by operationId in interfaces.openapi, so no router tables
are declared here; the lifespan initializes and shuts down app resources.
"""
# ruff: noqa: E402
import logging
from contextlib import asynccontextmanager

View File

@@ -9,7 +9,6 @@ from pathlib import Path
from typing import Any
import yaml
from hexa import build

View File

@@ -4,13 +4,12 @@ Family registry — resolve a ``(kind, variant, mode)`` triple to a root class.
A single lookup replaces hand-rolled ``if/else`` family selection.
"""
from typing import Dict, Type
# Populated by ``register`` as families import.
_FAMILIES: Dict[str, Dict[str, Dict[str, Type]]] = {}
_FAMILIES: dict[str, dict[str, dict[str, type]]] = {}
def register(kind: str, variant: str, root_cls: Type, mode: str = "default") -> Type:
def register(kind: str, variant: str, root_cls: type, mode: str = "default") -> type:
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
Returns:
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
return root_cls
def resolve_root(kind: str, variant: str, mode: str = "default") -> Type:
def resolve_root(kind: str, variant: str, mode: str = "default") -> type:
"""Return the root class for a ``(kind, variant, mode)`` triple.
Raises:

View File

@@ -40,6 +40,7 @@ testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
extend-exclude = ["_gen"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

View File

@@ -1,9 +1,9 @@
import importlib
import interfaces.openapi as routes_module
from openapi_first.app import OpenAPIFirstApp
import interfaces.openapi as routes_module
def test_openapi_app_builds():
"""The spec-first app must build and plant each route handler."""

View File

@@ -1,11 +1,10 @@
import importlib
import yaml
from openapi_first.app import OpenAPIFirstApp
import interfaces.openapi as routes_module
from openapi_first.app import OpenAPIFirstApp
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}