"""Versioned FastAPI service skeleton. The service layer is intentionally thin: route handlers translate HTTP requests into service/runtime contracts and must not own domain behavior. """ from __future__ import annotations from dataclasses import dataclass, field from importlib import metadata from typing import Any from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository from kontextual_engine.core import ( Actor, ActorType, Classification, ContextEntity, ContextEntityType, LifecycleState, MetadataRecord, OperationContext, PolicyDecision, RelationshipTargetKind, SourceReference, utc_now, ) from kontextual_engine.errors import AuthorizationError, KontextualError, NotFoundError, ValidationError from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway from kontextual_engine.services import AssetRegistryService API_VERSION = "v1" OPENAPI_VERSION = "1.0.0" @dataclass class ServiceRuntime: repository: AssetRegistryRepository = field(default_factory=InMemoryAssetRegistryRepository) policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway) api_version: str = API_VERSION service_name: str = "kontextual-engine" started_at: str = field(default_factory=lambda: utc_now().isoformat()) def asset_service(self) -> AssetRegistryService: return AssetRegistryService(self.repository, policy_gateway=self.policy_gateway) def operation_context( self, *, actor_id: str = "api-user", actor_type: str = "human", display_name: str | None = None, correlation_id: str | None = None, groups: list[str] | None = None, metadata: dict[str, Any] | None = None, ) -> OperationContext: actor = Actor.create( ActorType(actor_type), actor_id=actor_id, display_name=display_name, groups=groups, metadata=metadata, ) return OperationContext.create(actor, correlation_id=correlation_id) @property def package_version(self) -> str: try: return metadata.version("kontextual-engine") except metadata.PackageNotFoundError: return "0.1.0" def health(self) -> dict[str, Any]: return { "status": "ok", "service": self.service_name, "api_version": self.api_version, "package_version": self.package_version, "started_at": self.started_at, } def readiness(self) -> dict[str, Any]: checks: dict[str, dict[str, Any]] = {} try: asset_count = len(self.repository.list_assets()) checks["asset_registry"] = { "status": "ok", "repository": type(self.repository).__name__, "asset_count": asset_count, } except Exception as exc: checks["asset_registry"] = { "status": "error", "repository": type(self.repository).__name__, "error_type": type(exc).__name__, "message": str(exc), } ready = all(item["status"] == "ok" for item in checks.values()) return { "status": "ready" if ready else "not_ready", "ready": ready, "service": self.service_name, "api_version": self.api_version, "checks": checks, } def version(self) -> dict[str, Any]: return { "service": self.service_name, "api_version": self.api_version, "package_version": self.package_version, "openapi_version": OPENAPI_VERSION, } def create_asset(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]: classification = Classification.from_dict(payload["classification"]) result = self.asset_service().create_asset( payload["title"], classification, context, asset_id=payload.get("asset_id"), source_refs=[_source_reference(item) for item in payload.get("source_refs", ())], metadata_records=[_metadata_record(item) for item in payload.get("metadata_records", ())], idempotency_key=payload.get("idempotency_key"), ) return _asset_change_result(result) def get_asset(self, asset_id: str) -> dict[str, Any]: return self.asset_service().get_asset(asset_id).to_dict() def list_assets( self, *, lifecycle: str | None = None, asset_type: str | None = None, sensitivity: str | None = None, owner: str | None = None, topic: str | None = None, review_state: str | None = None, ) -> dict[str, Any]: assets = self.asset_service().list_assets( lifecycle=LifecycleState(lifecycle) if lifecycle else None, asset_type=asset_type, sensitivity=sensitivity, owner=owner, topic=topic, review_state=review_state, ) return {"items": [asset.to_dict() for asset in assets], "count": len(assets)} def add_metadata_record( self, asset_id: str, payload: dict[str, Any], context: OperationContext, ) -> dict[str, Any]: result = self.asset_service().add_metadata_record( asset_id, _metadata_record(payload), context, expected_current_version_id=payload.get("expected_current_version_id"), ) return _asset_change_result(result) def list_metadata_records(self, asset_id: str) -> dict[str, Any]: records = self.repository.list_metadata_records(asset_id) return {"items": [record.to_dict() for record in records], "count": len(records)} def transition_lifecycle( self, asset_id: str, payload: dict[str, Any], context: OperationContext, ) -> dict[str, Any]: result = self.asset_service().transition_lifecycle( asset_id, LifecycleState(payload["lifecycle"]), context, expected_current_version_id=payload.get("expected_current_version_id"), ) return _asset_change_result(result) def create_relationship(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]: target_kind = RelationshipTargetKind(payload.get("target_kind", RelationshipTargetKind.ASSET.value)) service = self.asset_service() if target_kind == RelationshipTargetKind.CONTEXT_ENTITY: entity_payload = payload.get("context_entity") or {} entity = ContextEntity( entity_id=payload["target_id"], entity_type=ContextEntityType(entity_payload.get("entity_type", ContextEntityType.TOPIC.value)), name=entity_payload.get("name", payload["target_id"]), external_ref=entity_payload.get("external_ref"), metadata=dict(entity_payload.get("metadata", {})), ) result = service.link_asset_to_context_entity( payload["source_asset_id"], entity, payload["predicate"], context, confidence=payload.get("confidence"), provenance=dict(payload.get("provenance", {})), expected_current_version_id=payload.get("expected_current_version_id"), ) else: result = service.link_asset_to_asset( payload["source_asset_id"], payload["target_id"], payload["predicate"], context, confidence=payload.get("confidence"), provenance=dict(payload.get("provenance", {})), expected_current_version_id=payload.get("expected_current_version_id"), ) return { "relationship": result.relationship.to_dict(), "version": result.version.to_dict(), "audit_event": result.audit_event.to_dict(), "policy_decision": result.policy_decision.to_dict(), } def list_relationships( self, *, source_id: str | None = None, target_id: str | None = None, ) -> dict[str, Any]: relationships = self.repository.list_relationships(source_id=source_id, target_id=target_id) return { "items": [relationship.to_dict() for relationship in relationships], "count": len(relationships), } def list_audit_events( self, *, target: str | None = None, correlation_id: str | None = None, ) -> dict[str, Any]: events = self.repository.list_audit_events(target=target, correlation_id=correlation_id) return {"items": [event.to_dict() for event in events], "count": len(events)} def evaluate_policy(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]: try: decision = self.policy_gateway.authorize( context, payload["action"], payload["resource"], resource_metadata=dict(payload.get("resource_metadata", {})), ) except Exception as exc: decision = PolicyDecision.fail_closed( context.actor.id, payload.get("action", "unknown"), payload.get("resource", "unknown"), reason=str(exc), context={"gateway_error": type(exc).__name__}, ) return decision.to_dict() def create_app(runtime: ServiceRuntime | None = None): try: from fastapi import Depends, FastAPI, Header, HTTPException, Query except ImportError as exc: # pragma: no cover - exercised when optional extra is absent raise RuntimeError( "FastAPI service dependencies are not installed. Install kontextual-engine[service]." ) from exc runtime = runtime or ServiceRuntime() app = FastAPI( title="Kontextual Engine Service API", version=OPENAPI_VERSION, openapi_url="/openapi.json", docs_url="/docs", redoc_url="/redoc", ) app.state.kontextual_runtime = runtime @app.get("/health", tags=["system"]) def health() -> dict[str, Any]: return runtime.health() @app.get("/ready", tags=["system"]) def ready() -> dict[str, Any]: return runtime.readiness() @app.get("/version", tags=["system"]) def version() -> dict[str, Any]: return runtime.version() prefix = f"/api/{runtime.api_version}" @app.get(f"{prefix}/health", tags=["system"]) def versioned_health() -> dict[str, Any]: return runtime.health() @app.get(f"{prefix}/ready", tags=["system"]) def versioned_ready() -> dict[str, Any]: return runtime.readiness() @app.get(f"{prefix}/version", tags=["system"]) def versioned_version() -> dict[str, Any]: return runtime.version() def context_from_headers( x_actor_id: str = Header("api-user"), x_actor_type: str = Header("human"), x_actor_display_name: str | None = Header(None), x_correlation_id: str | None = Header(None), ) -> OperationContext: return runtime.operation_context( actor_id=x_actor_id, actor_type=x_actor_type, display_name=x_actor_display_name, correlation_id=x_correlation_id, ) def response(callable_obj, *args: Any, **kwargs: Any) -> Any: try: return callable_obj(*args, **kwargs) except NotFoundError as exc: raise HTTPException(status_code=404, detail=_error_payload(exc)) from exc except AuthorizationError as exc: raise HTTPException(status_code=403, detail=_error_payload(exc)) from exc except ValidationError as exc: raise HTTPException(status_code=422, detail=_error_payload(exc)) from exc except KontextualError as exc: raise HTTPException(status_code=400, detail=_error_payload(exc)) from exc @app.post(f"{prefix}/assets", tags=["assets"]) def create_asset( payload: dict[str, Any], context: OperationContext = Depends(context_from_headers), ) -> dict[str, Any]: return response(runtime.create_asset, payload, context) @app.get(f"{prefix}/assets", tags=["assets"]) def list_assets( lifecycle: str | None = Query(None), asset_type: str | None = Query(None), sensitivity: str | None = Query(None), owner: str | None = Query(None), topic: str | None = Query(None), review_state: str | None = Query(None), ) -> dict[str, Any]: return response( runtime.list_assets, lifecycle=lifecycle, asset_type=asset_type, sensitivity=sensitivity, owner=owner, topic=topic, review_state=review_state, ) @app.get(f"{prefix}/assets/{{asset_id}}", tags=["assets"]) def get_asset(asset_id: str) -> dict[str, Any]: return response(runtime.get_asset, asset_id) @app.post(f"{prefix}/assets/{{asset_id}}/metadata", tags=["metadata"]) def add_metadata( asset_id: str, payload: dict[str, Any], context: OperationContext = Depends(context_from_headers), ) -> dict[str, Any]: return response(runtime.add_metadata_record, asset_id, payload, context) @app.get(f"{prefix}/assets/{{asset_id}}/metadata", tags=["metadata"]) def list_metadata(asset_id: str) -> dict[str, Any]: return response(runtime.list_metadata_records, asset_id) @app.post(f"{prefix}/assets/{{asset_id}}/lifecycle", tags=["assets"]) def transition_lifecycle( asset_id: str, payload: dict[str, Any], context: OperationContext = Depends(context_from_headers), ) -> dict[str, Any]: return response(runtime.transition_lifecycle, asset_id, payload, context) @app.post(f"{prefix}/relationships", tags=["relationships"]) def create_relationship( payload: dict[str, Any], context: OperationContext = Depends(context_from_headers), ) -> dict[str, Any]: return response(runtime.create_relationship, payload, context) @app.get(f"{prefix}/relationships", tags=["relationships"]) def list_relationships( source_id: str | None = Query(None), target_id: str | None = Query(None), ) -> dict[str, Any]: return response(runtime.list_relationships, source_id=source_id, target_id=target_id) @app.get(f"{prefix}/audit/events", tags=["audit"]) def list_audit_events( target: str | None = Query(None), correlation_id: str | None = Query(None), ) -> dict[str, Any]: return response(runtime.list_audit_events, target=target, correlation_id=correlation_id) @app.post(f"{prefix}/policy/evaluate", tags=["policy"]) def evaluate_policy( payload: dict[str, Any], context: OperationContext = Depends(context_from_headers), ) -> dict[str, Any]: return response(runtime.evaluate_policy, payload, context) return app def _metadata_record(data: dict[str, Any]) -> MetadataRecord: if "record_id" in data and "created_at" in data: return MetadataRecord.from_dict(data) return MetadataRecord( key=data["key"], value=data.get("value"), provenance=dict(data.get("provenance", {})), confidence=data.get("confidence"), confirmed=bool(data.get("confirmed", False)), record_id=data.get("record_id") or MetadataRecord(data["key"], data.get("value")).record_id, ) def _source_reference(data: dict[str, Any]) -> SourceReference: if "id" in data: return SourceReference.from_dict(data) return SourceReference( source_system=data["source_system"], path=data.get("path"), uri=data.get("uri"), external_id=data.get("external_id"), checksum=data.get("checksum"), connector_ref=data.get("connector_ref"), metadata=dict(data.get("metadata", {})), ) def _asset_change_result(result: Any) -> dict[str, Any]: return { "asset": result.asset.to_dict(), "version": result.version.to_dict(), "audit_event": result.audit_event.to_dict(), "policy_decision": result.policy_decision.to_dict(), } def _error_payload(error: KontextualError) -> dict[str, Any]: return { "code": error.code, "message": str(error), "details": dict(error.details), }