feat: add temporal context and contextual help

This commit is contained in:
2026-08-05 00:03:31 +02:00
parent 982ef636b8
commit add7a99f6d
43 changed files with 1878 additions and 167 deletions
@@ -7,7 +7,15 @@ from fastapi import Request
from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache"
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language")
JSON_ETAG_VARY_HEADERS = (
"Authorization",
"Cookie",
"X-API-Key",
"Accept-Language",
"X-Govoplan-Validity-Mode",
"X-Govoplan-Valid-At",
"X-Govoplan-Recorded-At",
)
async def conditional_json_get_middleware(
+2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
from govoplan_core.server.temporal import temporal_data_context_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
@@ -169,6 +170,7 @@ def create_govoplan_app(
return response
app.middleware("http")(conditional_json_get_middleware)
app.middleware("http")(temporal_data_context_middleware)
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
+25
View File
@@ -139,6 +139,29 @@ def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]
]
def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, object]]:
contexts: list[dict[str, object]] = []
seen: set[str] = set()
for topic in manifest.documentation:
raw_contexts = topic.metadata.get("help_contexts", ())
if isinstance(raw_contexts, str) or not isinstance(raw_contexts, (list, tuple, set)):
continue
for raw_context in raw_contexts:
context_id = str(raw_context).strip()
if not context_id or context_id in seen:
continue
seen.add(context_id)
contexts.append(
{
"id": context_id,
"topic_id": topic.id,
"title": topic.title,
"documentation_types": list(topic.documentation_types),
}
)
return contexts
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
frontend = manifest.frontend
if frontend is None:
@@ -240,6 +263,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
for key, value in manifest_interface_catalog(manifest).items()
if key != "declarations"
},
"help_contexts": _documentation_help_contexts(manifest),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
}
@@ -274,6 +298,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"help_contexts": _documentation_help_contexts(manifest),
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from govoplan_core.core.temporal import (
RECORDED_AT_HEADER,
TEMPORAL_EVALUATED_AT_HEADER,
TEMPORAL_VARY_HEADERS,
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
TemporalContextError,
TemporalDataContext,
bind_temporal_data_context,
current_temporal_data_context,
parse_temporal_data_context,
reset_temporal_data_context,
)
async def temporal_data_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
try:
context = parse_temporal_data_context(
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
valid_at=request.headers.get(VALID_AT_HEADER),
recorded_at=request.headers.get(RECORDED_AT_HEADER),
evaluated_at=datetime.now(UTC),
)
except TemporalContextError as exc:
return JSONResponse(status_code=400, content={"detail": str(exc)})
request.state.govoplan_temporal_data_context = context
token = bind_temporal_data_context(context)
try:
response = await call_next(request)
finally:
reset_temporal_data_context(token)
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
context.evaluated_at
)
if context.valid_at is not None:
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
if context.recorded_at is not None:
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
_merge_vary(response, TEMPORAL_VARY_HEADERS)
return response
def get_temporal_data_context(request: Request) -> TemporalDataContext:
context = getattr(request.state, "govoplan_temporal_data_context", None)
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
current = {
item.strip().lower(): item.strip()
for item in response.headers.get("Vary", "").split(",")
if item.strip()
}
for name in names:
current.setdefault(name.lower(), name)
response.headers["Vary"] = ", ".join(current.values())
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]