Commit 57b8bf6a authored by Dimitrios Gogos's avatar Dimitrios Gogos Committed by Paris Stentoumis
Browse files

feat: implement Location retrieval capability

parent 726cfc23
Loading
Loading
Loading
Loading
Loading
+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
        }
    )
+3 −0
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from srm.domain.models.canonical_parameters.parameters import (
    CapabilityParameters,
    CapabilityTarget,
    SourceSpecification,
)


@@ -96,6 +97,7 @@ class NetworkCapabilityPayloadV1(CommandSchema):
    target: CapabilityTarget
    profile_ref: str | None = None
    parameters: CapabilityParameters
    source_spec: SourceSpecification | None = None


class SrmNetworkCapabilityActivateV1(CommandPayloadV1):
@@ -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):
+32 −2
Original line number Diff line number Diff line
@@ -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

@@ -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"),
        )
+120 −3
Original line number Diff line number Diff line
@@ -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 (
@@ -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,
@@ -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__)


@@ -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.",
        }
    },
@@ -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.",
        }
    },
@@ -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