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

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