Loading src/srm/adapters/transformation_functions/registry.py 0 → 100644 +46 −0 Original line number Diff line number Diff line from collections.abc import Mapping from srm.config import ControlPathSettings from srm.domain.ports.transformation_functions import ( ControlPathRegistry, TransformationFunctionAdapterFactory, TransformationFunctionExecutionPort, UnknownControlPathRef, ) class NoAdapterFactoryInstalled(RuntimeError): pass class ConfiguredControlPathRegistry(ControlPathRegistry): def __init__(self, adapters: Mapping[str, TransformationFunctionExecutionPort]) -> None: self._adapters = dict(adapters) def get_adapter(self, control_path_ref: str) -> TransformationFunctionExecutionPort: try: return self._adapters[control_path_ref] except KeyError as exc: raise UnknownControlPathRef(control_path_ref) from exc def control_path_refs(self) -> list[str]: return sorted(self._adapters) def build_control_path_registry( settings: ControlPathSettings, factory: TransformationFunctionAdapterFactory | None, ) -> ConfiguredControlPathRegistry: if not settings.entries: return ConfiguredControlPathRegistry({}) if factory is None: raise NoAdapterFactoryInstalled( "control paths are configured but no TF-SDK adapter factory is installed: " + ", ".join(entry.control_path_ref for entry in settings.entries) ) return ConfiguredControlPathRegistry( { entry.control_path_ref: factory.create_adapter(entry.adapter, entry.config) for entry in settings.entries } ) src/srm/api/databus/schemas.py +3 −0 Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from srm.domain.models.canonical_parameters.parameters import ( CapabilityParameters, CapabilityTarget, SourceSpecification, ) Loading Loading @@ -96,6 +97,7 @@ class NetworkCapabilityPayloadV1(CommandSchema): target: CapabilityTarget profile_ref: str | None = None parameters: CapabilityParameters source_spec: SourceSpecification | None = None class SrmNetworkCapabilityActivateV1(CommandPayloadV1): Loading Loading @@ -136,6 +138,7 @@ class NetworkCapabilityUpdatePayloadV1(NetworkCapabilityRealizationRefV1): target: CapabilityTarget | None = None profile_ref: str | None = None parameters: CapabilityParameters source_spec: SourceSpecification | None = None class SrmNetworkCapabilityUpdateV1(CommandPayloadV1): Loading src/srm/api/dependencies.py +32 −2 Original line number Diff line number Diff line Loading @@ -3,14 +3,22 @@ from typing import Annotated, AsyncGenerator, cast from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.adapters.database.repos.topology import SqlZoneRepository from srm.adapters.database.repos.catalog import ( SqlServiceCapabilityRequirementRepository, SqlServiceSpecificationRepository, ) from srm.adapters.database.repos.topology import SqlDomainRepository, SqlZoneRepository from srm.app_state import AppState from srm.application.services.capability_placement import CapabilityPlacementPlanner from srm.application.services.device_targeted_control_path import ( DeviceTargetedControlPathResolver, ) from srm.application.use_cases.catalog import ( CreateServiceSpecificationUseCase, DeleteServiceSpecificationUseCase, GetServiceSpecificationUseCase, ) from srm.application.use_cases.location_query import LocationQueryUseCase from srm.application.use_cases.topology import ListZonesUseCase from srm.config import get_settings Loading Loading @@ -75,3 +83,25 @@ ListZonesUseCaseDep = Annotated[ Depends(get_list_zones_use_case), ] ZoneProviderNameDep = Annotated[str, Depends(get_zone_provider_name)] def get_location_query_use_case( request: Request, session: SessionDep, ) -> LocationQueryUseCase: registry = get_app_state(request).control_path_registry return LocationQueryUseCase( SqlServiceSpecificationRepository(session), SqlServiceCapabilityRequirementRepository(session), DeviceTargetedControlPathResolver( CapabilityPlacementPlanner(SqlZoneRepository(session), SqlDomainRepository(session)), registry, ), registry, ) LocationQueryUseCaseDep = Annotated[ LocationQueryUseCase, Depends(get_location_query_use_case), ] src/srm/api/errors.py 0 → 100644 +105 −0 Original line number Diff line number Diff line """RFC 7807 problem responses for the errors FastAPI would otherwise answer in its own shape. Interface Contract §E: every SRM error response is RFC 7807. Refusals build their problem in the router; this covers request validation, which FastAPI answers as `{"detail": [...]}`. Route-not-found is deliberately left in FastAPI's own shape. OEG tells "no such subscriber" apart from "no such route" by content type alone (§E.2 fixes the subscriber 404's problem `type` at `about:blank`), so answering both with a problem document would erase the only signal it has. """ from fastapi import FastAPI, Request, status from fastapi.exception_handlers import http_exception_handler as fastapi_http_exception_handler from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, Response from starlette.exceptions import HTTPException as StarletteHTTPException from srm.api.rest.schemas import ProblemDetail PROBLEM_BASE = "https://etsi.org/sdg/oop/problems" PROBLEM_MEDIA_TYPE = "application/problem+json" INVALID_REQUEST = f"{PROBLEM_BASE}/invalid-request" _MAX_DETAIL = 512 def _title_for_status(status_code: int) -> str: return { status.HTTP_400_BAD_REQUEST: "Bad Request", status.HTTP_404_NOT_FOUND: "Not Found", status.HTTP_409_CONFLICT: "Conflict", status.HTTP_422_UNPROCESSABLE_CONTENT: "Unprocessable Entity", status.HTTP_503_SERVICE_UNAVAILABLE: "Service Unavailable", }.get(status_code, "HTTP Error") def problem_response( *, status_code: int, type_uri: str, title: str, detail: str | None = None, correlator: str | None = None, ) -> JSONResponse: response = JSONResponse( status_code=status_code, content=ProblemDetail( type=type_uri, title=title, status=status_code, detail=detail, ).model_dump(mode="json", exclude_none=True), media_type=PROBLEM_MEDIA_TYPE, ) if correlator is not None: response.headers["x-correlator"] = correlator return response def _detail(exc: RequestValidationError) -> str: # `loc` opens with "body"/"query"; the useful part is the canonical field path after it. parts = [ f"{'.'.join(str(segment) for segment in error['loc'][1:]) or 'body'}: {error['msg']}" for error in exc.errors() ] return "; ".join(parts)[:_MAX_DETAIL] def _is_unrouted_path(request: Request, exc: StarletteHTTPException) -> bool: return exc.status_code == status.HTTP_404_NOT_FOUND and request.scope.get("route") is None def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(StarletteHTTPException) async def handle_http_exception( request: Request, exc: StarletteHTTPException, ) -> Response: if _is_unrouted_path(request, exc): return await fastapi_http_exception_handler(request, exc) detail = exc.detail if isinstance(exc.detail, str) else None response = problem_response( status_code=exc.status_code, type_uri="about:blank", title=_title_for_status(exc.status_code), detail=detail, correlator=request.headers.get("x-correlator"), ) if exc.headers: response.headers.update(exc.headers) return response @app.exception_handler(RequestValidationError) async def handle_request_validation_error( request: Request, exc: RequestValidationError, ) -> JSONResponse: return problem_response( status_code=status.HTTP_400_BAD_REQUEST, type_uri=INVALID_REQUEST, title="Request failed validation.", detail=_detail(exc), correlator=request.headers.get("x-correlator"), ) src/srm/api/rest/router.py +120 −3 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ from uuid import UUID import structlog from fastapi import APIRouter, Header, HTTPException, Response, status from fastapi.responses import JSONResponse from srm.adapters.errors import DuplicateServiceSpecificationError, ServiceSpecificationInUseError from srm.api.dependencies import ( Loading @@ -10,19 +11,28 @@ from srm.api.dependencies import ( DeleteServiceSpecificationUseCaseDep, GetServiceSpecificationUseCaseDep, ListZonesUseCaseDep, LocationQueryUseCaseDep, ZoneProviderNameDep, ) from srm.api.errors import PROBLEM_BASE, problem_response from srm.api.rest.schemas import ( CreateServiceSpecificationRequest, CreateServiceSpecificationResponse, ErrorResponse, GetServiceSpecificationResponse, LocationQueryRequest, LocationQueryResponse, ProblemDetail, ServiceCapabilityRequirementResponseSchema, ServiceDeploymentUnitCreateSchema, ServiceSpecificationCreateSchema, ZoneDomainSummaryResponse, ZoneResponse, ) from srm.application.use_cases.location_query import ( LocationQueryCommand, LocationQueryFailure, LocationQueryOutcome, ) from srm.application.use_cases.topology import ListZonesCommand from srm.domain.models.canonical_parameters.parameters import ( CapabilityParameters, Loading @@ -39,6 +49,44 @@ from srm.domain.models.catalog import ( from srm.domain.models.topology import DomainKind, Zone, ZoneState internal = APIRouter(prefix="/internal", tags=["Internal"]) _PROBLEMS: dict[LocationQueryFailure, tuple[int, str, str]] = { LocationQueryFailure.SUBSCRIBER_NOT_FOUND: ( status.HTTP_404_NOT_FOUND, "about:blank", "Identifier matches no subscriber on this network.", ), LocationQueryFailure.UNABLE_TO_LOCATE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unable-to-locate", "The network could not locate the device.", ), LocationQueryFailure.UNABLE_TO_FULFILL_MAX_AGE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unable-to-fulfill-max-age", "No location fix as fresh as max_age_seconds is available.", ), LocationQueryFailure.UNABLE_TO_FULFILL_MAX_SURFACE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unable-to-fulfill-max-surface", "No location area as tight as max_surface_sqm is available.", ), LocationQueryFailure.UNSUPPORTED_IDENTIFIER: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unsupported-identifier", "No supplied device identifier is supported here.", ), LocationQueryFailure.SERVICE_NOT_APPLICABLE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/service-not-applicable", "The capability is not available for this target.", ), LocationQueryFailure.CAPABILITY_EXECUTION_FAILED: ( status.HTTP_503_SERVICE_UNAVAILABLE, f"{PROBLEM_BASE}/capability-execution-failed", "The control path could not execute the query.", ), } logger: structlog.BoundLogger = structlog.get_logger(__name__) Loading Loading @@ -173,7 +221,7 @@ def _build_zone_response(zone: Zone, provider_name: str) -> ZoneResponse: status_code=status.HTTP_201_CREATED, responses={ status.HTTP_409_CONFLICT: { "model": ErrorResponse, "model": ProblemDetail, "description": "Service specification already exists.", } }, Loading Loading @@ -210,7 +258,7 @@ async def create_service_specification( "/catalog/service-specifications/{id}", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "model": ProblemDetail, "description": "Service specification not found.", } }, Loading Loading @@ -288,3 +336,72 @@ async def delete_service_specification( ) logger.info("delete_service_specification_succeeded", service_specification_id=str(id)) def _problem(outcome: LocationQueryOutcome, correlator: str) -> JSONResponse: assert outcome.failure is not None status_code, type_uri, title = _PROBLEMS[outcome.failure] return problem_response( status_code=status_code, type_uri=type_uri, title=title, detail=outcome.detail, correlator=correlator, ) @internal.post( "/network-queries/location", response_model=None, responses={ status.HTTP_200_OK: {"model": LocationQueryResponse}, status.HTTP_404_NOT_FOUND: {"model": ProblemDetail}, status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ProblemDetail}, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ProblemDetail}, }, ) async def query_device_location( request: LocationQueryRequest, use_case: LocationQueryUseCaseDep, response: Response, x_correlator: Annotated[str | None, Header()] = None, ) -> LocationQueryResponse | JSONResponse: correlator = x_correlator or request.correlation_id logger.info( "location_query_requested", correlation_id=request.correlation_id, app_provider_id=request.app_provider_id, ) outcome = await use_case.execute( LocationQueryCommand( correlation_id=request.correlation_id, app_provider_id=request.app_provider_id, service_specification_id=request.service_specification_id, target=request.target, parameters=request.parameters, zone_id=request.zone_id, domain_id=request.domain_id, source_spec=request.source_spec, ) ) if outcome.failure is not None: logger.info( "location_query_refused", correlation_id=request.correlation_id, failure=outcome.failure.value, ) return _problem(outcome, correlator) assert outcome.result is not None response.headers["x-correlator"] = correlator return LocationQueryResponse( last_location_time=outcome.result.last_location_time, area=outcome.result.area.model_dump(mode="json"), srm_resolved_identifier=( outcome.srm_resolved_identifier.value if outcome.srm_resolved_identifier is not None else None ), ) Loading
src/srm/adapters/transformation_functions/registry.py 0 → 100644 +46 −0 Original line number Diff line number Diff line from collections.abc import Mapping from srm.config import ControlPathSettings from srm.domain.ports.transformation_functions import ( ControlPathRegistry, TransformationFunctionAdapterFactory, TransformationFunctionExecutionPort, UnknownControlPathRef, ) class NoAdapterFactoryInstalled(RuntimeError): pass class ConfiguredControlPathRegistry(ControlPathRegistry): def __init__(self, adapters: Mapping[str, TransformationFunctionExecutionPort]) -> None: self._adapters = dict(adapters) def get_adapter(self, control_path_ref: str) -> TransformationFunctionExecutionPort: try: return self._adapters[control_path_ref] except KeyError as exc: raise UnknownControlPathRef(control_path_ref) from exc def control_path_refs(self) -> list[str]: return sorted(self._adapters) def build_control_path_registry( settings: ControlPathSettings, factory: TransformationFunctionAdapterFactory | None, ) -> ConfiguredControlPathRegistry: if not settings.entries: return ConfiguredControlPathRegistry({}) if factory is None: raise NoAdapterFactoryInstalled( "control paths are configured but no TF-SDK adapter factory is installed: " + ", ".join(entry.control_path_ref for entry in settings.entries) ) return ConfiguredControlPathRegistry( { entry.control_path_ref: factory.create_adapter(entry.adapter, entry.config) for entry in settings.entries } )
src/srm/api/databus/schemas.py +3 −0 Original line number Diff line number Diff line Loading @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from srm.domain.models.canonical_parameters.parameters import ( CapabilityParameters, CapabilityTarget, SourceSpecification, ) Loading Loading @@ -96,6 +97,7 @@ class NetworkCapabilityPayloadV1(CommandSchema): target: CapabilityTarget profile_ref: str | None = None parameters: CapabilityParameters source_spec: SourceSpecification | None = None class SrmNetworkCapabilityActivateV1(CommandPayloadV1): Loading Loading @@ -136,6 +138,7 @@ class NetworkCapabilityUpdatePayloadV1(NetworkCapabilityRealizationRefV1): target: CapabilityTarget | None = None profile_ref: str | None = None parameters: CapabilityParameters source_spec: SourceSpecification | None = None class SrmNetworkCapabilityUpdateV1(CommandPayloadV1): Loading
src/srm/api/dependencies.py +32 −2 Original line number Diff line number Diff line Loading @@ -3,14 +3,22 @@ from typing import Annotated, AsyncGenerator, cast from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.adapters.database.repos.topology import SqlZoneRepository from srm.adapters.database.repos.catalog import ( SqlServiceCapabilityRequirementRepository, SqlServiceSpecificationRepository, ) from srm.adapters.database.repos.topology import SqlDomainRepository, SqlZoneRepository from srm.app_state import AppState from srm.application.services.capability_placement import CapabilityPlacementPlanner from srm.application.services.device_targeted_control_path import ( DeviceTargetedControlPathResolver, ) from srm.application.use_cases.catalog import ( CreateServiceSpecificationUseCase, DeleteServiceSpecificationUseCase, GetServiceSpecificationUseCase, ) from srm.application.use_cases.location_query import LocationQueryUseCase from srm.application.use_cases.topology import ListZonesUseCase from srm.config import get_settings Loading Loading @@ -75,3 +83,25 @@ ListZonesUseCaseDep = Annotated[ Depends(get_list_zones_use_case), ] ZoneProviderNameDep = Annotated[str, Depends(get_zone_provider_name)] def get_location_query_use_case( request: Request, session: SessionDep, ) -> LocationQueryUseCase: registry = get_app_state(request).control_path_registry return LocationQueryUseCase( SqlServiceSpecificationRepository(session), SqlServiceCapabilityRequirementRepository(session), DeviceTargetedControlPathResolver( CapabilityPlacementPlanner(SqlZoneRepository(session), SqlDomainRepository(session)), registry, ), registry, ) LocationQueryUseCaseDep = Annotated[ LocationQueryUseCase, Depends(get_location_query_use_case), ]
src/srm/api/errors.py 0 → 100644 +105 −0 Original line number Diff line number Diff line """RFC 7807 problem responses for the errors FastAPI would otherwise answer in its own shape. Interface Contract §E: every SRM error response is RFC 7807. Refusals build their problem in the router; this covers request validation, which FastAPI answers as `{"detail": [...]}`. Route-not-found is deliberately left in FastAPI's own shape. OEG tells "no such subscriber" apart from "no such route" by content type alone (§E.2 fixes the subscriber 404's problem `type` at `about:blank`), so answering both with a problem document would erase the only signal it has. """ from fastapi import FastAPI, Request, status from fastapi.exception_handlers import http_exception_handler as fastapi_http_exception_handler from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, Response from starlette.exceptions import HTTPException as StarletteHTTPException from srm.api.rest.schemas import ProblemDetail PROBLEM_BASE = "https://etsi.org/sdg/oop/problems" PROBLEM_MEDIA_TYPE = "application/problem+json" INVALID_REQUEST = f"{PROBLEM_BASE}/invalid-request" _MAX_DETAIL = 512 def _title_for_status(status_code: int) -> str: return { status.HTTP_400_BAD_REQUEST: "Bad Request", status.HTTP_404_NOT_FOUND: "Not Found", status.HTTP_409_CONFLICT: "Conflict", status.HTTP_422_UNPROCESSABLE_CONTENT: "Unprocessable Entity", status.HTTP_503_SERVICE_UNAVAILABLE: "Service Unavailable", }.get(status_code, "HTTP Error") def problem_response( *, status_code: int, type_uri: str, title: str, detail: str | None = None, correlator: str | None = None, ) -> JSONResponse: response = JSONResponse( status_code=status_code, content=ProblemDetail( type=type_uri, title=title, status=status_code, detail=detail, ).model_dump(mode="json", exclude_none=True), media_type=PROBLEM_MEDIA_TYPE, ) if correlator is not None: response.headers["x-correlator"] = correlator return response def _detail(exc: RequestValidationError) -> str: # `loc` opens with "body"/"query"; the useful part is the canonical field path after it. parts = [ f"{'.'.join(str(segment) for segment in error['loc'][1:]) or 'body'}: {error['msg']}" for error in exc.errors() ] return "; ".join(parts)[:_MAX_DETAIL] def _is_unrouted_path(request: Request, exc: StarletteHTTPException) -> bool: return exc.status_code == status.HTTP_404_NOT_FOUND and request.scope.get("route") is None def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(StarletteHTTPException) async def handle_http_exception( request: Request, exc: StarletteHTTPException, ) -> Response: if _is_unrouted_path(request, exc): return await fastapi_http_exception_handler(request, exc) detail = exc.detail if isinstance(exc.detail, str) else None response = problem_response( status_code=exc.status_code, type_uri="about:blank", title=_title_for_status(exc.status_code), detail=detail, correlator=request.headers.get("x-correlator"), ) if exc.headers: response.headers.update(exc.headers) return response @app.exception_handler(RequestValidationError) async def handle_request_validation_error( request: Request, exc: RequestValidationError, ) -> JSONResponse: return problem_response( status_code=status.HTTP_400_BAD_REQUEST, type_uri=INVALID_REQUEST, title="Request failed validation.", detail=_detail(exc), correlator=request.headers.get("x-correlator"), )
src/srm/api/rest/router.py +120 −3 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ from uuid import UUID import structlog from fastapi import APIRouter, Header, HTTPException, Response, status from fastapi.responses import JSONResponse from srm.adapters.errors import DuplicateServiceSpecificationError, ServiceSpecificationInUseError from srm.api.dependencies import ( Loading @@ -10,19 +11,28 @@ from srm.api.dependencies import ( DeleteServiceSpecificationUseCaseDep, GetServiceSpecificationUseCaseDep, ListZonesUseCaseDep, LocationQueryUseCaseDep, ZoneProviderNameDep, ) from srm.api.errors import PROBLEM_BASE, problem_response from srm.api.rest.schemas import ( CreateServiceSpecificationRequest, CreateServiceSpecificationResponse, ErrorResponse, GetServiceSpecificationResponse, LocationQueryRequest, LocationQueryResponse, ProblemDetail, ServiceCapabilityRequirementResponseSchema, ServiceDeploymentUnitCreateSchema, ServiceSpecificationCreateSchema, ZoneDomainSummaryResponse, ZoneResponse, ) from srm.application.use_cases.location_query import ( LocationQueryCommand, LocationQueryFailure, LocationQueryOutcome, ) from srm.application.use_cases.topology import ListZonesCommand from srm.domain.models.canonical_parameters.parameters import ( CapabilityParameters, Loading @@ -39,6 +49,44 @@ from srm.domain.models.catalog import ( from srm.domain.models.topology import DomainKind, Zone, ZoneState internal = APIRouter(prefix="/internal", tags=["Internal"]) _PROBLEMS: dict[LocationQueryFailure, tuple[int, str, str]] = { LocationQueryFailure.SUBSCRIBER_NOT_FOUND: ( status.HTTP_404_NOT_FOUND, "about:blank", "Identifier matches no subscriber on this network.", ), LocationQueryFailure.UNABLE_TO_LOCATE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unable-to-locate", "The network could not locate the device.", ), LocationQueryFailure.UNABLE_TO_FULFILL_MAX_AGE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unable-to-fulfill-max-age", "No location fix as fresh as max_age_seconds is available.", ), LocationQueryFailure.UNABLE_TO_FULFILL_MAX_SURFACE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unable-to-fulfill-max-surface", "No location area as tight as max_surface_sqm is available.", ), LocationQueryFailure.UNSUPPORTED_IDENTIFIER: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/unsupported-identifier", "No supplied device identifier is supported here.", ), LocationQueryFailure.SERVICE_NOT_APPLICABLE: ( status.HTTP_422_UNPROCESSABLE_CONTENT, f"{PROBLEM_BASE}/service-not-applicable", "The capability is not available for this target.", ), LocationQueryFailure.CAPABILITY_EXECUTION_FAILED: ( status.HTTP_503_SERVICE_UNAVAILABLE, f"{PROBLEM_BASE}/capability-execution-failed", "The control path could not execute the query.", ), } logger: structlog.BoundLogger = structlog.get_logger(__name__) Loading Loading @@ -173,7 +221,7 @@ def _build_zone_response(zone: Zone, provider_name: str) -> ZoneResponse: status_code=status.HTTP_201_CREATED, responses={ status.HTTP_409_CONFLICT: { "model": ErrorResponse, "model": ProblemDetail, "description": "Service specification already exists.", } }, Loading Loading @@ -210,7 +258,7 @@ async def create_service_specification( "/catalog/service-specifications/{id}", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "model": ProblemDetail, "description": "Service specification not found.", } }, Loading Loading @@ -288,3 +336,72 @@ async def delete_service_specification( ) logger.info("delete_service_specification_succeeded", service_specification_id=str(id)) def _problem(outcome: LocationQueryOutcome, correlator: str) -> JSONResponse: assert outcome.failure is not None status_code, type_uri, title = _PROBLEMS[outcome.failure] return problem_response( status_code=status_code, type_uri=type_uri, title=title, detail=outcome.detail, correlator=correlator, ) @internal.post( "/network-queries/location", response_model=None, responses={ status.HTTP_200_OK: {"model": LocationQueryResponse}, status.HTTP_404_NOT_FOUND: {"model": ProblemDetail}, status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ProblemDetail}, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ProblemDetail}, }, ) async def query_device_location( request: LocationQueryRequest, use_case: LocationQueryUseCaseDep, response: Response, x_correlator: Annotated[str | None, Header()] = None, ) -> LocationQueryResponse | JSONResponse: correlator = x_correlator or request.correlation_id logger.info( "location_query_requested", correlation_id=request.correlation_id, app_provider_id=request.app_provider_id, ) outcome = await use_case.execute( LocationQueryCommand( correlation_id=request.correlation_id, app_provider_id=request.app_provider_id, service_specification_id=request.service_specification_id, target=request.target, parameters=request.parameters, zone_id=request.zone_id, domain_id=request.domain_id, source_spec=request.source_spec, ) ) if outcome.failure is not None: logger.info( "location_query_refused", correlation_id=request.correlation_id, failure=outcome.failure.value, ) return _problem(outcome, correlator) assert outcome.result is not None response.headers["x-correlator"] = correlator return LocationQueryResponse( last_location_time=outcome.result.last_location_time, area=outcome.result.area.model_dump(mode="json"), srm_resolved_identifier=( outcome.srm_resolved_identifier.value if outcome.srm_resolved_identifier is not None else None ), )