Commit 0cd9cee3 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

Merge branch 'feat/location-retrieval' into 'develop'

feat: implement location retrieval api

See merge request !25
parents f803ad2b 73874c2b
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -24,3 +24,4 @@

# CALLBACK_SETTINGS__TIMEOUT=10.0
# QOD_SETTINGS__SERVICE_SPECIFICATION_ID="7608e902-b927-559f-b448-e7e9061dfa5c"
# LOCATION_RETRIEVAL_SETTINGS__SERVICE_SPECIFICATION_ID="0129e7ce-8e02-5dfd-bae1-0303bcc7676e"
+102 −1
Original line number Diff line number Diff line
@@ -3,11 +3,14 @@ from uuid import UUID

import httpx
import structlog
from pydantic import ValidationError

from open_exposure_gateway.core.config import get_settings
from open_exposure_gateway.core.exceptions import (
    DownstreamServiceException,
    ErrorCode,
    NotFoundException,
    UnprocessableEntityException,
)
from open_exposure_gateway.domain.edge_application_management import (
    SRMCatalogPayload,
@@ -15,10 +18,56 @@ from open_exposure_gateway.domain.edge_application_management import (
    SRMServiceInstance,
    SRMZone,
)
from open_exposure_gateway.domain.location_retrieval import (
    SRMLocationQuery,
    SRMLocationResult,
)
from open_exposure_gateway.domain.quality_on_demand import SRMNetworkCapability

logger = structlog.get_logger(__name__)

# SRM's RFC 7807 problem `type` -> the CAMARA code and the message the vendored spec pairs
# with it (srm/interface-contract.md §E.2).
# The message is fixed here rather than passed through from `detail` because this body crosses
# the trust boundary to the app provider; `detail` is logged instead.
_PROBLEM_NS = "https://etsi.org/sdg/oop/problems/"
_QUERY_PROBLEMS: dict[str, tuple[ErrorCode, str]] = {
    f"{_PROBLEM_NS}unable-to-locate": (
        ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE,
        "The network is unable to locate the device",
    ),
    f"{_PROBLEM_NS}unable-to-fulfill-max-age": (
        ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE,
        "Unable to provide expected freshness for location",
    ),
    f"{_PROBLEM_NS}unable-to-fulfill-max-surface": (
        ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE,
        "Unable to provide accurate acceptable surface for location",
    ),
    f"{_PROBLEM_NS}unsupported-identifier": (
        ErrorCode.UNSUPPORTED_IDENTIFIER,
        "The identifier provided is not supported.",
    ),
    f"{_PROBLEM_NS}service-not-applicable": (
        ErrorCode.SERVICE_NOT_APPLICABLE,
        "The service is not available for the provided identifier.",
    ),
}

_UNKNOWN_QUERY_PROBLEM = _QUERY_PROBLEMS[f"{_PROBLEM_NS}service-not-applicable"]

_PROBLEM_MEDIA_TYPE = "application/problem+json"


def _problem_document(response: httpx.Response) -> dict[str, Any] | None:
    if _PROBLEM_MEDIA_TYPE not in response.headers.get("content-type", ""):
        return None
    try:
        body = response.json()
    except ValueError:  # JSONDecodeError -- truncated or mislabelled body
        return None
    return body if isinstance(body, dict) else None


class SRMClient:
    def __init__(self) -> None:
@@ -33,6 +82,7 @@ class SRMClient:
        json: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
        problems: dict[str, tuple[ErrorCode, str]] | None = None,
    ) -> Any:
        url = f"{self.base_url}{path}"
        log = logger.bind(
@@ -51,10 +101,26 @@ class SRMClient:
                    headers=headers,
                )

            if response.status_code == 404:
            # Only the query calls translate SRM problems into CAMARA codes; every other call
            # keeps the plain 404 handling it has always had.
            problem = _problem_document(response) if problems is not None else None

            if response.status_code == 404 and (problems is None or problem is not None):
                log.warning("SRM resource not found", url=url)
                raise NotFoundException(message="Resource not found")

            if response.status_code == 422 and problems is not None and problem is not None:
                problem_type = problem.get("type")
                key = problem_type if isinstance(problem_type, str) else ""
                error_code, message = problems.get(key, _UNKNOWN_QUERY_PROBLEM)
                log.warning(
                    "SRM could not fulfil query",
                    problem_type=problem_type,
                    mapped_code=error_code,
                    detail=problem.get("detail"),
                )
                raise UnprocessableEntityException(error_code=error_code, message=message)

            if response.status_code >= 400:
                log.error("SRM returned error", status=response.status_code, body=response.text)
                raise DownstreamServiceException(
@@ -181,3 +247,38 @@ class SRMClient:
            "GET", "/internal/service-instances", params=params or None, headers=headers
        )
        return [SRMServiceInstance.model_validate(i) for i in data]

    async def retrieve_location(
        self,
        query: SRMLocationQuery,
        x_correlator: str | None = None,
    ) -> SRMLocationResult:
        headers = {"x-correlator": x_correlator} if x_correlator else None
        try:
            data = await self._request(
                "POST",
                "/internal/network-queries/location",
                json=query.model_dump(mode="json", exclude_none=True),
                headers=headers,
                problems=_QUERY_PROBLEMS,
            )
        except NotFoundException as exc:
            raise NotFoundException(
                message="Device identifier not found.",
                error_code=ErrorCode.IDENTIFIER_NOT_FOUND,
            ) from exc

        try:
            return SRMLocationResult.model_validate(data)
        except ValidationError as exc:
            logger.warning(
                "SRM returned an unusable location",
                x_correlator=x_correlator,
                errors=[
                    {"loc": ".".join(str(part) for part in error["loc"]), "msg": error["msg"]}
                    for error in exc.errors()
                ],
            )
            raise DownstreamServiceException(
                message="SRM returned an unusable location",
            ) from exc
+14 −0
Original line number Diff line number Diff line
import re
from ipaddress import IPv4Address, IPv6Address
from typing import Annotated, Optional

from fastapi import Header
@@ -15,3 +16,16 @@ IdempotencyKeyHeader = Annotated[
    Optional[str],
    Header(alias="Idempotency-Key", max_length=128),
]


def validated_ipv4(value: Optional[str]) -> Optional[str]:
    if value is not None:
        IPv4Address(value)
    return value


def validated_ipv6(value: Optional[str]) -> Optional[str]:
    """CAMARA's `DeviceIpv6Address` is `format: ipv6`; same reasoning as `validated_ipv4`."""
    if value is not None:
        IPv6Address(value)
    return value
+682 −0

File added.

Preview size limit exceeded, changes collapsed.

+79 −0
Original line number Diff line number Diff line
from typing import Annotated, Any

from fastapi import APIRouter, Depends

from open_exposure_gateway.api.camara.common import XCorrelatorHeader
from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.schemas import (
    Location,
    RetrievalLocationRequest,
)
from open_exposure_gateway.application.services.location_retrieval_service import (
    LocationRetrievalService,
)
from open_exposure_gateway.core.exceptions import (
    BadRequestException,
    DownstreamServiceException,
    ErrorCode,
    ForbiddenException,
    NotFoundException,
    UnauthorizedException,
    UnprocessableEntityException,
)
from open_exposure_gateway.dependencies import (
    CallerContext,
    get_caller_context,
    get_location_retrieval_service,
)
from open_exposure_gateway.schemas.common import ErrorInfo

# CAMARA base path: the spec serves at {apiRoot}/location-retrieval/v0.5 (wire version of
# v0.5.0), mounted bare per ADR-0020.
BASE_PATH = "/location-retrieval/v0.5"

router = APIRouter(prefix=BASE_PATH)

LocationService = Annotated[LocationRetrievalService, Depends(get_location_retrieval_service)]
Caller = Annotated[CallerContext, Depends(get_caller_context)]

_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
    exc_cls().status_code: {"model": ErrorInfo, "description": exc_cls().message}
    for exc_cls in (
        BadRequestException,
        UnauthorizedException,
        ForbiddenException,
        NotFoundException,
        DownstreamServiceException,
    )
}
_ERROR_RESPONSES[422] = {
    "model": ErrorInfo,
    "description": UnprocessableEntityException(error_code=ErrorCode.MISSING_IDENTIFIER).message,
}
_ERROR_RESPONSES[500] = {"model": ErrorInfo, "description": "Internal server error"}


def _responses(*codes: int) -> dict[int | str, dict[str, Any]]:
    return {code: _ERROR_RESPONSES[code] for code in codes}


@router.post(
    "/retrieve",
    tags=["Location retrieval"],
    summary="Execute location retrieval for a user device",
    description="Retrieve the area where a certain user device is localized.",
    operation_id="retrieveLocation",
    response_model=Location,
    response_model_exclude_none=True,
    responses=_responses(400, 401, 403, 404, 422, 500, 503),
)
async def retrieve_location(
    request: RetrievalLocationRequest,
    service: LocationService,
    caller: Caller,
    x_correlator: XCorrelatorHeader = None,
) -> Any:
    return await service.retrieve_location(
        request=request,
        app_provider_id=caller.app_provider_id,
        x_correlator=caller.x_correlator,
    )
Loading