From de4441482d0e1b55b5d93f0dcaa5a4b62a563db3 Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 12:46:06 +0300 Subject: [PATCH 1/9] feat: implement location retrieval API and related schemas --- .env.example | 1 + .../adapters/http/srm_client.py | 44 ++ .../API_definitions/location-retrieval.yaml | 682 ++++++++++++++++++ .../location_retrieval/v0_5_0/router.py | 79 ++ .../location_retrieval/v0_5_0/schemas.py | 87 +++ .../mappers/location_retrieval_mapper.py | 106 +++ .../services/location_retrieval_service.py | 78 ++ src/open_exposure_gateway/core/config.py | 16 + src/open_exposure_gateway/core/exceptions.py | 35 +- src/open_exposure_gateway/dependencies.py | 12 + .../domain/location_retrieval.py | 54 ++ src/open_exposure_gateway/main.py | 24 + src/open_exposure_gateway/ports/srm_port.py | 8 + tests/conformance/conftest.py | 7 + .../test_location_retrieval_conformance.py | 53 ++ tests/unit/conftest.py | 11 + tests/unit/fakes.py | 23 +- tests/unit/test_config.py | 30 + tests/unit/test_location_retrieval_flows.py | 304 ++++++++ tests/unit/test_location_retrieval_mapper.py | 260 +++++++ tests/unit/test_srm_client.py | 101 +++ tests/unit/test_vendored_spec_refs.py | 4 + 22 files changed, 2016 insertions(+), 3 deletions(-) create mode 100644 src/open_exposure_gateway/api/camara/location_retrieval/API_definitions/location-retrieval.yaml create mode 100644 src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py create mode 100644 src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py create mode 100644 src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py create mode 100644 src/open_exposure_gateway/application/services/location_retrieval_service.py create mode 100644 src/open_exposure_gateway/domain/location_retrieval.py create mode 100644 tests/conformance/test_location_retrieval_conformance.py create mode 100644 tests/unit/test_location_retrieval_flows.py create mode 100644 tests/unit/test_location_retrieval_mapper.py diff --git a/.env.example b/.env.example index 444f099..f85a966 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index ce9ccc1..8610e04 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -7,7 +7,9 @@ import structlog 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 +17,22 @@ 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__) +_QUERY_PROBLEM_CODES: dict[str, ErrorCode] = { + "unable_to_locate": ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE, + "unable_to_fulfill_max_age": ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE, + "unable_to_fulfill_max_surface": (ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE), + "unsupported_identifier": ErrorCode.UNSUPPORTED_IDENTIFIER, + "service_not_applicable": ErrorCode.SERVICE_NOT_APPLICABLE, +} + class SRMClient: def __init__(self) -> None: @@ -55,6 +69,16 @@ class SRMClient: log.warning("SRM resource not found", url=url) raise NotFoundException(message="Resource not found") + if response.status_code == 422: + problem = response.json() + code = problem.get("code") + error_code = _QUERY_PROBLEM_CODES.get(code, ErrorCode.SERVICE_NOT_APPLICABLE) + log.warning("SRM could not fulfil query", code=code, mapped_code=error_code) + raise UnprocessableEntityException( + error_code=error_code, + message=problem.get("detail") or "Unable to fulfil the request", + ) + if response.status_code >= 400: log.error("SRM returned error", status=response.status_code, body=response.text) raise DownstreamServiceException( @@ -181,3 +205,23 @@ 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", + "/network-queries/location", + json=query.model_dump(mode="json", exclude_none=True), + headers=headers, + ) + except NotFoundException as exc: + raise NotFoundException( + message="Device identifier not found.", + error_code=ErrorCode.IDENTIFIER_NOT_FOUND, + ) from exc + return SRMLocationResult.model_validate(data) diff --git a/src/open_exposure_gateway/api/camara/location_retrieval/API_definitions/location-retrieval.yaml b/src/open_exposure_gateway/api/camara/location_retrieval/API_definitions/location-retrieval.yaml new file mode 100644 index 0000000..a009959 --- /dev/null +++ b/src/open_exposure_gateway/api/camara/location_retrieval/API_definitions/location-retrieval.yaml @@ -0,0 +1,682 @@ +openapi: 3.0.3 +info: + title: Device Location Retrieval + description: | + This API provides the ability to retrieve a device location. + + # Introduction + + With this API, API consumers can retrieve the area where a certain user device is localized. + The area provided in the response could be described: + - by a circle determined by coordinates (latitude and longitude) and a radius. + - by a simple polygon delimited by segments connecting consecutively an array of coordinates (points). The last point connects to the first point to delimit a closed shape bounded with straight sides. + + The retrieved shape depends on the network conditions at the device's location and any of the supported shapes could be received. + + The requester could optionally ask for + * a freshness of the localization information by providing a `maxAge` ("I want a location not older than 600 seconds"). + * an accuracy of the localization information by providing a `maxSurface` ("I want a location not larger than 1000000 square meters"). + + + The result accuracy depends on the network's ability and accuracy to locate the device. + + Additionally to location information, the answer will also provide indication about the location time. + + Location retrieval API could be useful in scenarios such as: + + - Fraud protection to ensure a given user is located in the region, country or location authorized for financial transactions + - Verify the GPS coordinates reported by the app on a device to ensure the GPS was not faked e.g. for content delivery with regional restrictions + - Contextual-based advertising, to trigger advertising after verifying the device is in the area of interest + + - Smart Mobility (Vehicle/bikes renting): obtain the location of a vehicle/bike to guarantee they are rented correctly + + **Note**: Location is in most jurisdictions considered to be sensitive data and thereby consent by device owner/user must be verified before providing it to the developer. + + # Relevant terms and definitions + + * **Device**: A device refers to any physical entity that can connect to a network and participate in network communication. + + * **Area**: It specifies the geographical surface where a device may be physically located. + + * **Max Age**: Maximum age of the location information which is accepted for the location retrieval (in seconds). + + * Absence of `maxAge` means that "any age" is acceptable for the client. In other words, this is like `maxAge`=infinite. The system will return `lastLocationTime` in the response. If the system is not able to provide location, an error 422 with code LOCATION_RETRIEVAL.UNABLE_TO_LOCATE is sent back. + * `maxAge`=0 means that a fresh calculation is requested by the client. If the system is not able to provide the fresh location, an error 422 with code LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_AGE is sent back. + + * **Last Location Time** : Last date and time when the device was localized. + + * **Max Surface**: Maximum surface in square meters which is accepted by the client for the location retrieval. + + * absence of `maxSurface` means that "any surface size" is acceptable for the client. + * API implementation could specify the minimum acceptable `maxSurface` in the documentation (for example a minimum of 10000 square meters are allowed). + * If the system is not able to provide an area with a surface acceptable with the client request, an error 422 with code LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_SURFACE is sent back. + * Note: if both `maxAge` and `maxSurface` requirements fail, the system can either send back one or the other error code. + + # API Functionality + + The API exposes a single endpoint/operation: + + - `/retrieve` : Retrieve where the device is localized. + The operation returns: + * a localization defined either as a circle, with the center specified by the latitude and longitude, and a radius for answer accuracy, or as polygon defined by the array of points delimiting its boundary. + * a timestamp with the location information freshness. + + # Authorization and authentication + + The "Camara Security and Interoperability Profile" provides details of how an API consumer requests an access token. Please refer to Identity and Consent Management (https://github.com/camaraproject/IdentityAndConsentManagement/) for the released version of the profile. + + The specific authorization flows to be used will be agreed upon during the onboarding process, happening between the API consumer and the API provider, taking into account the declared purpose for accessing the API, whilst also being subject to the prevailing legal framework dictated by local legislation. + + In cases where personal data is processed by the API and users can exercise their rights through mechanisms such as opt-in and/or opt-out, the use of three-legged access tokens is mandatory. This ensures that the API remains in compliance with privacy regulations, upholding the principles of transparency and user-centric privacy-by-design. + + # Identifying the device from the access token + + This API requires the API consumer to identify a device as the subject of the API as follows: + - When the API is invoked using a two-legged access token, the subject will be identified from the optional `device` object, which therefore MUST be provided. + - When a three-legged access token is used however, this optional identifier MUST NOT be provided, as the subject will be uniquely identified from the access token. + + This approach simplifies API usage for API consumers using a three-legged access token to invoke the API by relying on the information that is associated with the access token and was identified during the authentication process. + + ## Error handling: + + - If the subject cannot be identified from the access token and the optional `device` object is not included in the request, then the server will return an error with the `422 MISSING_IDENTIFIER` error code. + + - If the subject can be identified from the access token and the optional `device` object is also included in the request, then the server will return an error with the `422 UNNECESSARY_IDENTIFIER` error code. This will be the case even if the same device is identified by these two methods, as the server is unable to make this comparison. + + # Multi-SIM scenario handling + + In multi-SIM scenarios, where more than one mobile device is associated with the phone number given as input in the API call (e.g. a smartphone with an associated smartwatch), it might not be possible to uniquely identify the device whose location is to be verified. Check with the API provider what is the expected behaviour when a phone number belonging to a multi-SIM group is used as the device identifier, as the API may response with: + + - an error indicating that that phone number is not supported for this API, or + - the location of a single device in the multi-SIM group, if one of the devices is considered linked to the main SIM and this concept is supported by the operator, or + - a location value that combines the location of all the SIMs associated to the requested phone number. + + Possible solutions to make the scenario more deterministic include: + + - Using preferably the authorisation code flow to obtain an access token, which will automatically identify the intended device. + - Identifying the intended device from a unique identifier for that device, such as its source IP address and port. + - Check with the API provider whether a unique "secondary" phone number is already associated with each device and use the secondary phone number to identify the intended device if available. + + # Additional CAMARA error responses + + The list of error codes in this API specification is not exhaustive. Therefore the API specification may not document some non-mandatory error statuses as indicated in `CAMARA API Design Guide`. + + Please refer to the `CAMARA_common.yaml` of the Commonalities Release associated to this API version for a complete list of error responses. The applicable Commonalities Release can be identified in the `API Readiness Checklist` document associated to this API version. + + As a specific rule, error `501 - NOT_IMPLEMENTED` can be only a possible error response if it is explicitly documented in the API. + + # Further info and support + + (FAQs will be added in a later version of the documentation) + version: 0.5.0 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + x-camara-commonalities: 0.6 +externalDocs: + description: Project documentation at Camara + url: https://github.com/camaraproject/DeviceLocation +servers: + - url: '{apiRoot}/location-retrieval/v0.5' + variables: + apiRoot: + default: http://localhost:9091 + description: API root +tags: + - name: Location retrieval + description: Retrieve the location of a device +paths: + /retrieve: + post: + tags: + - Location retrieval + summary: 'Execute location retrieval for a user device' + description: Retrieve the area where a certain user device is localized. + operationId: retrieveLocation + parameters: + - $ref: '#/components/parameters/x-correlator' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RetrievalLocationRequest' + examples: + INPUT_PHONE_NUMBER_MAX_AGE: + summary: Phone number and maxAge + description: Retrieve location for a device identified by a phone number, providing a maxAge + value: + device: + phoneNumber: "+123456789" + maxAge: 120 + INPUT_PHONE_NUMBER_MAX_AGE_AND_SURFACE: + summary: Phone number, maxAge and maxSurface + description: Retrieve location for a device identified by a phone number, providing a maxAge and maxSurface + value: + device: + phoneNumber: "+123456789" + maxAge: 120 + maxSurface: 1000000 + INPUT_IP_ADDRESS_V4: + summary: IPv4 address without maxAge + description: Retrieve location for a device identified by an IPv4 address, without an indication for maxAge + value: + device: + ipv4Address: + publicAddress: 123.234.1.2 + publicPort: 1234 + INPUT_NO_DEVICE_AND_MAX_AGE: + summary: Device not provided, only maxAge + description: The device has to be deducted from token + value: + maxAge: 120 + INPUT_PHONE_NUMBER_IP_ADDRESS_V4: + summary: Both phone number and IPv4 address, without maxAge + description: Retrieve location for a device identified both by a phone number and an IPv4 address, without an indication for maxAge + value: + device: + phoneNumber: "+123456789" + ipv4Address: + publicAddress: 123.234.1.2 + publicPort: 1234 + responses: + '200': + description: Location retrieval result + headers: + x-correlator: + $ref: '#/components/headers/x-correlator' + content: + application/json: + schema: + $ref: '#/components/schemas/Location' + examples: + LOCATION_CIRCLE: + $ref: "#/components/examples/RETRIEVAL_CIRCLE" + LOCATION_POLYGON: + $ref: "#/components/examples/RETRIEVAL_POLYGON" + LOCATION_CIRCLE_WITH_DEVICE: + $ref: "#/components/examples/LOCATION_CIRCLE_WITH_DEVICE" + '400': + $ref: '#/components/responses/Generic400' + '401': + $ref: '#/components/responses/Generic401' + '403': + $ref: '#/components/responses/Generic403' + '404': + $ref: '#/components/responses/RetrieveLocationNotFound404' + '422': + $ref: '#/components/responses/RetrieveLocationUnprocessableEntity422' + security: + - openId: + - location-retrieval:read + +components: + securitySchemes: + openId: + description: OpenID Connect authentication + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + parameters: + x-correlator: + name: x-correlator + in: header + description: Correlation id for the different services + schema: + $ref: "#/components/schemas/XCorrelator" + headers: + x-correlator: + description: Correlation id for the different services + schema: + $ref: "#/components/schemas/XCorrelator" + schemas: + XCorrelator: + type: string + pattern: ^[a-zA-Z0-9-_:;.\/<>{}]{0,256}$ + example: "b4333c46-49c0-4f62-80d7-f0ef930f1c46" + RetrievalLocationRequest: + description: Request to retrieve the location of a device. Device is not required when using a 3-legged access token, following the rules in the description. + type: object + properties: + device: + $ref: '#/components/schemas/Device' + maxAge: + type: integer + description: Maximum age of the location information which is accepted for the location retrieval (in seconds). Absence of maxAge means "any age" and maxAge=0 means a fresh calculation. + maxSurface: + type: integer + minimum: 1 + description: Maximum surface in square meters which is accepted by the client for the location retrieval. Absence of maxSurface means "any surface size". + example: 1000000 + Device: + description: | + End-user device able to connect to a mobile network. Examples of devices include smartphones or IoT sensors/actuators. + + The developer can choose to provide the below specified device identifiers: + + * `ipv4Address` + * `ipv6Address` + * `phoneNumber` + * `networkAccessIdentifier` + NOTE1: the network operator might support only a subset of these options. The API invoker can provide multiple identifiers to be compatible across different network operators. In this case the identifiers MUST belong to the same device. Where more than one device identifier is provided, only one identifier will be selected by the implementation and this choice indicated to the API consumer in the response. + NOTE2: as for this Commonalities release, we are enforcing that the networkAccessIdentifier is only part of the schema for future-proofing, and CAMARA does not currently allow its use. After the CAMARA meta-release work is concluded and the relevant issues are resolved, its use will need to be explicitly documented in the guidelines. + type: object + properties: + phoneNumber: + $ref: "#/components/schemas/PhoneNumber" + networkAccessIdentifier: + $ref: "#/components/schemas/NetworkAccessIdentifier" + ipv4Address: + $ref: "#/components/schemas/DeviceIpv4Addr" + ipv6Address: + $ref: "#/components/schemas/DeviceIpv6Address" + minProperties: 1 + + PhoneNumber: + description: A public identifier addressing a telephone subscription. In mobile networks it corresponds to the MSISDN (Mobile Station International Subscriber Directory Number). In order to be globally unique it has to be formatted in international format, according to E.164 standard, prefixed with '+'. + type: string + pattern: '^\+[1-9][0-9]{4,14}$' + example: "+123456789" + + NetworkAccessIdentifier: + description: A public identifier addressing a subscription in a mobile network. In 3GPP terminology, it corresponds to the GPSI formatted with the External Identifier ({Local Identifier}@{Domain Identifier}). Unlike the telephone number, the network access identifier is not subjected to portability ruling in force, and is individually managed by each operator. + type: string + example: "123456789@domain.com" + + DeviceIpv4Addr: + type: object + description: | + The device should be identified by either the public (observed) IP address and port as seen by the application server, or the private (local) and any public (observed) IP addresses in use by the device (this information can be obtained by various means, for example from some DNS servers). + + If the allocated and observed IP addresses are the same (i.e. NAT is not in use) then the same address should be specified for both publicAddress and privateAddress. + + If NAT64 is in use, the device should be identified by its publicAddress and publicPort, or separately by its allocated IPv6 address (field ipv6Address of the Device object) + + In all cases, publicAddress must be specified, along with at least one of either privateAddress or publicPort, dependent upon which is known. In general, mobile devices cannot be identified by their public IPv4 address alone. + properties: + publicAddress: + $ref: "#/components/schemas/SingleIpv4Addr" + privateAddress: + $ref: "#/components/schemas/SingleIpv4Addr" + publicPort: + $ref: "#/components/schemas/Port" + anyOf: + - required: [publicAddress, privateAddress] + - required: [publicAddress, publicPort] + example: + publicAddress: "84.125.93.10" + publicPort: 59765 + + SingleIpv4Addr: + description: A single IPv4 address with no subnet mask + type: string + format: ipv4 + example: "84.125.93.10" + + Port: + description: TCP or UDP port number + type: integer + minimum: 0 + maximum: 65535 + + DeviceIpv6Address: + description: | + The device should be identified by the observed IPv6 address, or by any single IPv6 address from within the subnet allocated to the device (e.g. adding ::0 to the /64 prefix). + type: string + format: ipv6 + example: 2001:db8:85a3:8d3:1319:8a2e:370:7344 + + DeviceResponse: + description: | + An identifier for the end-user equipment able to connect to the network that the response refers to. This parameter is only returned when the API consumer includes the `device` parameter in their request (i.e. they are using a two-legged access token), and is relevant when more than one device identifier is specified, as only one of those device identifiers is allowed in the response. + If the API consumer provides more than one device identifier in their request, the API provider must return a single identifier which is the one they are using to fulfil the request, even if the identifiers do not match the same device. API provider does not perform any logic to validate/correlate that the indicated device identifiers match the same device. No error should be returned if the identifiers are otherwise valid to prevent API consumers correlating different identifiers with a given end user. + allOf: + - $ref: "#/components/schemas/Device" + - maxProperties: 1 + + Location: + type: object + description: Device location + required: + - lastLocationTime + - area + properties: + lastLocationTime: + $ref: "#/components/schemas/LastLocationTime" + area: + $ref: '#/components/schemas/Area' + device: + $ref: "#/components/schemas/DeviceResponse" + + Area: + description: Base schema for all areas + type: object + properties: + areaType: + $ref: "#/components/schemas/AreaType" + required: + - areaType + discriminator: + propertyName: areaType + mapping: + CIRCLE: "#/components/schemas/Circle" + POLYGON: "#/components/schemas/Polygon" + + AreaType: + type: string + description: | + Type of this area. + CIRCLE - The area is defined as a circle. + POLYGON - The area is defined as a polygon. + enum: + - CIRCLE + - POLYGON + + Circle: + description: Circular area + allOf: + - $ref: "#/components/schemas/Area" + - type: object + required: + - center + - radius + properties: + center: + $ref: "#/components/schemas/Point" + radius: + type: number + description: Distance from the center in meters + minimum: 1 + + Polygon: + description: Polygonal area + allOf: + - $ref: "#/components/schemas/Area" + - type: object + required: + - boundary + properties: + boundary: + $ref: "#/components/schemas/PointList" + + PointList: + description: List of points defining a polygon + type: array + items: + $ref: "#/components/schemas/Point" + minItems: 3 + maxItems: 15 + + Point: + type: object + description: Coordinates (latitude, longitude) defining a location in a map + required: + - latitude + - longitude + properties: + latitude: + $ref: "#/components/schemas/Latitude" + longitude: + $ref: "#/components/schemas/Longitude" + example: + latitude: 50.735851 + longitude: 7.10066 + + LastLocationTime: + description: Last date and time when the device was localized. It must follow [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6) and must have time zone. + format: date-time + type: string + example: "2023-09-07T10:40:52Z" + + Latitude: + description: Latitude component of a location + type: number + format: double + minimum: -90 + maximum: 90 + + Longitude: + description: Longitude component of location + type: number + format: double + minimum: -180 + maximum: 180 + + ErrorInfo: + description: Common schema for errors + type: object + required: + - status + - code + - message + properties: + status: + type: integer + description: HTTP response status code + code: + type: string + description: A human-readable code to describe the error + message: + type: string + description: A human-readable description of what the event represents + + responses: + Generic400: + description: Bad Request + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 400 + code: + enum: + - INVALID_ARGUMENT + examples: + GENERIC_400_INVALID_ARGUMENT: + summary: Invalid argument + description: Invalid Argument. Generic Syntax Exception + value: + status: 400 + code: INVALID_ARGUMENT + message: Client specified an invalid argument, request body or query param. + Generic401: + description: Unauthorized + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 401 + code: + enum: + - UNAUTHENTICATED + examples: + GENERIC_401_UNAUTHENTICATED: + description: Request cannot be authenticated and a new authentication is required + value: + status: 401 + code: UNAUTHENTICATED + message: Request not authenticated due to missing, invalid, or expired credentials. A new authentication is required. + Generic403: + description: Forbidden + headers: + x-correlator: + $ref: "#/components/headers/x-correlator" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 403 + code: + enum: + - PERMISSION_DENIED + examples: + GENERIC_403_PERMISSION_DENIED: + summary: Permission denied + description: Permission denied. OAuth2 token access does not have the required scope or when the user fails operational security + value: + status: 403 + code: PERMISSION_DENIED + message: Client does not have sufficient permissions to perform this action. + RetrieveLocationNotFound404: + description: Not found + headers: + x-correlator: + $ref: '#/components/headers/x-correlator' + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 404 + code: + enum: + - IDENTIFIER_NOT_FOUND + examples: + GENERIC_404_IDENTIFIER_NOT_FOUND: + summary: Identifier not found + description: Some identifier cannot be matched to a device + value: + status: 404 + code: IDENTIFIER_NOT_FOUND + message: Device identifier not found. + RetrieveLocationUnprocessableEntity422: + description: Unprocessable Content + headers: + x-correlator: + $ref: '#/components/headers/x-correlator' + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorInfo" + - type: object + properties: + status: + enum: + - 422 + code: + enum: + - SERVICE_NOT_APPLICABLE + - MISSING_IDENTIFIER + - UNSUPPORTED_IDENTIFIER + - UNNECESSARY_IDENTIFIER + - LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_AGE + - LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_SURFACE + - LOCATION_RETRIEVAL.UNABLE_TO_LOCATE + examples: + GENERIC_422_SERVICE_NOT_APPLICABLE: + summary: Service not applicable + description: Service not applicable for the provided identifier + value: + status: 422 + code: SERVICE_NOT_APPLICABLE + message: The service is not available for the provided identifier. + GENERIC_422_MISSING_IDENTIFIER: + summary: Missing identifier + description: An identifier is not included in the request and the device or phone number identification cannot be derived from the 3-legged access token + value: + status: 422 + code: MISSING_IDENTIFIER + message: The device cannot be identified. + GENERIC_422_UNSUPPORTED_IDENTIFIER: + summary: Unsupported identifier + description: None of the provided identifiers is supported by the implementation + value: + status: 422 + code: UNSUPPORTED_IDENTIFIER + message: The identifier provided is not supported. + GENERIC_422_UNNECESSARY_IDENTIFIER: + summary: Unnecessary identifier + description: An explicit identifier is provided when a device or phone number has already been identified from the access token + value: + status: 422 + code: UNNECESSARY_IDENTIFIER + message: The device is already identified by the access token. + LOCATION_RETRIEVAL_422_UNABLE_TO_FULFILL_MAX_AGE: + summary: Unable to fulfill maxAge + description: The system is not able to provide the fresh location required by the client + value: + status: 422 + code: LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_AGE + message: "Unable to provide expected freshness for location" + LOCATION_RETRIEVAL_422_UNABLE_TO_FULFILL_MAX_SURFACE: + summary: Unable to fulfill maxSurface + description: The system is not able to provide accurate acceptable surface required by the client + value: + status: 422 + code: LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_SURFACE + message: "Unable to provide accurate acceptable surface for location" + LOCATION_RETRIEVAL_422_UNABLE_TO_LOCATE: + summary: Unable to locate device + description: The network cannot locate the device + value: + status: 422 + code: LOCATION_RETRIEVAL.UNABLE_TO_LOCATE + message: "The network is unable to locate the device" + examples: + RETRIEVAL_CIRCLE: + summary: circle-based device location retrieval + description: The device is localized within a circle with a center at the specified coordinates and a radius of 800 meters. + value: + lastLocationTime: "2023-10-17T13:18:23.682Z" + area: + areaType: CIRCLE + center: + latitude: 45.754114 + longitude: 4.860374 + radius: 800 + RETRIEVAL_POLYGON: + summary: polygon-based device location retrieval + description: The device is localized within a polygon delimited by the provided coordinates. + value: + lastLocationTime: "2023-10-17T13:18:23.682Z" + area: + areaType: POLYGON + boundary: + - latitude: 45.754114 + longitude: 4.860374 + - latitude: 45.753845 + longitude: 4.863185 + - latitude: 45.752490 + longitude: 4.861876 + - latitude: 45.751224 + longitude: 4.861125 + - latitude: 45.751442 + longitude: 4.859827 + LOCATION_CIRCLE_WITH_DEVICE: + summary: circle-based device location retrieval, returning the device identifier used by the implementation + description: The device is localized within a circle with a center at the specified coordinates and a radius of 800 meters. Response when the request used a 2-legged access token with multiple device identifiers, or possibly only a single device identifier. + value: + lastLocationTime: "2023-10-17T13:18:23.682Z" + area: + areaType: CIRCLE + center: + latitude: 45.754114 + longitude: 4.860374 + radius: 800 + device: + phoneNumber: "+123456789" diff --git a/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py new file mode 100644 index 0000000..e82eaa3 --- /dev/null +++ b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py @@ -0,0 +1,79 @@ +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=x_correlator, + ) diff --git a/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py new file mode 100644 index 0000000..2d510e2 --- /dev/null +++ b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py @@ -0,0 +1,87 @@ +from enum import StrEnum +from typing import Annotated, Literal, Optional, Union + +from pydantic import AwareDatetime, BaseModel, Field, model_validator + + +class AreaType(StrEnum): + CIRCLE = "CIRCLE" + POLYGON = "POLYGON" + + +class DeviceIpv4Addr(BaseModel): + publicAddress: Optional[str] = None + privateAddress: Optional[str] = None + publicPort: Optional[int] = Field(default=None, ge=0, le=65535) + + @model_validator(mode="after") + def _require_public_plus_one(self) -> "DeviceIpv4Addr": + if self.publicAddress is None: + raise ValueError("publicAddress is required") + if self.privateAddress is None and self.publicPort is None: + raise ValueError("at least one of privateAddress or publicPort is required") + return self + + +class Device(BaseModel): + phoneNumber: Optional[str] = Field(default=None, pattern=r"^\+[1-9][0-9]{4,14}$") + networkAccessIdentifier: Optional[str] = None + ipv4Address: Optional[DeviceIpv4Addr] = None + ipv6Address: Optional[str] = None + + @model_validator(mode="after") + def _require_at_least_one_identifier(self) -> "Device": + if not any( + (self.phoneNumber, self.networkAccessIdentifier, self.ipv4Address, self.ipv6Address) + ): + raise ValueError("at least one device identifier must be provided") + return self + + +class DeviceResponse(Device): + @model_validator(mode="after") + def _require_exactly_one_identifier(self) -> "DeviceResponse": + provided = [ + f + for f in ( + self.phoneNumber, + self.networkAccessIdentifier, + self.ipv4Address, + self.ipv6Address, + ) + if f + ] + if len(provided) != 1: + raise ValueError("exactly one device identifier must be provided") + return self + + +class RetrievalLocationRequest(BaseModel): + device: Optional[Device] = None + maxAge: Optional[int] = None + maxSurface: Optional[int] = Field(default=None, ge=1) + + +class Point(BaseModel): + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) + + +class Circle(BaseModel): + areaType: Literal[AreaType.CIRCLE] + center: Point + radius: float = Field(ge=1, description="Distance from the center in meters") + + +class Polygon(BaseModel): + areaType: Literal[AreaType.POLYGON] + boundary: list[Point] = Field(min_length=3, max_length=15) + + +Area = Annotated[Union[Circle, Polygon], Field(discriminator="areaType")] + + +class Location(BaseModel): + lastLocationTime: AwareDatetime + area: Area + device: Optional[DeviceResponse] = None diff --git a/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py b/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py new file mode 100644 index 0000000..07d3a11 --- /dev/null +++ b/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py @@ -0,0 +1,106 @@ +from uuid import UUID + +from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.schemas import ( + AreaType, + Circle, + Device, + DeviceResponse, + Location, + Point, + Polygon, + RetrievalLocationRequest, +) +from open_exposure_gateway.domain.location_retrieval import ( + LocationQueryParameters, + LocationQueryTarget, + SRMLocationArea, + SRMLocationQuery, + SRMLocationResult, +) +from open_exposure_gateway.domain.quality_on_demand import NetworkCapabilityTargetDevice + +_IDENTIFIER_PRECEDENCE = ("ipv4", "phone_number", "ipv6", "network_access_id") + + +def build_location_query( + request: RetrievalLocationRequest, + device: Device, + correlation_id: str, + app_provider_id: str, + service_specification_id: UUID, +) -> SRMLocationQuery: + """CAMARA `RetrievalLocationRequest` -> `POST /internal/network-queries/location` body.""" + + return SRMLocationQuery( + correlation_id=correlation_id, + app_provider_id=app_provider_id, + service_specification_id=str(service_specification_id), + target=LocationQueryTarget( + device=NetworkCapabilityTargetDevice( + phone_number=device.phoneNumber, + ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None, + ipv6=device.ipv6Address, + network_access_id=device.networkAccessIdentifier, + ) + ), + parameters=LocationQueryParameters( + max_age_seconds=request.maxAge, + max_surface_sqm=request.maxSurface, + ), + ) + + +def _to_camara_area(area: SRMLocationArea) -> Circle | Polygon: + if area.area_type == "circle": + if area.center is None or area.radius_m is None: + raise ValueError("circle area requires center and radius_m") + return Circle( + areaType=AreaType.CIRCLE, + center=Point(latitude=area.center.latitude, longitude=area.center.longitude), + radius=area.radius_m, + ) + if not area.boundary: + raise ValueError("polygon area requires boundary") + return Polygon( + areaType=AreaType.POLYGON, + boundary=[Point(latitude=p.latitude, longitude=p.longitude) for p in area.boundary], + ) + + +def _echo_device(device: Device, srm_resolved: str | None) -> DeviceResponse: + """Echo back the single identifier this response is about, per CAMARA's DeviceResponse. + + CAMARA wants the identifier that was actually used, so `srm_resolved` -- SRM's report of + what the network resolved -- wins whenever it is present. The precedence order is only a + fallback for when SRM cannot say, and it is a guess: it may name a different identifier + than the network used. + """ + + available = { + "ipv4": device.ipv4Address, + "phone_number": device.phoneNumber, + "ipv6": device.ipv6Address, + "network_access_id": device.networkAccessIdentifier, + } + + if srm_resolved is not None and available.get(srm_resolved): + chosen = srm_resolved + else: + chosen = next(key for key in _IDENTIFIER_PRECEDENCE if available[key]) + + if chosen == "phone_number": + return DeviceResponse(phoneNumber=device.phoneNumber) + if chosen == "ipv4": + return DeviceResponse(ipv4Address=device.ipv4Address) + if chosen == "ipv6": + return DeviceResponse(ipv6Address=device.ipv6Address) + return DeviceResponse(networkAccessIdentifier=device.networkAccessIdentifier) + + +def build_location(result: SRMLocationResult, device: Device | None) -> Location: + """SRM's canonical location -> CAMARA `Location`.""" + return Location( + lastLocationTime=result.last_location_time, + area=_to_camara_area(result.area), + device=_echo_device(device, result.srm_resolved_identifier) if device else None, + ) diff --git a/src/open_exposure_gateway/application/services/location_retrieval_service.py b/src/open_exposure_gateway/application/services/location_retrieval_service.py new file mode 100644 index 0000000..be3e56d --- /dev/null +++ b/src/open_exposure_gateway/application/services/location_retrieval_service.py @@ -0,0 +1,78 @@ +from typing import Optional +from uuid import UUID, uuid4 + +import structlog + +from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.schemas import ( + Location, + RetrievalLocationRequest, +) +from open_exposure_gateway.application.mappers.location_retrieval_mapper import ( + build_location, + build_location_query, +) +from open_exposure_gateway.core.config import ( + DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID, +) +from open_exposure_gateway.core.exceptions import ( + DownstreamServiceException, + ErrorCode, + UnprocessableEntityException, +) +from open_exposure_gateway.ports.srm_port import SRMClientPort + +logger: structlog.BoundLogger = structlog.get_logger(__name__) + + +class LocationRetrievalService: + def __init__( + self, + srm_client: SRMClientPort, + service_specification_id: UUID = DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID, + ) -> None: + self.srm_client = srm_client + self._service_specification_id = service_specification_id + + async def retrieve_location( + self, + request: RetrievalLocationRequest, + app_provider_id: str, + x_correlator: Optional[str] = None, + ) -> Location: + # CAMARA makes `device` optional only because a three-legged token identifies the + # subject on its own. OEG has no JWT subject extraction yet (see + # dependencies.get_caller_context), so every call is effectively two-legged and the + # device must be supplied. UNNECESSARY_IDENTIFIER -- the mirror error, for a device + # sent alongside a three-legged token -- is therefore unreachable until that lands. + if request.device is None: + raise UnprocessableEntityException( + error_code=ErrorCode.MISSING_IDENTIFIER, + message="The device cannot be identified.", + ) + + correlation_id = x_correlator or str(uuid4()) + query = build_location_query( + request=request, + device=request.device, + correlation_id=correlation_id, + app_provider_id=app_provider_id, + service_specification_id=self._service_specification_id, + ) + + result = await self.srm_client.retrieve_location( + query=query, + x_correlator=x_correlator, + ) + + try: + return build_location(result, request.device) + except ValueError as exc: + logger.error( + "srm_location_result_malformed", + correlation_id=correlation_id, + error=str(exc), + ) + raise DownstreamServiceException( + message="SRM returned an unusable location", + details=str(exc), + ) from exc diff --git a/src/open_exposure_gateway/core/config.py b/src/open_exposure_gateway/core/config.py index 4f6066d..d2c0528 100644 --- a/src/open_exposure_gateway/core/config.py +++ b/src/open_exposure_gateway/core/config.py @@ -12,6 +12,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict DEFAULT_QOD_SERVICE_SPECIFICATION_ID = UUID("7608e902-b927-559f-b448-e7e9061dfa5c") +# Location Retrieval has no registration step either, so it resolves its +# service_specification_id the same way (ADR-0035's pattern applied to a second family). +# It persists nothing -- a sync query writes no service_order or capability_instance -- +# but the internal query body still requires the field, and SRM uses it to resolve the +# control path for the location_context capability. +DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID = UUID("0129e7ce-8e02-5dfd-bae1-0303bcc7676e") + class SRMSettings(BaseModel): base_url: HttpUrl = HttpUrl("http://localhost:8081") @@ -46,6 +53,14 @@ class QodSettings(BaseModel): return self.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID +class LocationRetrievalSettings(BaseModel): + service_specification_id: UUID = DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID + + @property + def uses_default_service_specification_id(self) -> bool: + return self.service_specification_id == DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -67,6 +82,7 @@ class Settings(BaseSettings): observability_settings: ObservabilitySettings = ObservabilitySettings() callback_settings: CallbackSettings = CallbackSettings() qod_settings: QodSettings = QodSettings() + location_retrieval_settings: LocationRetrievalSettings = LocationRetrievalSettings() @lru_cache diff --git a/src/open_exposure_gateway/core/exceptions.py b/src/open_exposure_gateway/core/exceptions.py index 52e5e2e..83b3ff2 100644 --- a/src/open_exposure_gateway/core/exceptions.py +++ b/src/open_exposure_gateway/core/exceptions.py @@ -15,6 +15,16 @@ class ErrorCode(StrEnum): ABORTED = "ABORTED" NOT_IMPLEMENTED = "NOT_IMPLEMENTED" UNAVAILABLE = "UNAVAILABLE" + MISSING_IDENTIFIER = "MISSING_IDENTIFIER" + UNSUPPORTED_IDENTIFIER = "UNSUPPORTED_IDENTIFIER" + UNNECESSARY_IDENTIFIER = "UNNECESSARY_IDENTIFIER" + IDENTIFIER_NOT_FOUND = "IDENTIFIER_NOT_FOUND" + SERVICE_NOT_APPLICABLE = "SERVICE_NOT_APPLICABLE" + LOCATION_RETRIEVAL_UNABLE_TO_LOCATE = "LOCATION_RETRIEVAL.UNABLE_TO_LOCATE" + LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE = "LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_AGE" + LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE = ( + "LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_SURFACE" + ) class OEGException(Exception): @@ -48,8 +58,13 @@ class ForbiddenException(OEGException): class NotFoundException(OEGException): - def __init__(self, message: str = "Resource not found", details: Optional[Any] = None): - super().__init__(message, 404, ErrorCode.NOT_FOUND, details) + def __init__( + self, + message: str = "Resource not found", + details: Optional[Any] = None, + error_code: ErrorCode = ErrorCode.NOT_FOUND, + ): + super().__init__(message, 404, error_code, details) class MethodNotAllowedException(OEGException): @@ -77,6 +92,22 @@ class OutOfRangeException(OEGException): super().__init__(message, 400, ErrorCode.OUT_OF_RANGE, details) +class UnprocessableEntityException(OEGException): + """422 -- syntactically valid but semantically unfulfillable. + + `error_code` is required rather than defaulted: every 422 in CAMARA carries a specific + code the consumer branches on, and there is no sensible generic fallback. + """ + + def __init__( + self, + error_code: ErrorCode, + message: str = "Unprocessable content", + details: Optional[Any] = None, + ): + super().__init__(message, 422, error_code, details) + + class NotImplementedException(OEGException): def __init__(self, message: str = "Not implemented", details: Optional[Any] = None): super().__init__(message, 501, ErrorCode.NOT_IMPLEMENTED, details) diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index 067e16b..3c721d2 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -28,6 +28,9 @@ from open_exposure_gateway.api.camara.common import XCorrelatorHeader from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) +from open_exposure_gateway.application.services.location_retrieval_service import ( + LocationRetrievalService, +) from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) @@ -176,3 +179,12 @@ def get_qod_service( callback_delivery_port, get_settings().qod_settings.service_specification_id, ) + + +def get_location_retrieval_service( + srm: SRMClientPort = Depends(get_client), +) -> LocationRetrievalService: + return LocationRetrievalService( + srm, + get_settings().location_retrieval_settings.service_specification_id, + ) diff --git a/src/open_exposure_gateway/domain/location_retrieval.py b/src/open_exposure_gateway/domain/location_retrieval.py new file mode 100644 index 0000000..b332356 --- /dev/null +++ b/src/open_exposure_gateway/domain/location_retrieval.py @@ -0,0 +1,54 @@ +"""Canonical shapes for the synchronous device-location query . + +SRM calls the adapter and answers on the same HTTP response. Field names mirror SRM +vocabulary. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, Field + +from open_exposure_gateway.domain.quality_on_demand import NetworkCapabilityTargetDevice + + +class LocationQueryTarget(BaseModel): + device: NetworkCapabilityTargetDevice + + +class LocationQueryParameters(BaseModel): + # Both optional: absent max_age_seconds means "any age", absent max_surface_sqm means + # "any surface". 0 is a distinct, meaningful value for max_age_seconds ("fresh + # calculation"), so these are omitted when None rather than defaulted. + max_age_seconds: int | None = None + max_surface_sqm: int | None = None + + +class SRMLocationQuery(BaseModel): + correlation_id: str + app_provider_id: str + service_specification_id: str + target: LocationQueryTarget + parameters: LocationQueryParameters = Field(default_factory=LocationQueryParameters) + + +class LocationPoint(BaseModel): + latitude: float + longitude: float + + +class SRMLocationArea(BaseModel): + area_type: Literal["circle", "polygon"] + center: LocationPoint | None = None + radius_m: float | None = None + boundary: list[LocationPoint] | None = None + + +class SRMLocationResult(BaseModel): + last_location_time: AwareDatetime + area: SRMLocationArea + # Which of the identifiers OEG sent the network actually used to find the device. + srm_resolved_identifier: Literal["phone_number", "ipv4", "ipv6", "network_access_id"] | None = ( + None + ) diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py index 364e1f2..13b4b94 100644 --- a/src/open_exposure_gateway/main.py +++ b/src/open_exposure_gateway/main.py @@ -34,6 +34,9 @@ from open_exposure_gateway.adapters.http.srm_client import SRMClient from open_exposure_gateway.api.camara.edge_application_management.vwip.router import ( router as edge_application_management_router, ) +from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.router import ( + router as location_retrieval_router, +) from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.router import ( router as quality_on_demand_router, ) @@ -152,6 +155,10 @@ openapi_tags = [ "name": "Quality on Demand Functions", "description": "Quality on Demand session management", }, + { + "name": "Location retrieval", + "description": "Retrieve the location of a device", + }, { "name": "Platform", "description": "Platform-specific endpoints (health, readiness probes)", @@ -183,6 +190,22 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: service_specification_id=str(settings.qod_settings.service_specification_id), ) + location_retrieval_settings = settings.location_retrieval_settings + if location_retrieval_settings.uses_default_service_specification_id: + logger.warning( + "location_retrieval_service_specification_id_defaulted", + service_specification_id=str(location_retrieval_settings.service_specification_id), + detail=( + "LOCATION_RETRIEVAL_SETTINGS__SERVICE_SPECIFICATION_ID is unset; " + "using a hardcoded id." + ), + ) + else: + logger.info( + "location_retrieval_service_specification_id_configured", + service_specification_id=str(location_retrieval_settings.service_specification_id), + ) + try: db_engine, session_maker = await build_engine_and_session_maker( url=settings.postgresql_settings.url, @@ -285,6 +308,7 @@ def create_app(lifespan: Optional[Lifespan[FastAPI]] = None) -> FastAPI: app.include_router(health_router, prefix="/platform") app.include_router(edge_application_management_router) app.include_router(quality_on_demand_router) + app.include_router(location_retrieval_router) return app diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py index e37fc33..00a1fd4 100644 --- a/src/open_exposure_gateway/ports/srm_port.py +++ b/src/open_exposure_gateway/ports/srm_port.py @@ -7,6 +7,10 @@ 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 @@ -39,3 +43,7 @@ class SRMClientPort(Protocol): async def get_network_capability( self, service_instance_id: str, x_correlator: str | None ) -> SRMNetworkCapability: ... + + async def retrieve_location( + self, query: SRMLocationQuery, x_correlator: str | None + ) -> SRMLocationResult: ... diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py index e0918d7..a56e4bf 100644 --- a/tests/conformance/conftest.py +++ b/tests/conformance/conftest.py @@ -14,11 +14,15 @@ import pytest from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) +from open_exposure_gateway.application.services.location_retrieval_service import ( + LocationRetrievalService, +) from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) from open_exposure_gateway.dependencies import ( get_edge_app_service, + get_location_retrieval_service, get_publisher, get_qod_service, ) @@ -93,6 +97,9 @@ def service_overrides() -> Generator[None, None, None]: callback_delivery_repo=qod_callback_delivery_repo, callback_delivery_port=qod_callback_delivery_port, ) + app.dependency_overrides[get_location_retrieval_service] = lambda: LocationRetrievalService( + srm_client=srm + ) app.dependency_overrides[get_publisher] = lambda: bus yield app.dependency_overrides.clear() diff --git a/tests/conformance/test_location_retrieval_conformance.py b/tests/conformance/test_location_retrieval_conformance.py new file mode 100644 index 0000000..aebcf56 --- /dev/null +++ b/tests/conformance/test_location_retrieval_conformance.py @@ -0,0 +1,53 @@ +"""CAMARA Location Retrieval conformance. + +Every operation in the vendored upstream spec is exercised with generated requests against +the ASGI app; responses are validated against the spec (status codes, response schemas, +headers). A failure here means the northbound interface diverges from CAMARA. +""" + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import schemathesis + +from tests.conformance.harness import app + +if TYPE_CHECKING: + from schemathesis.specs.openapi.schemas import OpenApiCase + +pytestmark = pytest.mark.conformance + +SPEC = ( + Path(__file__).parents[2] + / "src" + / "open_exposure_gateway" + / "api" + / "camara" + / "location_retrieval" + / "API_definitions" + / "location-retrieval.yaml" +) + +schema = schemathesis.openapi.from_path(SPEC) +schema.app = app +schema.config.generation.update(max_examples=10, no_shrink=True) +# Three statuses are correct here but look like rejections to positive_data_acceptance: +# 422 -- `device` is optional in the schema only because a three-legged token identifies +# the subject on its own; OEG has no JWT subject extraction yet, so a body with no +# device correctly returns MISSING_IDENTIFIER. +# 404 -- a generated identifier matches no device the fake SRM has been seeded with. +# 400 -- the identifier fields carry no minLength upstream, so an empty string is +# schema-valid; our validator rejects it as unusable. Same trade-off QoD already +# documents for empty ipv4/ipv6 strings. +schema.config.checks.positive_data_acceptance.expected_statuses = [ + *schema.config.checks.positive_data_acceptance.expected_statuses, + "400", + "404", + "422", +] + + +@schema.parametrize() +def test_location_retrieval_conformance(case: "OpenApiCase") -> None: + case.call_and_validate() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 5e2b0dc..08c12e2 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -11,12 +11,16 @@ from open_exposure_gateway.api.camara.edge_application_management.vwip.router im from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) +from open_exposure_gateway.application.services.location_retrieval_service import ( + LocationRetrievalService, +) from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) from open_exposure_gateway.dependencies import ( get_database_health, get_edge_app_service, + get_location_retrieval_service, get_publisher, get_qod_service, ) @@ -195,6 +199,11 @@ def qod_service( ) +@pytest.fixture() +def location_retrieval_service(fake_srm: FakeSRMClient) -> LocationRetrievalService: + return LocationRetrievalService(srm_client=fake_srm) + + @pytest.fixture() def register_app(api_client: TestClient) -> Callable[[Any], None]: """Registers an app via a real POST /apps call, for tests that exercise @@ -241,10 +250,12 @@ def register_app(api_client: TestClient) -> Callable[[Any], None]: def api_client( eam_service: EdgeApplicationManagementService, qod_service: QualityOnDemandService, + location_retrieval_service: LocationRetrievalService, fake_bus: FakeDataBus, ) -> Generator[TestClient, None, None]: app.dependency_overrides[get_edge_app_service] = lambda: eam_service app.dependency_overrides[get_qod_service] = lambda: qod_service + app.dependency_overrides[get_location_retrieval_service] = lambda: location_retrieval_service app.dependency_overrides[get_publisher] = lambda: fake_bus app.dependency_overrides[get_database_health] = lambda: True # raise_server_exceptions=False: unhandled errors surface as the 500 envelope diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 346960f..c064faa 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -36,7 +36,7 @@ from open_exposure_gateway.application.services.edge_application_management_serv from open_exposure_gateway.application.services.quality_on_demand_service import ( QualityOnDemandService, ) -from open_exposure_gateway.core.exceptions import NotFoundException +from open_exposure_gateway.core.exceptions import ErrorCode, NotFoundException from open_exposure_gateway.domain.edge_application_management import ( AppInstanceStatusChangeCloudEvent, SRMCatalogPayload, @@ -45,6 +45,10 @@ from open_exposure_gateway.domain.edge_application_management import ( SRMZone, Subject, ) +from open_exposure_gateway.domain.location_retrieval import ( + SRMLocationQuery, + SRMLocationResult, +) from open_exposure_gateway.domain.models import ( AppInstance, AppInstanceState, @@ -112,6 +116,8 @@ class FakeSRMClient: self.catalog: dict[str, dict[str, Any]] = {} self.instances: dict[str, SRMServiceInstance] = {} self.network_capabilities: dict[str, SRMNetworkCapability] = {} + self.locations: dict[str, SRMLocationResult] = {} + self.location_queries: list[SRMLocationQuery] = [] async def get_zones( self, @@ -162,6 +168,21 @@ class FakeSRMClient: raise NotFoundException(message=f"Network capability {service_instance_id} not found") return capability + async def retrieve_location( + self, + query: SRMLocationQuery, + x_correlator: str | None = None, + ) -> SRMLocationResult: + self.location_queries.append(query) + device = query.target.device + for value in (device.phone_number, device.ipv4, device.ipv6, device.network_access_id): + if value and value in self.locations: + return self.locations[value] + raise NotFoundException( + message="Device identifier not found.", + error_code=ErrorCode.IDENTIFIER_NOT_FOUND, + ) + def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]: payload: dict[str, Any] = { diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index fff5fbd..8b508fe 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -4,6 +4,7 @@ from uuid import NAMESPACE_URL, UUID, uuid5 import pytest from open_exposure_gateway.core.config import ( + DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID, DEFAULT_QOD_SERVICE_SPECIFICATION_ID, Settings, get_settings, @@ -85,6 +86,11 @@ class TestDefaults: settings.qod_settings.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID ) assert settings.qod_settings.uses_default_service_specification_id is True + assert ( + settings.location_retrieval_settings.service_specification_id + == DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID + ) + assert settings.location_retrieval_settings.uses_default_service_specification_id is True class TestWellKnownQodServiceSpecificationId: @@ -98,6 +104,30 @@ class TestWellKnownQodServiceSpecificationId: ) +class TestWellKnownLocationRetrievalServiceSpecificationId: + def test_constant_matches_its_documented_derivation(self) -> None: + """Same cross-component contract as the QoD id, same recipe -- only the `ref` + differs. Pinning it here is what stops the two sides drifting apart silently.""" + assert DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID == uuid5( + NAMESPACE_URL, "https://etsi.org/sdg/oop/service-specification/location-context/1" + ) + + +class TestLocationRetrievalServiceSpecificationIdIsConfigurable: + def test_env_override_is_applied_and_flagged_as_non_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv( + "LOCATION_RETRIEVAL_SETTINGS__SERVICE_SPECIFICATION_ID", + "11111111-2222-3333-4444-555555555555", + ) + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.location_retrieval_settings.service_specification_id == UUID( + "11111111-2222-3333-4444-555555555555" + ) + assert settings.location_retrieval_settings.uses_default_service_specification_id is False + + class TestGetSettingsCaching: def test_get_settings_returns_same_instance(self) -> None: assert get_settings() is get_settings() diff --git a/tests/unit/test_location_retrieval_flows.py b/tests/unit/test_location_retrieval_flows.py new file mode 100644 index 0000000..b1f97df --- /dev/null +++ b/tests/unit/test_location_retrieval_flows.py @@ -0,0 +1,304 @@ +"""End-to-end flow tests for CAMARA Location Retrieval.""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.router import ( + BASE_PATH as LOCATION_BASE, +) +from open_exposure_gateway.core.exceptions import ( + ErrorCode, + UnprocessableEntityException, +) +from open_exposure_gateway.domain.location_retrieval import SRMLocationResult +from tests.unit.fakes import FakeDataBus, FakeSRMClient + +RETRIEVE = f"{LOCATION_BASE}/retrieve" +PHONE = "+123456789" + +LAST_SEEN = "2023-10-17T13:18:23.682Z" + +CIRCLE_RESULT = SRMLocationResult.model_validate( + { + "last_location_time": LAST_SEEN, + "area": { + "area_type": "circle", + "center": {"latitude": 45.754114, "longitude": 4.860374}, + "radius_m": 800, + }, + } +) + +POLYGON_RESULT = SRMLocationResult.model_validate( + { + "last_location_time": LAST_SEEN, + "area": { + "area_type": "polygon", + "boundary": [ + {"latitude": 45.754114, "longitude": 4.860374}, + {"latitude": 45.753845, "longitude": 4.863185}, + {"latitude": 45.752490, "longitude": 4.861876}, + ], + }, + } +) + + +@pytest.fixture() +def located_device(fake_srm: FakeSRMClient) -> FakeSRMClient: + fake_srm.locations[PHONE] = CIRCLE_RESULT + return fake_srm + + +class TestRetrieveLocationHappyPath: + def test_circle_area_is_returned( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert response.status_code == 200 + body = response.json() + assert body["area"]["areaType"] == "CIRCLE" + assert body["area"]["center"] == {"latitude": 45.754114, "longitude": 4.860374} + assert body["area"]["radius"] == 800 + assert body["lastLocationTime"].startswith("2023-10-17T13:18:23") + + def test_polygon_area_is_returned( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + fake_srm.locations[PHONE] = POLYGON_RESULT + + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert response.status_code == 200 + body = response.json() + assert body["area"]["areaType"] == "POLYGON" + assert len(body["area"]["boundary"]) == 3 + assert "center" not in body["area"] + + def test_ipv4_identified_device_is_located( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + fake_srm.locations["84.125.93.10"] = CIRCLE_RESULT + + response = api_client.post( + RETRIEVE, + json={ + "device": {"ipv4Address": {"publicAddress": "84.125.93.10", "publicPort": 59765}} + }, + ) + + assert response.status_code == 200 + + def test_nothing_is_published_to_the_databus( + self, api_client: TestClient, located_device: FakeSRMClient, fake_bus: FakeDataBus + ) -> None: + api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert fake_bus.published == [] + + +class TestQueryTranslation: + def test_device_identifier_reaches_srm_in_canonical_form( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + query = located_device.location_queries[0] + assert query.target.device.phone_number == PHONE + assert query.app_provider_id == "placeholder" + assert query.service_specification_id + + def test_max_age_zero_is_forwarded_not_dropped( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + """maxAge=0 means "recalculate now" — the strictest possible freshness request. + A truthiness check instead of `is not None` would silently turn it into "any age + is acceptable", which is the exact opposite instruction.""" + api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}, "maxAge": 0}) + + assert located_device.location_queries[0].parameters.max_age_seconds == 0 + + def test_negative_max_age_is_accepted_and_forwarded( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + """The vendored schema puts no minimum on maxAge -- deliberately, since maxSurface + beside it does carry one. Rejecting a negative here would 400 a spec-conformant + request; whether the network can honour it is SRM's judgement, not OEG's.""" + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}, "maxAge": -5}) + + assert response.status_code == 200 + assert located_device.location_queries[0].parameters.max_age_seconds == -5 + + def test_absent_max_age_stays_absent( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert located_device.location_queries[0].parameters.max_age_seconds is None + + def test_max_surface_is_forwarded( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}, "maxSurface": 1_000_000}) + + assert located_device.location_queries[0].parameters.max_surface_sqm == 1_000_000 + + +class TestDeviceEcho: + def test_single_identifier_is_echoed_back( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert response.json()["device"] == {"phoneNumber": PHONE} + + def test_only_one_identifier_is_echoed_when_several_are_sent( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + """CAMARA's DeviceResponse is maxProperties: 1 — the provider must name the single + identifier it used, even though the consumer may send several.""" + response = api_client.post( + RETRIEVE, + json={ + "device": { + "phoneNumber": PHONE, + "ipv4Address": {"publicAddress": "84.125.93.10", "publicPort": 1234}, + } + }, + ) + + assert response.status_code == 200 + assert response.json()["device"] == { + "ipv4Address": {"publicAddress": "84.125.93.10", "publicPort": 1234} + } + + +class TestErrorMapping: + def test_missing_device_is_422_missing_identifier(self, api_client: TestClient) -> None: + """No JWT subject extraction exists yet, so every call is two-legged and `device` + is mandatory despite being optional in the vendored schema.""" + response = api_client.post(RETRIEVE, json={"maxAge": 120}) + + assert response.status_code == 422 + assert response.json() == { + "status": 422, + "code": "MISSING_IDENTIFIER", + "message": "The device cannot be identified.", + } + + def test_unknown_device_is_404_identifier_not_found(self, api_client: TestClient) -> None: + """CAMARA pins this API's 404 to IDENTIFIER_NOT_FOUND, not the generic NOT_FOUND + that EAM and QoD return.""" + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": "+999999999"}}) + + assert response.status_code == 404 + assert response.json()["code"] == "IDENTIFIER_NOT_FOUND" + + def test_empty_device_object_is_400(self, api_client: TestClient) -> None: + """`device: {}` is a malformed body (minProperties: 1), which is a different failure + from omitting `device` altogether.""" + response = api_client.post(RETRIEVE, json={"device": {}}) + + assert response.status_code == 400 + assert response.json()["code"] == "INVALID_ARGUMENT" + + @pytest.mark.parametrize( + ("ipv4", "reason"), + [ + ({"publicPort": 59765}, "publicAddress missing"), + ({"publicAddress": "84.125.93.10"}, "neither privateAddress nor publicPort"), + ], + ) + def test_incomplete_ipv4_address_is_400( + self, api_client: TestClient, ipv4: dict[str, Any], reason: str + ) -> None: + """CAMARA requires publicAddress plus at least one of privateAddress/publicPort -- + a mobile device generally cannot be identified by its public IPv4 alone.""" + response = api_client.post(RETRIEVE, json={"device": {"ipv4Address": ipv4}}) + + assert response.status_code == 400 + + def test_malformed_phone_number_is_400(self, api_client: TestClient) -> None: + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": "not-a-number"}}) + + assert response.status_code == 400 + + def test_max_surface_below_minimum_is_400(self, api_client: TestClient) -> None: + response = api_client.post( + RETRIEVE, json={"device": {"phoneNumber": PHONE}, "maxSurface": 0} + ) + + assert response.status_code == 400 + + @pytest.mark.parametrize( + ("error_code", "expected"), + [ + (ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE, "LOCATION_RETRIEVAL.UNABLE_TO_LOCATE"), + ( + ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE, + "LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_AGE", + ), + ( + ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE, + "LOCATION_RETRIEVAL.UNABLE_TO_FULFILL_MAX_SURFACE", + ), + (ErrorCode.SERVICE_NOT_APPLICABLE, "SERVICE_NOT_APPLICABLE"), + ], + ) + def test_srm_signalled_422s_reach_the_consumer( + self, + api_client: TestClient, + fake_srm: FakeSRMClient, + error_code: ErrorCode, + expected: str, + ) -> None: + """These conditions are network facts OEG cannot derive; the adapter maps SRM's + RFC 7807 code onto the CAMARA code, and it must survive the handler chain intact.""" + + async def _refuse(*args: Any, **kwargs: Any) -> Any: + raise UnprocessableEntityException(error_code=error_code, message="nope") + + fake_srm.retrieve_location = _refuse # type: ignore[method-assign] + + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert response.status_code == 422 + assert response.json()["code"] == expected + + def test_malformed_srm_area_is_503_not_500( + self, api_client: TestClient, fake_srm: FakeSRMClient + ) -> None: + """A circle without a radius is a broken downstream contract. It must not surface as + a 500 (which reads as an OEG bug) nor a 4xx (which blames the consumer).""" + fake_srm.locations[PHONE] = SRMLocationResult.model_validate( + {"last_location_time": LAST_SEEN, "area": {"area_type": "circle"}} + ) + + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert response.status_code == 503 + assert response.json()["code"] == "UNAVAILABLE" + + +class TestCorrelation: + def test_x_correlator_is_echoed_and_forwarded( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + response = api_client.post( + RETRIEVE, + json={"device": {"phoneNumber": PHONE}}, + headers={"x-correlator": "corr-loc-001"}, + ) + + assert response.headers["x-correlator"] == "corr-loc-001" + assert located_device.location_queries[0].correlation_id == "corr-loc-001" + + def test_correlation_id_is_generated_when_header_absent( + self, api_client: TestClient, located_device: FakeSRMClient + ) -> None: + api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + + assert located_device.location_queries[0].correlation_id diff --git a/tests/unit/test_location_retrieval_mapper.py b/tests/unit/test_location_retrieval_mapper.py new file mode 100644 index 0000000..92f9ee2 --- /dev/null +++ b/tests/unit/test_location_retrieval_mapper.py @@ -0,0 +1,260 @@ +"""CAMARA <-> canonical translation for Location Retrieval.""" + +from uuid import UUID + +import pytest + +from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.schemas import ( + AreaType, + Device, + DeviceIpv4Addr, + DeviceResponse, + RetrievalLocationRequest, +) +from open_exposure_gateway.application.mappers.location_retrieval_mapper import ( + build_location, + build_location_query, +) +from open_exposure_gateway.domain.location_retrieval import ( + SRMLocationArea, + SRMLocationResult, +) + +SPEC_ID = UUID("0129e7ce-8e02-5dfd-bae1-0303bcc7676e") +LAST_SEEN = "2023-10-17T13:18:23.682Z" + + +def _query(request: RetrievalLocationRequest) -> object: + assert request.device is not None + return build_location_query( + request=request, + device=request.device, + correlation_id="corr-1", + app_provider_id="VideoAppsCo", + service_specification_id=SPEC_ID, + ) + + +def _circle(**overrides: object) -> SRMLocationResult: + """An SRM circle answer, built from the wire shape so the timestamp is parsed the same + way it is in production.""" + return SRMLocationResult.model_validate( + { + "last_location_time": LAST_SEEN, + "area": { + "area_type": "circle", + "center": {"latitude": 45.754114, "longitude": 4.860374}, + "radius_m": 800, + }, + **overrides, + } + ) + + +class TestBuildLocationQuery: + def test_every_identifier_maps_to_its_canonical_field(self) -> None: + request = RetrievalLocationRequest( + device=Device( + phoneNumber="+123456789", + networkAccessIdentifier="123456789@domain.com", + ipv4Address=DeviceIpv4Addr(publicAddress="84.125.93.10", publicPort=59765), + ipv6Address="2001:db8:85a3:8d3:1319:8a2e:370:7344", + ) + ) + + device = _query(request).target.device # type: ignore[attr-defined] + + assert device.phone_number == "+123456789" + assert device.network_access_id == "123456789@domain.com" + assert device.ipv4 == "84.125.93.10" + assert device.ipv6 == "2001:db8:85a3:8d3:1319:8a2e:370:7344" + + def test_only_the_public_ipv4_address_crosses(self) -> None: + """The canonical target carries a single `ipv4` string, so privateAddress and + publicPort have nowhere to go — the same lossy step QoD's mapper documents.""" + request = RetrievalLocationRequest( + device=Device( + ipv4Address=DeviceIpv4Addr( + publicAddress="84.125.93.10", privateAddress="10.0.0.1", publicPort=59765 + ) + ) + ) + + assert _query(request).target.device.ipv4 == "84.125.93.10" # type: ignore[attr-defined] + + @pytest.mark.parametrize("max_age", [-5, 0, 120]) + def test_max_age_is_forwarded_including_zero(self, max_age: int) -> None: + request = RetrievalLocationRequest(device=Device(phoneNumber="+123456789"), maxAge=max_age) + + assert _query(request).parameters.max_age_seconds == max_age # type: ignore[attr-defined] + + def test_omitted_bounds_stay_none(self) -> None: + request = RetrievalLocationRequest(device=Device(phoneNumber="+123456789")) + + parameters = _query(request).parameters # type: ignore[attr-defined] + assert parameters.max_age_seconds is None + assert parameters.max_surface_sqm is None + + def test_none_valued_parameters_are_excluded_from_the_wire_body(self) -> None: + """SRM reads an absent max_age_seconds as "any age". Serializing it as an explicit + null would make SRM interpret a value OEG never meant to send.""" + request = RetrievalLocationRequest(device=Device(phoneNumber="+123456789")) + + body = _query(request).model_dump(mode="json", exclude_none=True) # type: ignore[attr-defined] + + assert body["parameters"] == {} + assert body["target"]["device"] == {"phone_number": "+123456789"} + + +class TestBuildLocation: + def test_circle_becomes_camara_circle(self) -> None: + location = build_location(_circle(), device=None) + + assert location.area.areaType == AreaType.CIRCLE + assert location.area.radius == 800 + assert location.area.center.latitude == 45.754114 + + def test_polygon_becomes_camara_polygon(self) -> None: + result = SRMLocationResult.model_validate( + { + "last_location_time": LAST_SEEN, + "area": { + "area_type": "polygon", + "boundary": [ + {"latitude": 45.754114, "longitude": 4.860374}, + {"latitude": 45.753845, "longitude": 4.863185}, + {"latitude": 45.752490, "longitude": 4.861876}, + ], + }, + } + ) + + location = build_location(result, device=None) + + assert location.area.areaType == AreaType.POLYGON + assert len(location.area.boundary) == 3 + + def test_last_location_time_keeps_its_timezone(self) -> None: + location = build_location(_circle(), device=None) + + assert location.lastLocationTime.tzinfo is not None + + def test_naive_timestamp_from_srm_is_rejected(self) -> None: + with pytest.raises(ValueError): + SRMLocationResult.model_validate( + { + "last_location_time": "2023-10-17T13:18:23", + "area": { + "area_type": "circle", + "center": {"latitude": 45.0, "longitude": 4.0}, + "radius_m": 800, + }, + } + ) + + @pytest.mark.parametrize( + ("area", "missing"), + [ + (SRMLocationArea(area_type="circle", center=None, radius_m=None), "circle"), + (SRMLocationArea(area_type="polygon", boundary=None), "polygon"), + ], + ) + def test_incomplete_area_raises(self, area: SRMLocationArea, missing: str) -> None: + result = SRMLocationResult.model_validate( + {"last_location_time": LAST_SEEN, "area": area.model_dump()} + ) + + with pytest.raises(ValueError, match=missing): + build_location(result, device=None) + + +class TestDeviceEchoRule: + def test_no_device_echoed_when_none_was_supplied(self) -> None: + """The three-legged case: the subject came from the token, so the spec omits the + field rather than inventing one.""" + assert build_location(_circle(), device=None).device is None + + @pytest.mark.parametrize( + ("device", "expected_field"), + [ + (Device(phoneNumber="+123456789"), "phoneNumber"), + ( + Device(ipv4Address=DeviceIpv4Addr(publicAddress="84.125.93.10", publicPort=1)), + "ipv4Address", + ), + (Device(ipv6Address="2001:db8::1"), "ipv6Address"), + (Device(networkAccessIdentifier="a@b.com"), "networkAccessIdentifier"), + ], + ) + def test_the_single_supplied_identifier_is_echoed( + self, device: Device, expected_field: str + ) -> None: + echoed = build_location(_circle(), device=device).device + + assert echoed is not None + assert echoed.model_dump(exclude_none=True).keys() == {expected_field} + + def test_precedence_picks_ipv4_over_phone_number(self) -> None: + """IPv4 arrives with a port, so it pins one session through NAT; a phone number may + belong to a multi-SIM group covering several devices.""" + device = Device( + phoneNumber="+123456789", + ipv6Address="2001:db8::1", + ipv4Address=DeviceIpv4Addr(publicAddress="84.125.93.10", publicPort=1), + ) + + echoed = build_location(_circle(), device=device).device + + assert echoed is not None + assert echoed.model_dump(exclude_none=True).keys() == {"ipv4Address"} + + def test_precedence_picks_phone_number_over_ipv6(self) -> None: + """An IPv6 may be any address from the device's /64, so it ranks below a phone + number rather than above it.""" + device = Device(phoneNumber="+123456789", ipv6Address="2001:db8::1") + + echoed = build_location(_circle(), device=device).device + + assert echoed is not None + assert echoed.model_dump(exclude_none=True).keys() == {"phoneNumber"} + + def test_srm_resolved_identifier_beats_precedence(self) -> None: + """CAMARA wants the identifier actually used, not our ranking. When SRM reports one, + it must win -- otherwise the consumer is told the answer came from an identifier the + network ignored.""" + device = Device( + phoneNumber="+123456789", + ipv4Address=DeviceIpv4Addr(publicAddress="84.125.93.10", publicPort=1), + ) + + # ipv4 outranks phone_number, so this only passes if SRM's report actually overrides. + echoed = build_location( + _circle(srm_resolved_identifier="phone_number"), device=device + ).device + + assert echoed is not None + assert echoed.model_dump(exclude_none=True).keys() == {"phoneNumber"} + + def test_srm_resolved_identifier_absent_from_request_falls_back(self) -> None: + """SRM naming an identifier the consumer never sent cannot be echoed -- that would put + a field in the response that was not in the request.""" + device = Device(phoneNumber="+123456789") + + echoed = build_location(_circle(srm_resolved_identifier="ipv6"), device=device).device + + assert echoed is not None + assert echoed.model_dump(exclude_none=True).keys() == {"phoneNumber"} + + +class TestDeviceResponseInvariant: + """DeviceResponse enforces CAMARA's `maxProperties: 1`. It is the guard that makes + `_echo_device`'s precedence rule safe: if the rule ever returned two identifiers, or + none, construction fails loudly here rather than emitting a non-conformant body.""" + + def test_two_identifiers_are_rejected(self) -> None: + with pytest.raises(ValueError, match="exactly one"): + DeviceResponse(phoneNumber="+123456789", ipv6Address="2001:db8::1") + + def test_zero_identifiers_are_rejected(self) -> None: + with pytest.raises(ValueError): + DeviceResponse() diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py index cf11145..8c5f270 100644 --- a/tests/unit/test_srm_client.py +++ b/tests/unit/test_srm_client.py @@ -5,6 +5,7 @@ Service-level tests mock the port instead of the transport, so this mapping was previously untested end-to-end. """ +import json from collections.abc import Callable from typing import Any @@ -14,8 +15,15 @@ import pytest from open_exposure_gateway.adapters.http.srm_client import SRMClient from open_exposure_gateway.core.exceptions import ( DownstreamServiceException, + ErrorCode, NotFoundException, + UnprocessableEntityException, ) +from open_exposure_gateway.domain.location_retrieval import ( + LocationQueryTarget, + SRMLocationQuery, +) +from open_exposure_gateway.domain.quality_on_demand import NetworkCapabilityTargetDevice HttpHandler = Callable[[httpx.Request], httpx.Response] @@ -124,3 +132,96 @@ async def test_generic_request_error_raises_downstream(monkeypatch: pytest.Monke with pytest.raises(DownstreamServiceException): await client._request("GET", "/internal/zones") + + +def _location_query() -> SRMLocationQuery: + return SRMLocationQuery( + correlation_id="corr-loc-001", + app_provider_id="VideoAppsCo", + service_specification_id="0129e7ce-8e02-5dfd-bae1-0303bcc7676e", + target=LocationQueryTarget(device=NetworkCapabilityTargetDevice(phone_number="+123456789")), + ) + + +@pytest.mark.parametrize( + ("problem_code", "expected"), + [ + ("unable_to_locate", ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE), + ("unable_to_fulfill_max_age", ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE), + ( + "unable_to_fulfill_max_surface", + ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE, + ), + ("unsupported_identifier", ErrorCode.UNSUPPORTED_IDENTIFIER), + ("service_not_applicable", ErrorCode.SERVICE_NOT_APPLICABLE), + ], +) +async def test_422_problem_code_maps_to_camara_code( + monkeypatch: pytest.MonkeyPatch, problem_code: str, expected: ErrorCode +) -> None: + """SRM's neutral RFC 7807 code is the only signal for conditions only the network knows; + this is the table that turns it into what the CAMARA consumer branches on.""" + client = _client( + monkeypatch, + lambda request: httpx.Response( + 422, json={"status": 422, "code": problem_code, "detail": "nope"} + ), + ) + + with pytest.raises(UnprocessableEntityException) as exc_info: + await client.retrieve_location(_location_query()) + + assert exc_info.value.error_code == expected + assert exc_info.value.message == "nope" + + +@pytest.mark.parametrize("body", [{"status": 422, "code": "something_new"}, {"status": 422}]) +async def test_unrecognized_422_falls_back_to_service_not_applicable( + monkeypatch: pytest.MonkeyPatch, body: dict[str, Any] +) -> None: + """An SRM build newer than this OEG must not produce a 500. SERVICE_NOT_APPLICABLE is a + documented CAMARA code and an honest answer.""" + client = _client(monkeypatch, lambda request: httpx.Response(422, json=body)) + + with pytest.raises(UnprocessableEntityException) as exc_info: + await client.retrieve_location(_location_query()) + + assert exc_info.value.error_code == ErrorCode.SERVICE_NOT_APPLICABLE + + +async def test_location_404_is_identifier_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + """The shared _request raises the generic NOT_FOUND; this API's vendored spec pins its + 404 to IDENTIFIER_NOT_FOUND.""" + client = _client(monkeypatch, lambda request: httpx.Response(404)) + + with pytest.raises(NotFoundException) as exc_info: + await client.retrieve_location(_location_query()) + + assert exc_info.value.error_code == ErrorCode.IDENTIFIER_NOT_FOUND + + +async def test_location_query_body_omits_none_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sent: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + sent.update(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "last_location_time": "2023-10-17T13:18:23.682Z", + "area": { + "area_type": "circle", + "center": {"latitude": 45.75, "longitude": 4.86}, + "radius_m": 800, + }, + }, + ) + + client = _client(monkeypatch, handler) + + result = await client.retrieve_location(_location_query()) + + assert sent["parameters"] == {} + assert result.area.radius_m == 800 diff --git a/tests/unit/test_vendored_spec_refs.py b/tests/unit/test_vendored_spec_refs.py index 9b51c56..9cd4dea 100644 --- a/tests/unit/test_vendored_spec_refs.py +++ b/tests/unit/test_vendored_spec_refs.py @@ -23,6 +23,10 @@ SPECS = { / "API_definitions" / "edge-application-management.yaml", "qod": _CAMARA / "quality_on_demand" / "API_definitions" / "qod-api.yaml", + "location_retrieval": _CAMARA + / "location_retrieval" + / "API_definitions" + / "location-retrieval.yaml", } -- GitLab From 31552279108f46b948fc2a34c425a0ac06640834 Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 17:36:59 +0300 Subject: [PATCH 2/9] fix: call SRM's location query under /internal Every other SRM call in this client targets /internal/...; retrieve_location omitted the prefix, so the request never reached the endpoint. SRM serves its whole internal API under that base (srm/interface-contract.md "Base path: http://srm:8081/internal"), and oeg/interface-contract.md K.3 lists this route as /internal/network-queries/location. The failure was quiet rather than loud: SRM answers 404 for the unknown route, _request turns any 404 into NotFoundException, and retrieve_location relabels that as IDENTIFIER_NOT_FOUND. A misrouted call therefore surfaced to the caller as a plausible "no such device" rather than as a broken deployment. No test observed the request path -- the mock transport answers any URL -- so the suite stayed green. Adding that assertion. Co-Authored-By: Claude Opus 5 --- .../adapters/http/srm_client.py | 2 +- tests/unit/test_srm_client.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 8610e04..51ec9ff 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -215,7 +215,7 @@ class SRMClient: try: data = await self._request( "POST", - "/network-queries/location", + "/internal/network-queries/location", json=query.model_dump(mode="json", exclude_none=True), headers=headers, ) diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py index 8c5f270..f162d8a 100644 --- a/tests/unit/test_srm_client.py +++ b/tests/unit/test_srm_client.py @@ -225,3 +225,29 @@ async def test_location_query_body_omits_none_parameters( assert sent["parameters"] == {} assert result.area.radius_m == 800 + + +async def test_location_query_targets_the_internal_base_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + called["path"] = request.url.path + return httpx.Response( + 200, + json={ + "last_location_time": "2023-10-17T13:18:23.682Z", + "area": { + "area_type": "circle", + "center": {"latitude": 45.75, "longitude": 4.86}, + "radius_m": 800, + }, + }, + ) + + client = _client(monkeypatch, handler) + + await client.retrieve_location(_location_query()) + + assert called["path"] == "/internal/network-queries/location" -- GitLab From 40b240d2a48676c0a953ad21ddb912160a131e61 Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 17:37:56 +0300 Subject: [PATCH 3/9] fix: scope SRM 422 translation to sync queries and key it on RFC 7807 type Four defects in one fifteen-line block; they are one change because each edit sits inside the previous one. Scope. The 422 branch lived in the shared _request, so a location-retrieval rule applied to every SRM call. An SRM 422 during a zone read became a CAMARA 422 SERVICE_NOT_APPLICABLE where it had previously been a 503 -- and neither the EAM nor the QoD vendored spec documents a 422 at all, so OEG could emit a status its own contract excludes. _request now translates only when a caller passes a `problems` table; retrieve_location is the only one that does. Signal. The mapping keyed on problem["code"]. SRM answers RFC 7807 (REQ-SRM-10), which defines type/title/status/detail/instance and no `code`, and the vocabulary it matched appears nowhere in the architecture bundle. Every 422 therefore fell through to the fallback, making the four specific CAMARA codes unreachable in production. Now keyed on `type`, RFC 7807's machine-readable problem identifier -- no extension member needed -- against the vocabulary in srm/interface-contract.md E.2. An unknown type still degrades to SERVICE_NOT_APPLICABLE, so SRM can add one without waiting on OEG. Trust boundary. The outward `message` was SRM's `detail`, which is internal diagnostic prose ("adapter oai-nef-01 timed out on subscriber 262011234567890") and crosses to the app provider. Messages now come from the vendored CAMARA spec; `detail` is logged instead. Robustness. response.json() sat inside a try that catches only httpx errors, so a 422 carrying an HTML error page, an empty body, or a JSON array escaped as JSONDecodeError/AttributeError and surfaced as 500 -- blaming OEG for a downstream fault. An unreadable body now falls through to the >= 400 branch and becomes 503. That is deliberately distinct from an unrecognized type: there SRM refused, here we cannot confirm the response even came from SRM. test_eam_contract's _request doubles gain the new parameter. The unit suite passed without that; mypy caught it. Co-Authored-By: Claude Opus 5 --- .../adapters/http/srm_client.py | 61 +++++++-- tests/unit/test_eam_contract.py | 3 + tests/unit/test_srm_client.py | 128 ++++++++++++++++-- 3 files changed, 162 insertions(+), 30 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 51ec9ff..b353a6a 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -25,14 +25,36 @@ from open_exposure_gateway.domain.quality_on_demand import SRMNetworkCapability logger = structlog.get_logger(__name__) -_QUERY_PROBLEM_CODES: dict[str, ErrorCode] = { - "unable_to_locate": ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE, - "unable_to_fulfill_max_age": ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE, - "unable_to_fulfill_max_surface": (ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE), - "unsupported_identifier": ErrorCode.UNSUPPORTED_IDENTIFIER, - "service_not_applicable": ErrorCode.SERVICE_NOT_APPLICABLE, +# 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"] + class SRMClient: def __init__(self) -> None: @@ -47,6 +69,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( @@ -69,15 +92,22 @@ class SRMClient: log.warning("SRM resource not found", url=url) raise NotFoundException(message="Resource not found") - if response.status_code == 422: - problem = response.json() - code = problem.get("code") - error_code = _QUERY_PROBLEM_CODES.get(code, ErrorCode.SERVICE_NOT_APPLICABLE) - log.warning("SRM could not fulfil query", code=code, mapped_code=error_code) - raise UnprocessableEntityException( - error_code=error_code, - message=problem.get("detail") or "Unable to fulfil the request", - ) + if response.status_code == 422 and problems is not None: + try: + problem = response.json() + except ValueError: # JSONDecodeError -- HTML error page, empty body, truncation + problem = None + if isinstance(problem, dict): + 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) @@ -218,6 +248,7 @@ class SRMClient: "/internal/network-queries/location", json=query.model_dump(mode="json", exclude_none=True), headers=headers, + problems=_QUERY_PROBLEMS, ) except NotFoundException as exc: raise NotFoundException( diff --git a/tests/unit/test_eam_contract.py b/tests/unit/test_eam_contract.py index 06d184f..60b1d72 100644 --- a/tests/unit/test_eam_contract.py +++ b/tests/unit/test_eam_contract.py @@ -292,6 +292,7 @@ class TestInternalHttpPaths: json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, + problems: Any = None, ) -> Any: calls.append((method, path)) return [] @@ -316,6 +317,7 @@ class TestInternalHttpPaths: json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, + problems: Any = None, ) -> Any: nonlocal recorded_params recorded_params = params @@ -375,6 +377,7 @@ class TestInternalHttpPaths: json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, + problems: Any = None, ) -> Any: return srm_response diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py index f162d8a..d129d2a 100644 --- a/tests/unit/test_srm_client.py +++ b/tests/unit/test_srm_client.py @@ -12,7 +12,7 @@ from typing import Any import httpx import pytest -from open_exposure_gateway.adapters.http.srm_client import SRMClient +from open_exposure_gateway.adapters.http.srm_client import _PROBLEM_NS, SRMClient from open_exposure_gateway.core.exceptions import ( DownstreamServiceException, ErrorCode, @@ -144,27 +144,54 @@ def _location_query() -> SRMLocationQuery: @pytest.mark.parametrize( - ("problem_code", "expected"), + ("problem_type", "expected", "expected_message"), [ - ("unable_to_locate", ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE), - ("unable_to_fulfill_max_age", ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE), ( - "unable_to_fulfill_max_surface", + "unable-to-locate", + ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_LOCATE, + "The network is unable to locate the device", + ), + ( + "unable-to-fulfill-max-age", + ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_AGE, + "Unable to provide expected freshness for location", + ), + ( + "unable-to-fulfill-max-surface", ErrorCode.LOCATION_RETRIEVAL_UNABLE_TO_FULFILL_MAX_SURFACE, + "Unable to provide accurate acceptable surface for location", + ), + ( + "unsupported-identifier", + ErrorCode.UNSUPPORTED_IDENTIFIER, + "The identifier provided is not supported.", + ), + ( + "service-not-applicable", + ErrorCode.SERVICE_NOT_APPLICABLE, + "The service is not available for the provided identifier.", ), - ("unsupported_identifier", ErrorCode.UNSUPPORTED_IDENTIFIER), - ("service_not_applicable", ErrorCode.SERVICE_NOT_APPLICABLE), ], ) -async def test_422_problem_code_maps_to_camara_code( - monkeypatch: pytest.MonkeyPatch, problem_code: str, expected: ErrorCode +async def test_422_problem_type_maps_to_camara_code( + monkeypatch: pytest.MonkeyPatch, + problem_type: str, + expected: ErrorCode, + expected_message: str, ) -> None: - """SRM's neutral RFC 7807 code is the only signal for conditions only the network knows; - this is the table that turns it into what the CAMARA consumer branches on.""" + """RFC 7807's `type` is the only machine-readable signal for conditions only the network + knows (srm/interface-contract.md §E.2); this is the table that turns it into what the + CAMARA consumer branches on. The message comes from the vendored CAMARA spec, not from + SRM's `detail` -- see the next test.""" client = _client( monkeypatch, lambda request: httpx.Response( - 422, json={"status": 422, "code": problem_code, "detail": "nope"} + 422, + json={ + "status": 422, + "type": f"{_PROBLEM_NS}{problem_type}", + "detail": "internal detail", + }, ), ) @@ -172,21 +199,92 @@ async def test_422_problem_code_maps_to_camara_code( await client.retrieve_location(_location_query()) assert exc_info.value.error_code == expected - assert exc_info.value.message == "nope" + assert exc_info.value.message == expected_message + + +async def test_srm_problem_detail_is_not_echoed_to_the_consumer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`detail` is SRM's internal diagnostic text. It is logged, but the body OEG returns + crosses the trust boundary to the app provider, so it carries the spec's wording.""" + client = _client( + monkeypatch, + lambda request: httpx.Response( + 422, + json={ + "status": 422, + "type": f"{_PROBLEM_NS}unable-to-locate", + "detail": "adapter oai-nef-01 timed out on subscriber 262011234567890", + }, + ), + ) + + with pytest.raises(UnprocessableEntityException) as exc_info: + await client.retrieve_location(_location_query()) + + assert "oai-nef-01" not in exc_info.value.message + assert exc_info.value.message == "The network is unable to locate the device" -@pytest.mark.parametrize("body", [{"status": 422, "code": "something_new"}, {"status": 422}]) +async def test_422_on_other_endpoints_stays_a_downstream_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The 422 vocabulary belongs to the sync query paths. Neither the EAM nor the QoD + vendored spec documents a 422 at all, so SRM answering 422 on a zone read must not + become a CAMARA 422 with a location-retrieval code.""" + client = _client( + monkeypatch, + lambda request: httpx.Response( + 422, json={"status": 422, "title": "Validation failed", "detail": "zone filter invalid"} + ), + ) + + with pytest.raises(DownstreamServiceException): + await client.get_zones(x_correlator=None) + + +@pytest.mark.parametrize( + "body", + [ + {"status": 422, "type": "https://etsi.org/sdg/oop/problems/something-new"}, + {"status": 422, "type": "about:blank"}, + {"status": 422}, + ], +) async def test_unrecognized_422_falls_back_to_service_not_applicable( monkeypatch: pytest.MonkeyPatch, body: dict[str, Any] ) -> None: """An SRM build newer than this OEG must not produce a 500. SERVICE_NOT_APPLICABLE is a - documented CAMARA code and an honest answer.""" + documented CAMARA code and an honest answer, so adding a problem type on SRM's side stays + backward-compatible.""" client = _client(monkeypatch, lambda request: httpx.Response(422, json=body)) with pytest.raises(UnprocessableEntityException) as exc_info: await client.retrieve_location(_location_query()) assert exc_info.value.error_code == ErrorCode.SERVICE_NOT_APPLICABLE + assert exc_info.value.message == "The service is not available for the provided identifier." + + +@pytest.mark.parametrize( + ("name", "response_kwargs"), + [ + ("html error page", {"content": b"502 Bad Gateway"}), + ("empty body", {"content": b""}), + ("json but not an object", {"json": ["something"]}), + ], +) +async def test_unreadable_422_body_is_a_downstream_failure( + monkeypatch: pytest.MonkeyPatch, name: str, response_kwargs: dict[str, Any] +) -> None: + """A 422 whose body is not a problem document must not surface as a 500 -- that reads as + an OEG bug when the fault is downstream. It is also distinct from an unrecognized problem + type: there we know SRM refused, here we cannot confirm the response even came from SRM + rather than a proxy in between, so it stays retryable.""" + client = _client(monkeypatch, lambda request: httpx.Response(422, **response_kwargs)) + + with pytest.raises(DownstreamServiceException): + await client.retrieve_location(_location_query()) async def test_location_404_is_identifier_not_found(monkeypatch: pytest.MonkeyPatch) -> None: -- GitLab From ab6a7639c492961c245be15d1334c18cc9886373 Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 17:38:49 +0300 Subject: [PATCH 4/9] feat: send CAMARA ipv4 port and private address to SRM Both mappers put device.ipv4Address.publicAddress into target.device.ipv4 and dropped publicPort and privateAddress, because the canonical target had nowhere to put them -- srm/canonical-parameters-schema.md mapped the whole CAMARA object onto one string. CAMARA requires publicAddress plus at least one of privateAddress or publicPort because "in general, mobile devices cannot be identified by their public IPv4 address alone". Under carrier NAT one public address is shared by thousands of subscribers and the port is what selects one of them, so sending the address alone asks SRM to resolve a device from information the spec calls insufficient. CAMARA's own conformance example is {publicAddress, publicPort}. target.device gains ipv4_port and ipv4_private alongside ipv4. Deliberately not folded into `ports`: that field is CAMARA's separate devicePorts -- which ports a capability acts on -- and a QoD CreateSession can carry both at once, so one list would leave SRM unable to tell a subscriber-identifying port from a port to shape. Same line ADR-0037 drew for Traffic Influence's traffic_filters. QoD's build_session_info now reads the fields back. model_construct stays, but as a genuine fallback: sessions created before this change hold only the bare address, so their reconstruction can still fall short of CAMARA's rule. Additive -- ipv4 keeps its type and meaning, so no schema_version bump. Co-Authored-By: Claude Opus 5 --- .../mappers/location_retrieval_mapper.py | 2 ++ .../mappers/quality_on_demand_mapper.py | 20 ++++++++------ .../domain/quality_on_demand.py | 6 +++++ tests/unit/test_location_retrieval_mapper.py | 26 ++++++++++++++++--- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py b/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py index 07d3a11..e5edcf8 100644 --- a/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py +++ b/src/open_exposure_gateway/application/mappers/location_retrieval_mapper.py @@ -39,6 +39,8 @@ def build_location_query( device=NetworkCapabilityTargetDevice( phone_number=device.phoneNumber, ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None, + ipv4_port=device.ipv4Address.publicPort if device.ipv4Address else None, + ipv4_private=device.ipv4Address.privateAddress if device.ipv4Address else None, ipv6=device.ipv6Address, network_access_id=device.networkAccessIdentifier, ) diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index 1b1fd4c..b256ecd 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -59,6 +59,8 @@ def build_activate_command( device=NetworkCapabilityTargetDevice( phone_number=device.phoneNumber, ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None, + ipv4_port=device.ipv4Address.publicPort if device.ipv4Address else None, + ipv4_private=device.ipv4Address.privateAddress if device.ipv4Address else None, ipv6=device.ipv6Address, network_access_id=device.networkAccessIdentifier, ports=_to_capability_ports(request.devicePorts), @@ -96,15 +98,17 @@ def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability ipv4_address = None if device_target.ipv4: - # Only the bare address survives on SRM's side (build_activate_command never sends - # privateAddress/publicPort), so this can't fully round-trip a CAMARA DeviceIpv4Addr. - # model_construct bypasses DeviceIpv4Addr's "publicAddress + one of private/port" - # validator, which exists to constrain client input, not this internal reconstruction. - # Device.model_construct is required alongside it: pydantic revalidates nested model - # fields against their own validators even when a pre-built instance is passed in, so - # the bypass has to hold at every level enclosing the incomplete address. + # Sessions created before ipv4_port/ipv4_private were sent still hold only the bare + # address, so the reconstruction can still fall short of DeviceIpv4Addr's "publicAddress + # + one of private/port" rule. model_construct bypasses that validator, which exists to + # constrain client input, not this internal reconstruction. Device.model_construct is + # required alongside it: pydantic revalidates nested model fields against their own + # validators even when a pre-built instance is passed in, so the bypass has to hold at + # every level enclosing a potentially incomplete address. ipv4_address = DeviceIpv4Addr.model_construct( - publicAddress=device_target.ipv4, privateAddress=None, publicPort=None + publicAddress=device_target.ipv4, + privateAddress=device_target.ipv4_private, + publicPort=device_target.ipv4_port, ) device = Device.model_construct( diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py index 7f5a310..44994de 100644 --- a/src/open_exposure_gateway/domain/quality_on_demand.py +++ b/src/open_exposure_gateway/domain/quality_on_demand.py @@ -31,7 +31,13 @@ class NetworkCapabilityPorts(BaseModel): class NetworkCapabilityTargetDevice(BaseModel): phone_number: str | None = None + # `ipv4` is CAMARA's publicAddress; `ipv4_port`/`ipv4_private` are the other half of its + # DeviceIpv4Addr. CAMARA requires publicAddress plus one of them because a NAT'd public + # address alone identifies thousands of subscribers. Distinct from `ports` below, which is + # CAMARA's separate devicePorts -- what to act on, not which device this is. ipv4: str | None = None + ipv4_port: int | None = None + ipv4_private: str | None = None ipv6: str | None = None network_access_id: str | None = None ports: NetworkCapabilityPorts | None = None diff --git a/tests/unit/test_location_retrieval_mapper.py b/tests/unit/test_location_retrieval_mapper.py index 92f9ee2..cddab28 100644 --- a/tests/unit/test_location_retrieval_mapper.py +++ b/tests/unit/test_location_retrieval_mapper.py @@ -69,9 +69,11 @@ class TestBuildLocationQuery: assert device.ipv4 == "84.125.93.10" assert device.ipv6 == "2001:db8:85a3:8d3:1319:8a2e:370:7344" - def test_only_the_public_ipv4_address_crosses(self) -> None: - """The canonical target carries a single `ipv4` string, so privateAddress and - publicPort have nowhere to go — the same lossy step QoD's mapper documents.""" + def test_the_whole_ipv4_address_crosses(self) -> None: + """Under carrier NAT one public address is shared by thousands of subscribers, so + CAMARA requires publicAddress plus privateAddress or publicPort -- the second half is + what selects one device. Sending only the address asks SRM to identify a subscriber + from information CAMARA says is insufficient.""" request = RetrievalLocationRequest( device=Device( ipv4Address=DeviceIpv4Addr( @@ -80,7 +82,23 @@ class TestBuildLocationQuery: ) ) - assert _query(request).target.device.ipv4 == "84.125.93.10" # type: ignore[attr-defined] + device = _query(request).target.device # type: ignore[attr-defined] + + assert device.ipv4 == "84.125.93.10" + assert device.ipv4_port == 59765 + assert device.ipv4_private == "10.0.0.1" + + def test_ipv4_identification_is_not_folded_into_ports(self) -> None: + """`ports` is CAMARA's separate devicePorts -- what a capability acts on. A NAT source + port says which device this is. QoD can carry both at once, so merging them would + leave SRM unable to tell them apart.""" + request = RetrievalLocationRequest( + device=Device( + ipv4Address=DeviceIpv4Addr(publicAddress="84.125.93.10", publicPort=59765) + ) + ) + + assert _query(request).target.device.ports is None # type: ignore[attr-defined] @pytest.mark.parametrize("max_age", [-5, 0, 120]) def test_max_age_is_forwarded_including_zero(self, max_age: int) -> None: -- GitLab From 0b2b271f6cfc92097fd5bd9aaabd990cd1ddea3f Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 17:39:39 +0300 Subject: [PATCH 5/9] fix: resolve one correlation id per request and use it everywhere x-correlator is optional, and when a caller omitted it three separate pieces of code each noticed the absence and invented their own answer: the service generated one for the SRM request body, the SRM client sent no header at all, and x_correlator_header generated a different one for the response. One request produced two unrelated UUIDs and no downstream header. The customer was handed an id that existed only in OEG's response header. It was never sent anywhere, so quoting it to support matched nothing in SRM's logs, and the id SRM did record was one the customer never saw. The correlation id looked like a working feature and did nothing. oeg/architecture.md is explicit that these are one value, and REQ-OEG-13 requires propagating it to downstream HTTP calls -- so this was a bug, not an open question. Resolve it once at the edge and read that everywhere: x_correlator_header caches on request.state, get_caller_context takes the resolved value, and routers pass caller.x_correlator. EAM already did this, so it was fixed by the first two changes alone; QoD's get_qod_session had no caller parameter and gains one. LocationRetrievalService also sent the raw parameter as the SRM header while building correlation_id for the body. Through the router those coincide, but a direct call with None rebuilt the same divergence inside the service, and tests and the conformance harness take that path. test_passes_region_and_status_filters asserted x_correlator=None -- it encoded the bug, so it is updated rather than preserved. The three new flow tests were verified to fail when the wiring is reverted; a guard that cannot fail is not a guard. Co-Authored-By: Claude Opus 5 --- .../camara/location_retrieval/v0_5_0/router.py | 2 +- .../camara/quality_on_demand/v0_10_1/router.py | 7 ++++--- src/open_exposure_gateway/api/error_handlers.py | 16 +++++++++++++--- .../services/location_retrieval_service.py | 5 ++++- src/open_exposure_gateway/dependencies.py | 7 ++++++- tests/unit/test_eam_endpoints.py | 9 ++++++--- tests/unit/test_eam_flows.py | 13 +++++++++++++ tests/unit/test_location_retrieval_flows.py | 11 ++++++++--- tests/unit/test_qod_flows.py | 17 +++++++++++++++++ 9 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py index e82eaa3..c084cc5 100644 --- a/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py +++ b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py @@ -75,5 +75,5 @@ async def retrieve_location( return await service.retrieve_location( request=request, app_provider_id=caller.app_provider_id, - x_correlator=x_correlator, + x_correlator=caller.x_correlator, ) diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py index 6ed649f..4db5584 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py @@ -72,7 +72,7 @@ async def create_qod_session( request=request, tenant_id=caller.tenant_id, app_provider_id=caller.app_provider_id, - x_correlator=x_correlator, + x_correlator=caller.x_correlator, ) @@ -87,11 +87,12 @@ async def create_qod_session( async def get_qod_session( sessionId: str, service: QoDService, + caller: Caller, x_correlator: XCorrelatorHeader = None, ) -> Any: return await service.get_session( session_id=sessionId, - x_correlator=x_correlator, + x_correlator=caller.x_correlator, ) @@ -112,7 +113,7 @@ async def delete_qod_session( session_id=sessionId, tenant_id=caller.tenant_id, app_provider_id=caller.app_provider_id, - x_correlator=x_correlator, + x_correlator=caller.x_correlator, ) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/open_exposure_gateway/api/error_handlers.py b/src/open_exposure_gateway/api/error_handlers.py index 6066602..c5dfdba 100644 --- a/src/open_exposure_gateway/api/error_handlers.py +++ b/src/open_exposure_gateway/api/error_handlers.py @@ -32,10 +32,20 @@ _HTTP_EXCEPTION_MAP: dict[int, type[OEGException]] = { def x_correlator_header(request: Request) -> str: + """The one correlation value for this request. + + Resolved once and cached on `request.state`: a generated id must be the same in the body + OEG sends SRM, the header it sends SRM, and the `X-Correlator` it returns, or the caller + holds an id that appears in no downstream log (oeg/architecture.md REQ-OEG-13). + """ + cached = getattr(request.state, "x_correlator", None) + if isinstance(cached, str): + return cached x_correlator = request.headers.get("x-correlator") - if x_correlator and X_CORRELATOR_PATTERN.match(x_correlator): - return x_correlator - return str(uuid.uuid4()) + if not (x_correlator and X_CORRELATOR_PATTERN.match(x_correlator)): + x_correlator = str(uuid.uuid4()) + request.state.x_correlator = x_correlator + return x_correlator def register_exception_handlers(app: FastAPI) -> None: diff --git a/src/open_exposure_gateway/application/services/location_retrieval_service.py b/src/open_exposure_gateway/application/services/location_retrieval_service.py index be3e56d..9d684e9 100644 --- a/src/open_exposure_gateway/application/services/location_retrieval_service.py +++ b/src/open_exposure_gateway/application/services/location_retrieval_service.py @@ -50,6 +50,9 @@ class LocationRetrievalService: message="The device cannot be identified.", ) + # One value for both surfaces. The router always supplies the caller context's + # correlator, but a direct call with None must not put a generated id in the body and + # nothing in the header -- that is the divergence this fallback exists to close. correlation_id = x_correlator or str(uuid4()) query = build_location_query( request=request, @@ -61,7 +64,7 @@ class LocationRetrievalService: result = await self.srm_client.retrieve_location( query=query, - x_correlator=x_correlator, + x_correlator=correlation_id, ) try: diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index 3c721d2..1b9f2bd 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -25,6 +25,7 @@ from open_exposure_gateway.adapters.database.repos.qod_sessions import ( SqlQodSessionRepository, ) from open_exposure_gateway.api.camara.common import XCorrelatorHeader +from open_exposure_gateway.api.error_handlers import x_correlator_header from open_exposure_gateway.application.services.edge_application_management_service import ( EdgeApplicationManagementService, ) @@ -57,10 +58,14 @@ class CallerContext: def get_caller_context( + request: Request, x_correlator: XCorrelatorHeader = None, ) -> CallerContext: + # `x_correlator` stays declared so FastAPI still validates the header against CAMARA's + # pattern and documents it; the value comes from x_correlator_header, which generates and + # caches one when the caller sent none, so every surface reports the same id. return CallerContext( - x_correlator=x_correlator, + x_correlator=x_correlator_header(request), tenant_id="placeholder", # TODO: extract from JWT app_provider_id="placeholder", # TODO: extract from JWT ) diff --git a/tests/unit/test_eam_endpoints.py b/tests/unit/test_eam_endpoints.py index 1ece8e1..7f37155 100644 --- a/tests/unit/test_eam_endpoints.py +++ b/tests/unit/test_eam_endpoints.py @@ -95,9 +95,12 @@ class TestGetEdgeCloudZones: self, client: TestClient, mock_eam_service: AsyncMock ) -> None: client.get(f"{EAM_BASE}/edge-cloud-zones?region=eu-west&status=active") - mock_eam_service.get_edge_cloud_zones.assert_called_once_with( - region="eu-west", status="active", x_correlator=None - ) + # x_correlator is never None: when the caller sends no header OEG generates one at the + # edge and uses that single value downstream and in the response (REQ-OEG-13). + kwargs = mock_eam_service.get_edge_cloud_zones.call_args.kwargs + assert kwargs["region"] == "eu-west" + assert kwargs["status"] == "active" + assert kwargs["x_correlator"] class TestSubmitApp: diff --git a/tests/unit/test_eam_flows.py b/tests/unit/test_eam_flows.py index 4ce7404..0c23f70 100644 --- a/tests/unit/test_eam_flows.py +++ b/tests/unit/test_eam_flows.py @@ -202,6 +202,19 @@ class TestCreateAppInstanceFlow: _, command = fake_bus.published[0] assert command["correlation_id"] == "corr-123" + def test_generated_correlation_id_is_the_one_the_caller_is_given( + self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient + ) -> None: + """x-correlator is optional, so most callers omit it and OEG generates one at the + edge. The generated value must be the same in the command SRM consumes and in the + header the caller can quote to support, or the caller holds an id that appears in no + downstream log (oeg/architecture.md REQ-OEG-13).""" + response = api_client.post(f"{EAM_BASE}/appinstances", json=CREATE_INSTANCE_BODY) + + _, command = fake_bus.published[0] + assert command["correlation_id"] + assert response.headers["x-correlator"] == command["correlation_id"] + def test_each_request_gets_a_distinct_operation_id( self, api_client: TestClient, fake_bus: FakeDataBus, live_srm: FakeSRMClient ) -> None: diff --git a/tests/unit/test_location_retrieval_flows.py b/tests/unit/test_location_retrieval_flows.py index b1f97df..202050e 100644 --- a/tests/unit/test_location_retrieval_flows.py +++ b/tests/unit/test_location_retrieval_flows.py @@ -296,9 +296,14 @@ class TestCorrelation: assert response.headers["x-correlator"] == "corr-loc-001" assert located_device.location_queries[0].correlation_id == "corr-loc-001" - def test_correlation_id_is_generated_when_header_absent( + def test_generated_correlation_id_is_the_one_the_caller_is_given( self, api_client: TestClient, located_device: FakeSRMClient ) -> None: - api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) + """One value, or the caller holds an id that appears in no downstream log. When the + header is absent OEG generates it at the edge, so what SRM records and what the caller + can quote to support must be the same string (oeg/architecture.md REQ-OEG-13).""" + response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}}) - assert located_device.location_queries[0].correlation_id + generated = located_device.location_queries[0].correlation_id + assert generated + assert response.headers["x-correlator"] == generated diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 7f3c51f..831cc99 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -49,6 +49,18 @@ class TestQodSessionFlow: assert body["qosProfile"] == "QOS_E" assert "sessionId" in body + def test_generated_correlation_id_is_the_one_the_caller_is_given( + self, api_client: TestClient, fake_bus: FakeDataBus + ) -> None: + """Same invariant as EAM and Location Retrieval: when the caller sends no + x-correlator, the id OEG generates must reach SRM and come back to the caller as one + value (oeg/architecture.md REQ-OEG-13).""" + response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) + + _, command = fake_bus.published[0] + assert command["correlation_id"] + assert response.headers["x-correlator"] == command["correlation_id"] + def test_create_session_accepts_webhook(self, api_client: TestClient) -> None: body = {**SESSION_BODY, "webhook": {"notificationUrl": "https://application-server.com"}} response = api_client.post(f"{QOD_BASE}/sessions", json=body) @@ -65,6 +77,10 @@ class TestQodSessionFlow: assert command.network_capability.profile_ref == "QOS_E" assert command.network_capability.parameters.duration_seconds == 3600 assert command.network_capability.target.application_server.ipv4 == "192.168.0.1" + assert command.network_capability.target.device.ipv4 == "84.125.93.10" + # The NAT'd public address alone names thousands of subscribers; the port is what + # picks out this device, so dropping it would leave SRM unable to resolve the target. + assert command.network_capability.target.device.ipv4_port == 59765 assert command.source == "nbi_camara" def test_persists_operation_and_qod_session_rows( @@ -177,6 +193,7 @@ class TestQodSessionFlow: assert body["sessionId"] == session_id assert body["qosStatus"] == "REQUESTED" assert body["applicationServer"]["ipv4Address"] == "192.168.0.1" + assert body["device"]["ipv4Address"]["publicPort"] == 59765 def test_get_session_returns_404_when_unknown(self, api_client: TestClient) -> None: response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}") -- GitLab From c9fd3508e54e0981c8a5068942eca950c3df648a Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 17:51:53 +0300 Subject: [PATCH 6/9] fix: remove unnecessary comments --- .../application/mappers/quality_on_demand_mapper.py | 7 ------- .../application/services/location_retrieval_service.py | 8 -------- src/open_exposure_gateway/dependencies.py | 3 --- tests/unit/test_eam_endpoints.py | 2 -- tests/unit/test_qod_flows.py | 5 ----- 5 files changed, 25 deletions(-) diff --git a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py index b256ecd..a773f51 100644 --- a/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py +++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py @@ -98,13 +98,6 @@ def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability ipv4_address = None if device_target.ipv4: - # Sessions created before ipv4_port/ipv4_private were sent still hold only the bare - # address, so the reconstruction can still fall short of DeviceIpv4Addr's "publicAddress - # + one of private/port" rule. model_construct bypasses that validator, which exists to - # constrain client input, not this internal reconstruction. Device.model_construct is - # required alongside it: pydantic revalidates nested model fields against their own - # validators even when a pre-built instance is passed in, so the bypass has to hold at - # every level enclosing a potentially incomplete address. ipv4_address = DeviceIpv4Addr.model_construct( publicAddress=device_target.ipv4, privateAddress=device_target.ipv4_private, diff --git a/src/open_exposure_gateway/application/services/location_retrieval_service.py b/src/open_exposure_gateway/application/services/location_retrieval_service.py index 9d684e9..b2841a9 100644 --- a/src/open_exposure_gateway/application/services/location_retrieval_service.py +++ b/src/open_exposure_gateway/application/services/location_retrieval_service.py @@ -39,20 +39,12 @@ class LocationRetrievalService: app_provider_id: str, x_correlator: Optional[str] = None, ) -> Location: - # CAMARA makes `device` optional only because a three-legged token identifies the - # subject on its own. OEG has no JWT subject extraction yet (see - # dependencies.get_caller_context), so every call is effectively two-legged and the - # device must be supplied. UNNECESSARY_IDENTIFIER -- the mirror error, for a device - # sent alongside a three-legged token -- is therefore unreachable until that lands. if request.device is None: raise UnprocessableEntityException( error_code=ErrorCode.MISSING_IDENTIFIER, message="The device cannot be identified.", ) - # One value for both surfaces. The router always supplies the caller context's - # correlator, but a direct call with None must not put a generated id in the body and - # nothing in the header -- that is the divergence this fallback exists to close. correlation_id = x_correlator or str(uuid4()) query = build_location_query( request=request, diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py index 1b9f2bd..afe4e08 100644 --- a/src/open_exposure_gateway/dependencies.py +++ b/src/open_exposure_gateway/dependencies.py @@ -61,9 +61,6 @@ def get_caller_context( request: Request, x_correlator: XCorrelatorHeader = None, ) -> CallerContext: - # `x_correlator` stays declared so FastAPI still validates the header against CAMARA's - # pattern and documents it; the value comes from x_correlator_header, which generates and - # caches one when the caller sent none, so every surface reports the same id. return CallerContext( x_correlator=x_correlator_header(request), tenant_id="placeholder", # TODO: extract from JWT diff --git a/tests/unit/test_eam_endpoints.py b/tests/unit/test_eam_endpoints.py index 7f37155..0bd1659 100644 --- a/tests/unit/test_eam_endpoints.py +++ b/tests/unit/test_eam_endpoints.py @@ -95,8 +95,6 @@ class TestGetEdgeCloudZones: self, client: TestClient, mock_eam_service: AsyncMock ) -> None: client.get(f"{EAM_BASE}/edge-cloud-zones?region=eu-west&status=active") - # x_correlator is never None: when the caller sends no header OEG generates one at the - # edge and uses that single value downstream and in the response (REQ-OEG-13). kwargs = mock_eam_service.get_edge_cloud_zones.call_args.kwargs assert kwargs["region"] == "eu-west" assert kwargs["status"] == "active" diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py index 831cc99..56bf2d0 100644 --- a/tests/unit/test_qod_flows.py +++ b/tests/unit/test_qod_flows.py @@ -52,9 +52,6 @@ class TestQodSessionFlow: def test_generated_correlation_id_is_the_one_the_caller_is_given( self, api_client: TestClient, fake_bus: FakeDataBus ) -> None: - """Same invariant as EAM and Location Retrieval: when the caller sends no - x-correlator, the id OEG generates must reach SRM and come back to the caller as one - value (oeg/architecture.md REQ-OEG-13).""" response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY) _, command = fake_bus.published[0] @@ -78,8 +75,6 @@ class TestQodSessionFlow: assert command.network_capability.parameters.duration_seconds == 3600 assert command.network_capability.target.application_server.ipv4 == "192.168.0.1" assert command.network_capability.target.device.ipv4 == "84.125.93.10" - # The NAT'd public address alone names thousands of subscribers; the port is what - # picks out this device, so dropping it would leave SRM unable to resolve the target. assert command.network_capability.target.device.ipv4_port == 59765 assert command.source == "nbi_camara" -- GitLab From a84df597c7e1e85693e2953d6b3afc7be98fa93a Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 25 Aug 2026 18:25:00 +0300 Subject: [PATCH 7/9] feat: enhance error handling for unusable location responses from SRM --- .../adapters/http/srm_client.py | 17 ++++- tests/unit/test_srm_client.py | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index b353a6a..9a400fb 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -3,6 +3,7 @@ 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 ( @@ -255,4 +256,18 @@ class SRMClient: message="Device identifier not found.", error_code=ErrorCode.IDENTIFIER_NOT_FOUND, ) from exc - return SRMLocationResult.model_validate(data) + + 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 diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py index d129d2a..f2fc785 100644 --- a/tests/unit/test_srm_client.py +++ b/tests/unit/test_srm_client.py @@ -349,3 +349,65 @@ async def test_location_query_targets_the_internal_base_path( await client.retrieve_location(_location_query()) assert called["path"] == "/internal/network-queries/location" + + +_VALID_AREA = { + "area_type": "circle", + "center": {"latitude": 45.75, "longitude": 4.86}, + "radius_m": 800, +} + + +@pytest.mark.parametrize( + ("name", "body"), + [ + ("last_location_time missing", {"area": _VALID_AREA}), + ( + "area_type in CAMARA casing instead of canonical", + { + "last_location_time": "2023-10-17T13:18:23.682Z", + "area": {**_VALID_AREA, "area_type": "CIRCLE"}, + }, + ), + ( + "timestamp without a time zone", + {"last_location_time": "2023-10-17T13:18:23", "area": _VALID_AREA}, + ), + ("area missing entirely", {"last_location_time": "2023-10-17T13:18:23.682Z"}), + ], +) +async def test_unusable_200_location_body_is_a_downstream_failure( + monkeypatch: pytest.MonkeyPatch, name: str, body: dict[str, Any] +) -> None: + client = _client(monkeypatch, lambda request: httpx.Response(200, json=body)) + + with pytest.raises(DownstreamServiceException) as exc_info: + await client.retrieve_location(_location_query()) + + assert exc_info.value.status_code == 503 + + +async def test_unusable_location_body_is_not_echoed_to_the_consumer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """pydantic renders the offending input into its error text, and here that input is the + subscriber's coordinates. Neither the outward message nor `details` may carry it.""" + client = _client( + monkeypatch, + lambda request: httpx.Response( + 200, + json={ + "area": { + "area_type": "circle", + "center": {"latitude": 45.754114, "longitude": 4.860374}, + "radius_m": 800, + } + }, + ), + ) + + with pytest.raises(DownstreamServiceException) as exc_info: + await client.retrieve_location(_location_query()) + + assert exc_info.value.message == "SRM returned an unusable location" + assert "45.754114" not in str(exc_info.value.details) -- GitLab From 05365df9c75188dff5dec3de3e80e7d5c84db27d Mon Sep 17 00:00:00 2001 From: dgogos Date: Mon, 31 Aug 2026 16:45:14 +0300 Subject: [PATCH 8/9] feat: refactor service specification ID handling for QoD and Location Retrieval --- src/open_exposure_gateway/core/config.py | 34 +++++++++++++++--------- tests/unit/test_config.py | 28 +++++++++---------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/open_exposure_gateway/core/config.py b/src/open_exposure_gateway/core/config.py index d2c0528..e898f9c 100644 --- a/src/open_exposure_gateway/core/config.py +++ b/src/open_exposure_gateway/core/config.py @@ -1,23 +1,31 @@ from functools import lru_cache -from uuid import UUID +from uuid import UUID, uuid5 from pydantic import BaseModel, HttpUrl from pydantic_settings import BaseSettings, SettingsConfigDict -# The platform seeds one QoD service specification at deployment (ADR-0035); every -# activate carries its id. There is no CAMARA registration step to resolve it from, -# so the id cannot be derived per request -- minting one would reference no catalog -# row and SRM would answer failed_before_start. This is the well-known id used when -# nobody sets one at deploy time; SRM's topology bootstrap must seed the same value. +# QoD and Location Retrieval have no CAMARA registration step to resolve a +# service_specification_id from, so each uses one platform-seeded specification at a +# well-known id (ADR-0035). Both sides derive that id from the recipe in SRM Interface +# Contract §E.2 rather than pasting a constant, so neither can go stale if the recipe +# changes; SRM's bootstrap seeds the same value. -DEFAULT_QOD_SERVICE_SPECIFICATION_ID = UUID("7608e902-b927-559f-b448-e7e9061dfa5c") +_SPECIFICATION_VERSION = "1" +_NAMESPACE_URL = UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8") -# Location Retrieval has no registration step either, so it resolves its -# service_specification_id the same way (ADR-0035's pattern applied to a second family). -# It persists nothing -- a sync query writes no service_order or capability_instance -- -# but the internal query body still requires the field, and SRM uses it to resolve the -# control path for the location_context capability. -DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID = UUID("0129e7ce-8e02-5dfd-bae1-0303bcc7676e") + +def well_known_service_specification_id(ref: str) -> UUID: + return uuid5( + _NAMESPACE_URL, + f"https://etsi.org/sdg/oop/service-specification/{ref}/{_SPECIFICATION_VERSION}", + ) + + +DEFAULT_QOD_SERVICE_SPECIFICATION_ID = well_known_service_specification_id("qod-session") + +DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID = well_known_service_specification_id( + "location-context" +) class SRMSettings(BaseModel): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8b508fe..81b9782 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -8,6 +8,7 @@ from open_exposure_gateway.core.config import ( DEFAULT_QOD_SERVICE_SPECIFICATION_ID, Settings, get_settings, + well_known_service_specification_id, ) @@ -93,23 +94,20 @@ class TestDefaults: assert settings.location_retrieval_settings.uses_default_service_specification_id is True -class TestWellKnownQodServiceSpecificationId: - def test_constant_matches_its_documented_derivation(self) -> None: - """The default is a cross-component contract: SRM's bootstrap must seed this - exact id. Both sides derive it from the same URL rather than copying a literal, - so this pins the recipe -- changing the constant without changing the URL (or - the reverse) is a silent break that only shows up as failed_before_start.""" - assert DEFAULT_QOD_SERVICE_SPECIFICATION_ID == uuid5( - NAMESPACE_URL, "https://etsi.org/sdg/oop/service-specification/qod-session/1" - ) +class TestWellKnownServiceSpecificationIds: + """The ids are derived, so the literals live here rather than in src""" + def test_qod_matches_the_published_id(self) -> None: + assert DEFAULT_QOD_SERVICE_SPECIFICATION_ID == UUID("7608e902-b927-559f-b448-e7e9061dfa5c") -class TestWellKnownLocationRetrievalServiceSpecificationId: - def test_constant_matches_its_documented_derivation(self) -> None: - """Same cross-component contract as the QoD id, same recipe -- only the `ref` - differs. Pinning it here is what stops the two sides drifting apart silently.""" - assert DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID == uuid5( - NAMESPACE_URL, "https://etsi.org/sdg/oop/service-specification/location-context/1" + def test_location_retrieval_matches_the_published_id(self) -> None: + assert DEFAULT_LOCATION_RETRIEVAL_SERVICE_SPECIFICATION_ID == UUID( + "0129e7ce-8e02-5dfd-bae1-0303bcc7676e" + ) + + def test_the_recipe_is_the_documented_url(self) -> None: + assert well_known_service_specification_id("qod-session") == uuid5( + NAMESPACE_URL, "https://etsi.org/sdg/oop/service-specification/qod-session/1" ) -- GitLab From 73874c2b222433092ca4f615cf097ea6ffe8cea0 Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 1 Sep 2026 00:13:45 +0300 Subject: [PATCH 9/9] feat: implement validation for IPv4 and IPv6 addresses in schemas and enhance error handling for SRM responses --- .../adapters/http/srm_client.py | 45 ++++++++++------ .../api/camara/common.py | 14 +++++ .../location_retrieval/v0_5_0/schemas.py | 18 ++++++- .../quality_on_demand/v0_10_1/schemas.py | 14 ++++- tests/unit/test_location_retrieval_flows.py | 17 +++--- tests/unit/test_location_retrieval_mapper.py | 2 +- tests/unit/test_srm_client.py | 54 ++++++++++++++++--- 7 files changed, 130 insertions(+), 34 deletions(-) diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py index 9a400fb..0bd41a4 100644 --- a/src/open_exposure_gateway/adapters/http/srm_client.py +++ b/src/open_exposure_gateway/adapters/http/srm_client.py @@ -56,6 +56,18 @@ _QUERY_PROBLEMS: dict[str, tuple[ErrorCode, str]] = { _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: @@ -89,26 +101,25 @@ 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: - try: - problem = response.json() - except ValueError: # JSONDecodeError -- HTML error page, empty body, truncation - problem = None - if isinstance(problem, dict): - 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 == 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) diff --git a/src/open_exposure_gateway/api/camara/common.py b/src/open_exposure_gateway/api/camara/common.py index 4b6fef0..0b57372 100644 --- a/src/open_exposure_gateway/api/camara/common.py +++ b/src/open_exposure_gateway/api/camara/common.py @@ -1,4 +1,5 @@ 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 diff --git a/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py index 2d510e2..9b4ee99 100644 --- a/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py +++ b/src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/schemas.py @@ -1,7 +1,9 @@ from enum import StrEnum from typing import Annotated, Literal, Optional, Union -from pydantic import AwareDatetime, BaseModel, Field, model_validator +from pydantic import AwareDatetime, BaseModel, Field, field_validator, model_validator + +from open_exposure_gateway.api.camara.common import validated_ipv4, validated_ipv6 class AreaType(StrEnum): @@ -14,6 +16,11 @@ class DeviceIpv4Addr(BaseModel): privateAddress: Optional[str] = None publicPort: Optional[int] = Field(default=None, ge=0, le=65535) + @field_validator("publicAddress", "privateAddress") + @classmethod + def _valid_ipv4(cls, value: Optional[str]) -> Optional[str]: + return validated_ipv4(value) + @model_validator(mode="after") def _require_public_plus_one(self) -> "DeviceIpv4Addr": if self.publicAddress is None: @@ -29,6 +36,11 @@ class Device(BaseModel): ipv4Address: Optional[DeviceIpv4Addr] = None ipv6Address: Optional[str] = None + @field_validator("ipv6Address") + @classmethod + def _valid_ipv6(cls, value: Optional[str]) -> Optional[str]: + return validated_ipv6(value) + @model_validator(mode="after") def _require_at_least_one_identifier(self) -> "Device": if not any( @@ -58,7 +70,9 @@ class DeviceResponse(Device): class RetrievalLocationRequest(BaseModel): device: Optional[Device] = None - maxAge: Optional[int] = None + # CAMARA leaves maxAge unbounded; SRM's canonical max_age_seconds is >= 0 (§E.2), so a + # negative value is the client's 400 rather than a downstream rejection. + maxAge: Optional[int] = Field(default=None, ge=0) maxSurface: Optional[int] = Field(default=None, ge=1) diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py index 904b33b..daa6b6c 100644 --- a/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py +++ b/src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/schemas.py @@ -2,7 +2,9 @@ from enum import StrEnum from typing import Literal, Optional from uuid import UUID -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator + +from open_exposure_gateway.api.camara.common import validated_ipv4, validated_ipv6 class QosStatus(StrEnum): @@ -16,6 +18,11 @@ class DeviceIpv4Addr(BaseModel): privateAddress: Optional[str] = None publicPort: Optional[int] = Field(default=None, ge=0, le=65535) + @field_validator("publicAddress", "privateAddress") + @classmethod + def _valid_ipv4(cls, value: Optional[str]) -> Optional[str]: + return validated_ipv4(value) + @model_validator(mode="after") def _require_public_plus_one(self) -> "DeviceIpv4Addr": if self.publicAddress is None: @@ -31,6 +38,11 @@ class Device(BaseModel): ipv4Address: Optional[DeviceIpv4Addr] = None ipv6Address: Optional[str] = None + @field_validator("ipv6Address") + @classmethod + def _valid_ipv6(cls, value: Optional[str]) -> Optional[str]: + return validated_ipv6(value) + @model_validator(mode="after") def _require_at_least_one_identifier(self) -> "Device": if not any( diff --git a/tests/unit/test_location_retrieval_flows.py b/tests/unit/test_location_retrieval_flows.py index 202050e..d7e29f7 100644 --- a/tests/unit/test_location_retrieval_flows.py +++ b/tests/unit/test_location_retrieval_flows.py @@ -121,16 +121,21 @@ class TestQueryTranslation: assert located_device.location_queries[0].parameters.max_age_seconds == 0 - def test_negative_max_age_is_accepted_and_forwarded( + def test_negative_max_age_is_rejected_here( self, api_client: TestClient, located_device: FakeSRMClient ) -> None: - """The vendored schema puts no minimum on maxAge -- deliberately, since maxSurface - beside it does carry one. Rejecting a negative here would 400 a spec-conformant - request; whether the network can honour it is SRM's judgement, not OEG's.""" + """The vendored schema puts no minimum on maxAge, but SRM's canonical + `max_age_seconds` is `>= 0` (Interface Contract §E.2) and SRM enforces it. + + Forwarding a negative would buy CAMARA schema-conformance and pay for it with a 503: + SRM rejects the body, and a rejected body is a fault OEG cannot report as anything + useful. A 400 naming the field is the answer the caller can act on, so OEG is + deliberately stricter than the vendored schema on this one field.""" response = api_client.post(RETRIEVE, json={"device": {"phoneNumber": PHONE}, "maxAge": -5}) - assert response.status_code == 200 - assert located_device.location_queries[0].parameters.max_age_seconds == -5 + assert response.status_code == 400 + assert response.json()["code"] == "INVALID_ARGUMENT" + assert located_device.location_queries == [] def test_absent_max_age_stays_absent( self, api_client: TestClient, located_device: FakeSRMClient diff --git a/tests/unit/test_location_retrieval_mapper.py b/tests/unit/test_location_retrieval_mapper.py index cddab28..3793cee 100644 --- a/tests/unit/test_location_retrieval_mapper.py +++ b/tests/unit/test_location_retrieval_mapper.py @@ -100,7 +100,7 @@ class TestBuildLocationQuery: assert _query(request).target.device.ports is None # type: ignore[attr-defined] - @pytest.mark.parametrize("max_age", [-5, 0, 120]) + @pytest.mark.parametrize("max_age", [0, 120]) def test_max_age_is_forwarded_including_zero(self, max_age: int) -> None: request = RetrievalLocationRequest(device=Device(phoneNumber="+123456789"), maxAge=max_age) diff --git a/tests/unit/test_srm_client.py b/tests/unit/test_srm_client.py index f2fc785..f3f4f1c 100644 --- a/tests/unit/test_srm_client.py +++ b/tests/unit/test_srm_client.py @@ -44,6 +44,15 @@ def _client(monkeypatch: pytest.MonkeyPatch, handler: HttpHandler) -> SRMClient: return client +def _problem(status_code: int, body: dict[str, Any]) -> httpx.Response: + """An RFC 7807 answer as SRM actually sends it -- the media type is what marks it as one.""" + return httpx.Response( + status_code, + content=json.dumps(body).encode(), + headers={"content-type": "application/problem+json"}, + ) + + def _details(exc: DownstreamServiceException) -> dict[str, Any]: assert isinstance(exc.details, dict) return exc.details @@ -185,9 +194,9 @@ async def test_422_problem_type_maps_to_camara_code( SRM's `detail` -- see the next test.""" client = _client( monkeypatch, - lambda request: httpx.Response( + lambda request: _problem( 422, - json={ + { "status": 422, "type": f"{_PROBLEM_NS}{problem_type}", "detail": "internal detail", @@ -209,9 +218,9 @@ async def test_srm_problem_detail_is_not_echoed_to_the_consumer( crosses the trust boundary to the app provider, so it carries the spec's wording.""" client = _client( monkeypatch, - lambda request: httpx.Response( + lambda request: _problem( 422, - json={ + { "status": 422, "type": f"{_PROBLEM_NS}unable-to-locate", "detail": "adapter oai-nef-01 timed out on subscriber 262011234567890", @@ -257,7 +266,7 @@ async def test_unrecognized_422_falls_back_to_service_not_applicable( """An SRM build newer than this OEG must not produce a 500. SERVICE_NOT_APPLICABLE is a documented CAMARA code and an honest answer, so adding a problem type on SRM's side stays backward-compatible.""" - client = _client(monkeypatch, lambda request: httpx.Response(422, json=body)) + client = _client(monkeypatch, lambda request: _problem(422, body)) with pytest.raises(UnprocessableEntityException) as exc_info: await client.retrieve_location(_location_query()) @@ -289,8 +298,12 @@ async def test_unreadable_422_body_is_a_downstream_failure( async def test_location_404_is_identifier_not_found(monkeypatch: pytest.MonkeyPatch) -> None: """The shared _request raises the generic NOT_FOUND; this API's vendored spec pins its - 404 to IDENTIFIER_NOT_FOUND.""" - client = _client(monkeypatch, lambda request: httpx.Response(404)) + 404 to IDENTIFIER_NOT_FOUND. §E.2 leaves the problem `type` at `about:blank` -- the status + is what OEG branches on.""" + client = _client( + monkeypatch, + lambda request: _problem(404, {"status": 404, "type": "about:blank", "title": "Not found"}), + ) with pytest.raises(NotFoundException) as exc_info: await client.retrieve_location(_location_query()) @@ -411,3 +424,30 @@ async def test_unusable_location_body_is_not_echoed_to_the_consumer( assert exc_info.value.message == "SRM returned an unusable location" assert "45.754114" not in str(exc_info.value.details) + + +@pytest.mark.parametrize( + ("name", "status_code", "body"), + [ + ( + "validation error", + 422, + {"detail": [{"loc": ["body", "target"], "msg": "field required"}]}, + ), + ("route not found", 404, {"detail": "Not Found"}), + ], +) +async def test_an_answer_that_is_not_a_problem_document_is_a_downstream_failure( + monkeypatch: pytest.MonkeyPatch, name: str, status_code: int, body: dict[str, Any] +) -> None: + """Every SRM error is RFC 7807 (§E). FastAPI's own shapes mean we sent a body SRM could not + accept, or called a path it does not serve -- our fault, not an answer about the subscriber. + + Translating either would hand the app provider a plausible, documented, non-alerting reply + (SERVICE_NOT_APPLICABLE, IDENTIFIER_NOT_FOUND) for a bug on this side of the wire, which is + exactly the failure mode this gate exists to prevent. + """ + client = _client(monkeypatch, lambda request: httpx.Response(status_code, json=body)) + + with pytest.raises(DownstreamServiceException): + await client.retrieve_location(_location_query()) -- GitLab