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:
@@ -76,11 +76,14 @@ Then:
|
||||
```bash
|
||||
openapi-first models openapi.yaml -o _gen/models.py
|
||||
openapi-first routes openapi.yaml -o _gen/routes \
|
||||
--use-models --models-module _gen.models --inject-request --package
|
||||
--use-models --models-module _gen.models --inject-request --package --template-style
|
||||
```
|
||||
`_gen/models.py` is the **wire contract** (Pydantic, one class per
|
||||
`components.schemas` entry). `_gen/routes/<resource>/routes.py` lists every
|
||||
`operationId` with its exact parameter signature. Treat `_gen/` as generated
|
||||
`operationId` with its exact parameter signature. `--template-style` makes
|
||||
those stubs `async def` with a `body` parameter, matching the handlers you
|
||||
keep in `interfaces/` (without it they are sync `def` stubs taking
|
||||
`payload`; both bind the same way). Treat `_gen/` as generated
|
||||
(never hand-edit it) — the canonical handlers live in `interfaces/`.
|
||||
|
||||
4. **Write each CRUD slice (app-specific)** — per resource create the four-file
|
||||
@@ -168,6 +171,10 @@ Rules of the road:
|
||||
`domain/<resource>/models.py` is your business shape. If the spec's schema has
|
||||
a required `id: str`, keep it off the domain model and let the document get its
|
||||
id from `BaseDocument`.
|
||||
- **Use `--template-style`.** Stubs are then `async def ...(request, body: ...)`
|
||||
matching `interfaces/`; without it they are sync `def ...(payload: ...)`.
|
||||
Either binds (the router introspects the signature), but `--template-style`
|
||||
keeps the scaffold consistent with the code you keep.
|
||||
- **Resource names are sanitized.** `/fetch-requests` → `fetch_requests.py`.
|
||||
- **Multipart bodies expand** to `UploadFile = File(...)` / `Form(...)` params.
|
||||
- **`_gen/` is disposable.** Regenerate on every spec change; `git diff` shows
|
||||
|
||||
@@ -61,6 +61,14 @@ async def create_item(request: Request, body: dict):
|
||||
return await svc.create(Item.model_validate(body))
|
||||
```
|
||||
|
||||
**Generated stubs.** `openapi-first routes` emits this exact shape (`async def`
|
||||
handlers taking `body`) when given `--template-style`; without that flag the
|
||||
stub is `def create_item(payload: ItemInput)` — sync, with a `payload`
|
||||
parameter. Both shapes bind identically: the router resolves handlers by
|
||||
`operationId` and introspects the FastAPI signature, and it never inspects the
|
||||
request body. Pass `--template-style` so the generated signatures match the
|
||||
handlers you keep in `interfaces/`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `operationId` naming: `list_<resource>`, `create_<resource>`,
|
||||
@@ -84,6 +92,11 @@ just omit the security keys:
|
||||
global `security` block exists. Leaving every operation without a `security`
|
||||
key is the cleanest authless spec.
|
||||
|
||||
Profile endpoints (`/users/me`) and profile headers (`x-profile`) still exist in
|
||||
an authless app. Define a stand-in identity in the composition root — for
|
||||
example `get_current_user` returns or lazily creates a fixed "Demo User" — so
|
||||
handlers stay auth-agnostic and only the wiring changes when real auth arrives.
|
||||
|
||||
The handlers, services, and composition root are unchanged. When auth is needed
|
||||
later, add the global `security` block and an introspecting dependency; only
|
||||
handlers that must opt out get `security: []`.
|
||||
@@ -96,7 +109,7 @@ handlers that must opt out get `security: []`.
|
||||
```bash
|
||||
openapi-first models openapi.yaml -o _gen/models.py
|
||||
openapi-first routes openapi.yaml -o _gen/routes \
|
||||
--use-models --models-module _gen.models --inject-request --package
|
||||
--use-models --models-module _gen.models --inject-request --package --template-style
|
||||
```
|
||||
|
||||
Prefer codegen over hand-writing boilerplate. The result is the wire contract
|
||||
|
||||
@@ -69,11 +69,26 @@ def create_item_repo(*, owner_repo=None) -> ItemRepository:
|
||||
return repo_factory(
|
||||
collection="items",
|
||||
document=ItemDocument,
|
||||
populate_rules=[PopulateRule(field="owner", collection="users")],
|
||||
populate_rules=[PopulateRule(field_name="owner", collection_name="users")],
|
||||
cache=None, # or a CachedBaseRepository config
|
||||
)
|
||||
```
|
||||
|
||||
## Nested references
|
||||
|
||||
The wire contract often declares a reference as an expanded object
|
||||
(`expense.entity: Entity`), while the domain model stays id-free. Pick one
|
||||
convention and apply it consistently:
|
||||
|
||||
- **Embed (default for small, stable refs)** — store the object as a `dict` /
|
||||
`list[dict]` on the document; the API returns it verbatim. No populate wiring.
|
||||
- **Populate** — store an id and declare a `PopulateRule`; mongo-ops expands it
|
||||
on read. The referenced repo must be registered with the `PopulationEngine`.
|
||||
- **Id only** — store the id and let the client resolve it.
|
||||
|
||||
For populate-backed repositories change a reference with `update`, not `patch`:
|
||||
`patch` rejects changes to populated FK fields by design.
|
||||
|
||||
## ports/service.py
|
||||
|
||||
The service is built through the DI library, injecting the repository. The
|
||||
|
||||
@@ -27,7 +27,7 @@ Tier A / B / C split.
|
||||
- Referenced documents are described declaratively:
|
||||
|
||||
```python
|
||||
PopulateRule(field="owner", collection="users",
|
||||
PopulateRule(field_name="owner", collection_name="users",
|
||||
nested_rules=[], max_depth=2, projection=None)
|
||||
```
|
||||
|
||||
|
||||
23
standards/src/templates/app/.gitignore
vendored
Normal file
23
standards/src/templates/app/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Byte-compiled / caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
# Generated wire contract (regenerate from openapi.yaml)
|
||||
_gen/
|
||||
|
||||
# Packaging / coverage
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
htmlcov/
|
||||
.coverage
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
Mongo persistence for items: document schema and repository factory.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
from domain.item.models import Item
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
|
||||
class ItemDocument(Item, BaseDocument):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Job domain model — a pipeline work item consumed by the background worker.
|
||||
"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
Mongo persistence for jobs: document schema and repository factory.
|
||||
"""
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
from domain.job.models import Job
|
||||
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
|
||||
from mongo_ops import BaseDocument, BaseRepository
|
||||
|
||||
|
||||
class JobDocument(Job, BaseDocument):
|
||||
|
||||
@@ -21,18 +21,17 @@ Example:
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class KVStore(ABC):
|
||||
"""Abstract key-value store port."""
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the value at ``key``, or ``None``."""
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
||||
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||
|
||||
@abstractmethod
|
||||
@@ -40,7 +39,7 @@ class KVStore(ABC):
|
||||
"""Delete ``key`` if present."""
|
||||
|
||||
@abstractmethod
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
async def scan(self, pattern: str) -> list[str]:
|
||||
"""Return keys matching a glob ``pattern``."""
|
||||
|
||||
async def clear(self, pattern: str = "*") -> None:
|
||||
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty store with no imports or connections."""
|
||||
self._data: Dict[str, str] = {}
|
||||
self._expiry: Dict[str, float] = {}
|
||||
self._data: dict[str, str] = {}
|
||||
self._expiry: dict[str, float] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
import time
|
||||
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the value at ``key``, or ``None`` when absent/expired."""
|
||||
if self._expired(key):
|
||||
return None
|
||||
return self._data.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
||||
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||
import time
|
||||
|
||||
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
|
||||
self._data.pop(key, None)
|
||||
self._expiry.pop(key, None)
|
||||
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
async def scan(self, pattern: str) -> list[str]:
|
||||
"""Return live keys matching a glob ``pattern``."""
|
||||
import fnmatch
|
||||
|
||||
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
|
||||
|
||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the value at ``key``, or ``None``."""
|
||||
return await self._redis.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
||||
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
|
||||
await self._redis.set(key, value, ex=ttl)
|
||||
|
||||
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
|
||||
"""Delete ``key`` if present."""
|
||||
await self._redis.delete(key)
|
||||
|
||||
async def scan(self, pattern: str) -> List[str]:
|
||||
async def scan(self, pattern: str) -> list[str]:
|
||||
"""Return keys matching a glob ``pattern``."""
|
||||
return [key async for key in self._redis.scan_iter(match=pattern)]
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ Generic CRUD service adaptor over a Mongo repository.
|
||||
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
|
||||
patch, delete, and search operations for every domain that needs them.
|
||||
"""
|
||||
from typing import Generic, Type, TypeVar
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from mongo_ops import BaseDocument, PopulatingRepository
|
||||
from mongo_ops.models import PyObjectId
|
||||
from pydantic import BaseModel
|
||||
|
||||
from infra.db.repos import mongo
|
||||
|
||||
from pydantic import BaseModel
|
||||
from mongo_ops import BaseDocument
|
||||
from mongo_ops.models import PyObjectId
|
||||
|
||||
Model = TypeVar("Model", bound=BaseModel)
|
||||
Schema = TypeVar("Schema", bound=BaseDocument)
|
||||
|
||||
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
||||
"""
|
||||
|
||||
repo: mongo.BaseRepository
|
||||
model: Type[Model]
|
||||
schema: Type[Schema]
|
||||
model: type[Model]
|
||||
schema: type[Schema]
|
||||
|
||||
def __init__(self, *, repo=None):
|
||||
"""Initialize the adaptor with a backing repository.
|
||||
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
|
||||
|
||||
Returns:
|
||||
Schema | None: Updated document, or ``None`` when absent.
|
||||
|
||||
Notes:
|
||||
A populating repository expects a document *model* (so FK fields can
|
||||
be depopulated), while a base repository expects a raw mapping. This
|
||||
method normalizes both inputs to whatever the repo requires.
|
||||
"""
|
||||
if isinstance(self.repo, PopulatingRepository):
|
||||
if not isinstance(data, self.schema):
|
||||
fields = data if isinstance(data, dict) else self.model_to_dict(data)
|
||||
data = self.schema(**fields)
|
||||
return await self.repo.update(id, data)
|
||||
return await self.repo.update(
|
||||
id,
|
||||
data if isinstance(data, dict) else self.model_to_dict(data),
|
||||
|
||||
@@ -7,7 +7,7 @@ SSEInMemoryBus — in-memory event bus for SSE progress events.
|
||||
Buffer published progress events so they can be replayed (and streamed) per
|
||||
correlation key, such as a job, upload, or fetch-request id.
|
||||
"""
|
||||
from typing import Generic, List, TypeVar
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
|
||||
key_attr (str): Attribute name on each event holding the stream
|
||||
correlation key (for example ``"job_id"``).
|
||||
"""
|
||||
self._events: List[T] = []
|
||||
self._events: list[T] = []
|
||||
self._key_attr = key_attr
|
||||
|
||||
async def publish(self, event: T) -> None:
|
||||
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
|
||||
"""
|
||||
self._events.append(event)
|
||||
|
||||
def get_events(self, key: str) -> List[T]:
|
||||
def get_events(self, key: str) -> list[T]:
|
||||
"""Return buffered events for a correlation key.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -5,21 +5,20 @@ Generic MongoDB CRUD repo — single implementation for all domains.
|
||||
Select a concrete mongo-ops repository (base, cached, or populating) from the
|
||||
options supplied to the factory.
|
||||
"""
|
||||
from typing import List, Optional, Type
|
||||
|
||||
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
|
||||
from mongo_ops.cache import CacheBackend, CacheConfig
|
||||
from mongo_ops.models import BaseDocument
|
||||
from mongo_ops.populate import PopulationEngine, PopulateRule
|
||||
from mongo_ops.populate import PopulateRule, PopulationEngine
|
||||
|
||||
|
||||
def _repo_factory(
|
||||
collection: str,
|
||||
db_model: Type[BaseDocument],
|
||||
cache_backend: Optional[CacheBackend] = None,
|
||||
cache_config: Optional[CacheConfig] = None,
|
||||
engine: Optional[PopulationEngine] = None,
|
||||
populate_rules: Optional[List[PopulateRule]] = None,
|
||||
db_model: type[BaseDocument],
|
||||
cache_backend: CacheBackend | None = None,
|
||||
cache_config: CacheConfig | None = None,
|
||||
engine: PopulationEngine | None = None,
|
||||
populate_rules: list[PopulateRule] | None = None,
|
||||
) -> BaseRepository:
|
||||
"""Build the appropriate mongo-ops repository.
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ Notes:
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
@@ -55,7 +56,7 @@ class HttpCacheStore:
|
||||
|
||||
self._redis = Redis.from_url(url, decode_responses=decode_responses)
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Read the value stored at ``key``.
|
||||
|
||||
Args:
|
||||
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
|
||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def cache_response(ttl: Optional[int] = None) -> Callable:
|
||||
def cache_response(ttl: int | None = None) -> Callable:
|
||||
"""Decorate a GET-style route handler to cache its JSON response.
|
||||
|
||||
Args:
|
||||
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
db_resources = getattr(request.app.state, "db_resources", None) or {}
|
||||
store: Optional[HttpCacheStore] = db_resources.get("http_cache_store")
|
||||
store: HttpCacheStore | None = db_resources.get("http_cache_store")
|
||||
if store is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Infrastructure initialization — connects MongoDB and wires all repos and servi
|
||||
Shared bootstrap for both the FastAPI app and the worker, guarded against
|
||||
repeated initialization via a module-level flag.
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
from domain.item.ports.db import create_item_repo
|
||||
@@ -13,11 +13,11 @@ from domain.job.ports.db import create_job_repo
|
||||
from domain.job.ports.service import get_job_service
|
||||
from infra.db.setup import setup_database, shutdown_database
|
||||
|
||||
_resources: Dict[str, Any] = {}
|
||||
_resources: dict[str, Any] = {}
|
||||
_initialized: bool = False
|
||||
|
||||
|
||||
async def init() -> Dict[str, Any]:
|
||||
async def init() -> dict[str, Any]:
|
||||
"""Connect MongoDB, wire all repos and services, and return the resources dict.
|
||||
|
||||
Idempotent: repeated calls short-circuit and return the cached resources.
|
||||
|
||||
@@ -4,5 +4,6 @@ All handler functions — names must match openapi.yaml operationId values. The
|
||||
OpenAPI-first router resolves each operation to a handler by name, so this module
|
||||
re-exports every route handler from the subpackages into one flat namespace.
|
||||
"""
|
||||
# ruff: noqa: F403
|
||||
from interfaces.item.routes import *
|
||||
from interfaces.job.routes import *
|
||||
@@ -4,6 +4,7 @@ FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
|
||||
Handlers are looked up by operationId in interfaces.openapi, so no router tables
|
||||
are declared here; the lifespan initializes and shuts down app resources.
|
||||
"""
|
||||
# ruff: noqa: E402
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from hexa import build
|
||||
|
||||
|
||||
|
||||
@@ -4,13 +4,12 @@ Family registry — resolve a ``(kind, variant, mode)`` triple to a root class.
|
||||
|
||||
A single lookup replaces hand-rolled ``if/else`` family selection.
|
||||
"""
|
||||
from typing import Dict, Type
|
||||
|
||||
# Populated by ``register`` as families import.
|
||||
_FAMILIES: Dict[str, Dict[str, Dict[str, Type]]] = {}
|
||||
_FAMILIES: dict[str, dict[str, dict[str, type]]] = {}
|
||||
|
||||
|
||||
def register(kind: str, variant: str, root_cls: Type, mode: str = "default") -> Type:
|
||||
def register(kind: str, variant: str, root_cls: type, mode: str = "default") -> type:
|
||||
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
|
||||
|
||||
Returns:
|
||||
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
|
||||
return root_cls
|
||||
|
||||
|
||||
def resolve_root(kind: str, variant: str, mode: str = "default") -> Type:
|
||||
def resolve_root(kind: str, variant: str, mode: str = "default") -> type:
|
||||
"""Return the root class for a ``(kind, variant, mode)`` triple.
|
||||
|
||||
Raises:
|
||||
|
||||
@@ -40,6 +40,7 @@ testpaths = ["tests"]
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
extend-exclude = ["_gen"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
@@ -1,9 +1,9 @@
|
||||
import importlib
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
|
||||
def test_openapi_app_builds():
|
||||
"""The spec-first app must build and plant each route handler."""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import importlib
|
||||
|
||||
import yaml
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
import interfaces.openapi as routes_module
|
||||
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
|
||||
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user