feat: add temporal context and contextual help
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user