diff --git a/.env.example b/.env.example
index e2b3e9a9a91a927364a7e6f7563279dc00ebca9e..444f0992dbdfb57430534163335bbcb0b2de1ee4 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,7 @@
# DEBUG=false
# HOST="0.0.0.0"
# PORT=8080
+# PUBLIC_BASE_URL="http://localhost:8080"
# SRM_SETTINGS__BASE_URL="http://localhost:8081"
# SRM_SETTINGS__TIMEOUT=10.0
@@ -20,3 +21,6 @@
# NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS=3
# OBSERVABILITY_SETTINGS__LOG_LEVEL="INFO"
+
+# CALLBACK_SETTINGS__TIMEOUT=10.0
+# QOD_SETTINGS__SERVICE_SPECIFICATION_ID="7608e902-b927-559f-b448-e7e9061dfa5c"
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4a0d4de0c83e8c9fbd5f06473c2bf9b454ba6f0f..cd2724b8e59235580477660deb6cb35a6537c9b4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,6 +1,4 @@
default:
- tags:
- - docker
image: python:3.12-slim
cache:
paths:
@@ -18,12 +16,10 @@ stages:
- lint
- format
- test
- - build
- - push
+ - build-and-push
variables:
UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv"
- GIT_STRATEGY: clone
type:
stage: type
@@ -47,13 +43,6 @@ format:
test:
stage: test
- # Integration tests (tests/integration) need a Docker daemon for testcontainers.
- # A docker:dind service provides it; DOCKER_HOST/DOCKER_TLS_CERTDIR point the
- # docker-py client (used by testcontainers) at it without requiring TLS certs
- # to be shared into this job's (non-docker) image.
- # test/conformance stays out of this job's scope: it's collected/run separately
- # (see the `conformance` job) since its module-level schemathesis schema
- # loading is expensive and would cost minutes on every pipeline otherwise.
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
@@ -63,36 +52,27 @@ test:
- pytest tests/unit tests/integration --cov=src/open_exposure_gateway --cov-branch --cov-report=term-missing || [ $? -eq 5 ] #bypass no tests found error.
coverage: '/TOTAL.+ ([0-9]{1,3}(?:\.[0-9]+)?%)/'
+
conformance:
stage: test
allow_failure: true
script:
- pytest tests/conformance
-build:
- stage: build
- tags:
- - shell
- before_script:
- - docker info
- script:
- - export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
- - docker build --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" .
- rules:
- - if: '$CI_COMMIT_BRANCH'
-
-push:
- stage: push
- tags:
- - shell
- needs:
- - build
+build-and-push:
+ stage: build-and-push
+ image: docker:cli
+ variables:
+ DOCKER_HOST: tcp://docker:2375
+ DOCKER_TLS_CERTDIR: ""
+ services:
+ - docker:dind
before_script:
- docker info
script:
- export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin
- - docker push "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG"
+ - docker buildx build --provenance=false --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" --push .
- docker logout "$CI_REGISTRY"
rules:
- - if: '$CI_COMMIT_BRANCH'
+ - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
diff --git a/src/open_exposure_gateway/adapters/database/mappers.py b/src/open_exposure_gateway/adapters/database/mappers.py
index 6f8067f1817fc4573ea702f25a9654259dd8c0e5..85a4c791f5dfa3d041d67d8d78027e199ad7b88f 100644
--- a/src/open_exposure_gateway/adapters/database/mappers.py
+++ b/src/open_exposure_gateway/adapters/database/mappers.py
@@ -4,6 +4,7 @@ from open_exposure_gateway.adapters.database.sql import (
CallbackDeliveryRow,
CallbackRegistrationRow,
OperationRow,
+ QodSessionRow,
)
from open_exposure_gateway.domain.models import (
AppInstance,
@@ -11,6 +12,7 @@ from open_exposure_gateway.domain.models import (
CallbackDelivery,
CallbackRegistration,
Operation,
+ QodSession,
)
@@ -108,6 +110,38 @@ class AppInstanceMapper:
)
+class QodSessionMapper:
+ @staticmethod
+ def to_domain(row: QodSessionRow) -> QodSession:
+ return QodSession(
+ session_id=row.session_id,
+ operation_id=row.operation_id,
+ service_specification_id=row.service_specification_id,
+ qos_profile=row.qos_profile,
+ duration_seconds=row.duration_seconds,
+ state=row.state,
+ external_ref=row.external_ref,
+ device_ports=row.device_ports,
+ application_server_ports=row.application_server_ports,
+ created_at=row.created_at,
+ updated_at=row.updated_at,
+ )
+
+ @staticmethod
+ def to_row(domain: QodSession) -> QodSessionRow:
+ return QodSessionRow(
+ session_id=domain.session_id,
+ operation_id=domain.operation_id,
+ service_specification_id=domain.service_specification_id,
+ qos_profile=domain.qos_profile,
+ duration_seconds=domain.duration_seconds,
+ state=domain.state,
+ external_ref=domain.external_ref,
+ device_ports=domain.device_ports,
+ application_server_ports=domain.application_server_ports,
+ )
+
+
class CallbackRegistrationMapper:
@staticmethod
def to_domain(row: CallbackRegistrationRow) -> CallbackRegistration:
diff --git a/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py b/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d84c6411533b9015d6967175ac97a58b6ad65a4
--- /dev/null
+++ b/src/open_exposure_gateway/adapters/database/repos/qod_sessions.py
@@ -0,0 +1,32 @@
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from open_exposure_gateway.adapters.database.mappers import QodSessionMapper
+from open_exposure_gateway.adapters.database.sql import QodSessionRow
+from open_exposure_gateway.domain.models import QodSession
+from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository
+
+
+class SqlQodSessionRepository(QodSessionRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, session_id: UUID) -> QodSession | None:
+ stmt = select(QodSessionRow).where(QodSessionRow.session_id == session_id)
+ row = await self._session.scalar(stmt)
+ return QodSessionMapper.to_domain(row) if row is not None else None
+
+ async def get_by_operation_id(self, operation_id: UUID) -> QodSession | None:
+ stmt = select(QodSessionRow).where(QodSessionRow.operation_id == operation_id)
+ row = await self._session.scalar(stmt)
+ return QodSessionMapper.to_domain(row) if row is not None else None
+
+ async def save(self, qod_session: QodSession) -> QodSession:
+ merged = await self._session.merge(QodSessionMapper.to_row(qod_session))
+ await self._session.flush()
+ saved = await self.get_by_id(merged.session_id)
+ if saved is None:
+ raise RuntimeError("Saved QoD session could not be reloaded")
+ return saved
diff --git a/src/open_exposure_gateway/adapters/database/sql.py b/src/open_exposure_gateway/adapters/database/sql.py
index e9820691fad07bb7a45c3e27e6bd34b9bd408c2a..857b9f389781af633481c3da69dbdb36c987ea06 100644
--- a/src/open_exposure_gateway/adapters/database/sql.py
+++ b/src/open_exposure_gateway/adapters/database/sql.py
@@ -26,6 +26,7 @@ from open_exposure_gateway.domain.models.operations.enums import (
OperationStatus,
OperationType,
)
+from open_exposure_gateway.domain.models.qod_sessions.enums import QodSessionState
from open_exposure_gateway.domain.models.registration.enums import (
AppRegistrationStatus,
PackageType,
@@ -184,3 +185,20 @@ class AppInstanceRow(AuditedMixin, Base):
)
edge_cloud_zone_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False)
state: Mapped[AppInstanceState] = mapped_column(_enum_type(AppInstanceState), nullable=False)
+
+
+class QodSessionRow(AuditedMixin, Base):
+ __tablename__ = "qod_sessions"
+ __table_args__ = (Index("idx_qod_sessions_operation", "operation_id"),)
+
+ session_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True)
+ operation_id: Mapped[UUID] = mapped_column(
+ ForeignKey("operations.operation_id"), nullable=False
+ )
+ service_specification_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False)
+ qos_profile: Mapped[str] = mapped_column(String(256), nullable=False)
+ duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
+ state: Mapped[QodSessionState] = mapped_column(_enum_type(QodSessionState), nullable=False)
+ external_ref: Mapped[str | None] = mapped_column(String(255))
+ device_ports: Mapped[dict[str, object] | None] = mapped_column(JSONB)
+ application_server_ports: Mapped[dict[str, object] | None] = mapped_column(JSONB)
diff --git a/src/open_exposure_gateway/adapters/databus/nats_adapter.py b/src/open_exposure_gateway/adapters/databus/nats_adapter.py
index 51ce781b1e11707bc76e373611b85754f5de490b..2d54fd170f658e06396b88a4f09a88e3fc315df3 100644
--- a/src/open_exposure_gateway/adapters/databus/nats_adapter.py
+++ b/src/open_exposure_gateway/adapters/databus/nats_adapter.py
@@ -10,6 +10,7 @@ from pydantic import ValidationError
from open_exposure_gateway.core.config import NatsSettings
from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted
+from open_exposure_gateway.domain.quality_on_demand import SRMOperationStatus
from open_exposure_gateway.ports.databus_port import DataBusPort
logger: structlog.BoundLogger = structlog.get_logger(__name__)
@@ -109,3 +110,38 @@ class NatsOperationConsumer:
await self._handler(event)
except Exception:
logger.exception("operation_completed_handler_failed", operation_id=event.operation_id)
+
+
+class NatsOperationStatusConsumer:
+ def __init__(
+ self,
+ client: Client,
+ subject: str,
+ handler: Callable[[SRMOperationStatus], Awaitable[None]],
+ ) -> None:
+ self._client = client
+ self._subject = subject
+ self._handler = handler
+ self._subscription: Subscription | None = None
+
+ async def start(self) -> None:
+ # TODO: same at-most-once caveat as NatsOperationConsumer.start() — see there.
+ self._subscription = await self._client.subscribe(self._subject, cb=self._handle_message)
+
+ async def _handle_message(self, msg: _Msg) -> None:
+ try:
+ raw: Any = json.loads(msg.data.decode())
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ logger.warning("invalid_json", subject=msg.subject)
+ return
+
+ try:
+ event = SRMOperationStatus.model_validate(raw)
+ except ValidationError as exc:
+ logger.warning("invalid_operation_status_event", subject=msg.subject, error=str(exc))
+ return
+
+ try:
+ await self._handler(event)
+ except Exception:
+ logger.exception("operation_status_handler_failed", operation_id=event.operation_id)
diff --git a/src/open_exposure_gateway/adapters/http/qod_callback_client.py b/src/open_exposure_gateway/adapters/http/qod_callback_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..6542e985f707a6824821cb66a1b7271dd262e807
--- /dev/null
+++ b/src/open_exposure_gateway/adapters/http/qod_callback_client.py
@@ -0,0 +1,19 @@
+import httpx
+
+from open_exposure_gateway.core.config import get_settings
+from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent
+
+
+class HttpQodCallbackClient:
+ def __init__(self) -> None:
+ settings = get_settings()
+ self.timeout = settings.callback_settings.timeout
+
+ async def deliver(self, sink: str, event: QosStatusChangedCloudEvent) -> None:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ response = await client.post(
+ sink,
+ content=event.model_dump_json(),
+ headers={"Content-Type": "application/cloudevents+json"},
+ )
+ response.raise_for_status()
diff --git a/src/open_exposure_gateway/adapters/http/srm_client.py b/src/open_exposure_gateway/adapters/http/srm_client.py
index f3fad30f29f5eb1df5bca07236e410de7d763941..ce9ccc1532041e2ea3cccd8145ce522da3efcdd1 100644
--- a/src/open_exposure_gateway/adapters/http/srm_client.py
+++ b/src/open_exposure_gateway/adapters/http/srm_client.py
@@ -4,9 +4,6 @@ from uuid import UUID
import httpx
import structlog
-from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
- QoDSessionResponse,
-)
from open_exposure_gateway.core.config import get_settings
from open_exposure_gateway.core.exceptions import (
DownstreamServiceException,
@@ -18,6 +15,7 @@ from open_exposure_gateway.domain.edge_application_management import (
SRMServiceInstance,
SRMZone,
)
+from open_exposure_gateway.domain.quality_on_demand import SRMNetworkCapability
logger = structlog.get_logger(__name__)
@@ -124,31 +122,16 @@ class SRMClient:
)
return [SRMZone.model_validate(z) for z in data]
- async def create_qod_session(
- self,
- payload: dict[str, Any],
- x_correlator: str | None = None,
- ) -> QoDSessionResponse:
- headers = {"x-correlator": x_correlator} if x_correlator else None
- data = await self._request("POST", "/sessions", json=payload, headers=headers)
- return QoDSessionResponse.model_validate(data)
-
- async def get_qod_session(
+ async def get_network_capability(
self,
- session_id: str,
+ service_instance_id: str,
x_correlator: str | None = None,
- ) -> QoDSessionResponse:
+ ) -> SRMNetworkCapability:
headers = {"x-correlator": x_correlator} if x_correlator else None
- data = await self._request("GET", f"/sessions/{session_id}", headers=headers)
- return QoDSessionResponse.model_validate(data)
-
- async def delete_qod_session(
- self,
- session_id: str,
- x_correlator: str | None = None,
- ) -> None:
- headers = {"x-correlator": x_correlator} if x_correlator else None
- await self._request("DELETE", f"/sessions/{session_id}", headers=headers)
+ data = await self._request(
+ "GET", f"/internal/network-capabilities/{service_instance_id}", headers=headers
+ )
+ return SRMNetworkCapability.model_validate(data)
async def get_apps(self, x_correlator: str | None = None) -> list[SRMCatalogPayload]:
headers = {"x-correlator": x_correlator} if x_correlator else None
diff --git a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py
index 346f10b6d457b1588ab0dd3099e240642badc460..a04ec2e34d5e2d9fcc5b5aa0708ae21c82b35059 100644
--- a/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py
+++ b/src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py
@@ -281,3 +281,93 @@ async def delete_app_instance(
x_correlator=caller.x_correlator,
)
return Response(status_code=status.HTTP_202_ACCEPTED)
+
+
+@router.post(
+ "/deployments",
+ tags=["Application"],
+ summary="Deploy an Application",
+ responses=_responses(
+ BadRequestException(),
+ UnauthorizedException(),
+ ForbiddenException(),
+ AlreadyExistsException(),
+ NotImplementedException(),
+ DownstreamServiceException(),
+ ),
+)
+async def create_app_deployment() -> Any:
+ raise NotImplementedException(message="Multi-zone deployment is not implemented")
+
+
+@router.get(
+ "/deployments",
+ tags=["Application"],
+ summary="Retrieve a list of Application Deployment for a given App",
+ responses=_responses(
+ BadRequestException(),
+ UnauthorizedException(),
+ ForbiddenException(),
+ NotImplementedException(),
+ DownstreamServiceException(),
+ ),
+)
+async def get_app_deployments(
+ appId: Annotated[Optional[UUID], Query()] = None,
+ appDeploymentId: Annotated[Optional[UUID], Query()] = None,
+) -> Any:
+ raise NotImplementedException(message="Multi-zone deployment is not implemented")
+
+
+@router.delete(
+ "/deployments/{appDeploymentId}",
+ tags=["Application"],
+ summary="Terminate an Application Deployment",
+ responses=_responses(
+ BadRequestException(),
+ UnauthorizedException(),
+ ForbiddenException(),
+ NotFoundException(),
+ NotImplementedException(),
+ DownstreamServiceException(),
+ ),
+)
+async def delete_app_deployment(appDeploymentId: UUID) -> Any:
+ raise NotImplementedException(message="Multi-zone deployment is not implemented")
+
+
+@router.patch(
+ "/deployments/{appDeploymentId}",
+ tags=["Application"],
+ summary="Update an Application Deployment",
+ responses=_responses(
+ BadRequestException(),
+ UnauthorizedException(),
+ ForbiddenException(),
+ NotFoundException(),
+ NotImplementedException(),
+ DownstreamServiceException(),
+ ),
+)
+async def update_app_deployment(appDeploymentId: UUID) -> Any:
+ raise NotImplementedException(message="Multi-zone deployment is not implemented")
+
+
+@router.get(
+ "/clusters",
+ tags=["Cluster"],
+ summary="Retrieve a list of the available clusters filtered by the optional query parameters",
+ responses=_responses(
+ BadRequestException(),
+ UnauthorizedException(),
+ ForbiddenException(),
+ NotImplementedException(),
+ DownstreamServiceException(),
+ ),
+)
+async def get_clusters(
+ region: Annotated[Optional[str], Query(max_length=64)] = None,
+ clusterRef: Annotated[Optional[UUID], Query()] = None,
+ edgeCloudZoneId: Annotated[Optional[UUID], Query()] = None,
+) -> Any:
+ raise NotImplementedException(message="Cluster catalog is not implemented")
diff --git a/src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml b/src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c207cda38fc0036771841e0f0f066e90f84420ea
--- /dev/null
+++ b/src/open_exposure_gateway/api/camara/quality_on_demand/API_definitions/qod-api.yaml
@@ -0,0 +1,1234 @@
+openapi: 3.0.3
+info:
+ title: QoD for enhanced communication
+ description: |
+ The Quality-On-Demand (QoD) API provides programmable interface for developers and other users (capabilities consumers) to request stable latency or throughput managed by Telco networks without the necessity to have an in-depth knowledge of the 4G/5G system or the overall complexity of the Telecom Systems.
+
+ # Introduction
+
+ Industrial (IoT), VR/Gaming, live video streaming, autonomous driving and many other scenarios demand network communication quality and are sensitive to any change in transmission conditions. Being able to request a stable latency (reduced jitter) or prioritized throughput from the network can improve user experience substantially.
+
+ The QoD API offers the application developers the capability to request for stable latency (reduced jitter) or throughput for some specified application data flows between application clients (within a user device) and Application Servers (backend services). The developer has a pre-defined set of Quality of Service (QoS) profiles which they could choose from depending on their latency or throughput requirements.
+
+ 
+
+ The usage of the API is based on QoS session resources, which can be created (based on available QoS profiles), queried and deleted. The deletion of a requested session can be triggered by the API consumer or can be triggered automatically. The automatic process is triggered either when the requested specified duration of a QoS session has reached its limit or the default session expiration time has been reached (within an example provider implementation it is set to 24hrs).
+
+ # Relevant terms and definitions
+
+ * **QOD service endpoint**:
+ The URL pointing to the RESTful resource of the QoD API.
+
+ * **Authentication**:
+ Security access keys such as OAuth 2.0 client credentials used by client applications to invoke the QoD API.
+
+ * **QoS profiles and QoS profile labels**:
+ Latency or throughput requirements of the application mapped to relevant QoS profile class.
+
+ * **Identifier for the device**:
+ At least one identifier for the device (user equipment) out of four options: IPv4 address, IPv6 address, Phone number, or Network Access Identifier [[5]](#5) assigned by the mobile network operator for the device.
+
+ * **Identifier for the application server**:
+ IPv4 and/or IPv6 address of the application server (application backend)
+
+ * **App-Flow (between the application client and application server)**:
+ The precise application data flow the developer wants to prioritize and have stable latency or throughput for. This flow is in the current API version determined by the identifiers used for the device and the application server. And it can be further elaborated with details such as ports or port-ranges. Future version of the API might allow more detailed flow identification features.
+
+ * **Duration**:
+ Duration (in seconds) for which the QoS session (between application client and application server) should be created. This parameter is optional. When not specified, a default session duration (e.g. 24 hours) is applied. The user may request a termination before its expiration.
+
+ * **Notification URL and token**:
+ Developers may provide a callback URL on which notifications about all status change events of the session (eg. session termination) can be received from the service provider. This is an optional parameter.
+
+ # API functionality
+
+ The usage of the QoD API is based on QoS profile classes and parameters which define App-Flows.
+ Based on the API, QoS session resources can be created, queried, and deleted. Once an offered QoS profile class is requested, application users get a prioritized service with stable latency or throughput even in the case of congestion. The QoD API has the following characteristics:
+
+ * A specified App-Flow is prioritized to ensure stable latency or throughput for that flow.
+ * The prioritized App-Flow is described by providing information such as device IP address (or other device identifier) & application server IP addresses and port/port-ranges.
+ * The developer can optionally specify the duration for which they need the prioritized App-flow.
+ * Stable latency or throughput is requested by selecting from the list of QoS profiles made available by the service provider (e.g. QOS_E) to map latency and throughput requirements.
+ * The developer can optionally also specify callback URL on which notifications for the session can be sent.
+
+ Following diagram shows the interaction between different components
+
+ 
+
+ How QoS profiles are mapped to connectivity characteristics are subject to agreements between the communication service provider and the API invoker. Within the CAMARA project, you can find a sample for such a mapping of QoS profiles. [CAMARA QoS Profiles Mapping Table (REFERENCE DRAFT)](https://github.com/camaraproject/QualityOnDemand/blob/main/documentation/API_documentation/QoSProfile_Mapping_Table.md)
+
+ # Further info and support
+
+ (FAQs will be added in a later version of the documentation)
+ termsOfService: http://swagger.io/terms/
+ contact:
+ email: project-email@sample.com
+ license:
+ name: Apache 2.0
+ url: https://www.apache.org/licenses/LICENSE-2.0.html
+ version: 0.10.1
+externalDocs:
+ description: Product documentation at Camara
+ url: https://github.com/camaraproject/
+security:
+ - oAuth2ClientCredentials: []
+servers:
+ - url: "{apiRoot}/qod/v0"
+ variables:
+ apiRoot:
+ default: http://localhost:9091
+ description: API root, defined by the service provider, e.g. `api.example.com` or `api.example.com/somepath`
+tags:
+ - name: QoS Sessions
+ description: Manage QoS sessions
+ - name: QoS Profiles
+ description: Manage QoS Profiles
+paths:
+ /sessions:
+ post:
+ tags:
+ - QoS Sessions
+ summary: Creates a new session
+ description: |
+ Create QoS Session to manage latency/throughput priorities
+
+ If the qosStatus in the API response is "AVAILABLE" and a notification callback is provided the client will receive in addition to the response a
+ `QOS_STATUS_CHANGED` event notification with `qosStatus` as `AVAILABLE`.
+
+ If the `qosStatus` in the API response is `REQUESTED`, the client will receive either
+ - a `QOS_STATUS_CHANGED` event notification with `qosStatus` as `AVAILABLE` after the network notifies that it has created the requested session, or
+ - a `QOS_STATUS_CHANGED` event notification with `qosStatus` as `UNAVAILABLE` and `statusInfo` as `NETWORK_TERMINATED` after the network notifies that it has failed to provide the requested session.
+
+ A `QOS_STATUS_CHANGED` event notification with `qosStatus` as `UNAVAILABLE` will also be send if the network terminates the session before the requested duration expired
+
+ NOTE: in case of a `QOS_STATUS_CHANGED` event with `qosStatus` as `UNAVAILABLE` and `statusInfo` as `NETWORK_TERMINATED` the resources of the QoS session
+ are not directly released, but will get deleted automatically at earliest 360 seconds after the event.
+ This behavior allows clients which are not receiving notification events but are polling to get the session information with
+ the `qosStatus` `UNAVAILABLE` (the `statusInfo` parameter is not included in the current version but will be adding to `SessionInfo` in an upcoming release). Before a client can attempt to create a new QoD session
+ for the same device and flow period they must release the session resources with an explicit `delete` operation if not yet automatically deleted.
+
+ operationId: createSession
+ requestBody:
+ description: Parameters to create a new session
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateSession"
+ required: true
+ callbacks:
+ notifications:
+ "{$request.body#/webhook/notificationUrl}":
+ post:
+ tags:
+ - Session notifications callback
+ summary: "Session notifications callback"
+ description: |
+ Important: this endpoint is to be implemented by the API consumer.
+ The QoD server will call this endpoint whenever any QoS session change (e.g. network termination) related event occurs.
+ Currently only QOS_STATUS_CHANGED event is defined.
+ operationId: postNotification
+ requestBody:
+ required: true
+ content:
+ application/cloudevents+json:
+ schema:
+ $ref: "#/components/schemas/CloudEvent"
+ examples:
+ QOS_STATUS_CHANGED_EXAMPLE:
+ $ref: "#/components/examples/QOS_STATUS_CHANGED_EXAMPLE"
+ responses:
+ "204":
+ description: Successful notification
+ "400":
+ $ref: "#/components/responses/Generic400"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "500":
+ $ref: "#/components/responses/Generic500"
+ "503":
+ $ref: "#/components/responses/Generic503"
+ security:
+ - {}
+ - notificationsBearerAuth: []
+ responses:
+ "201":
+ description: Session created
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SessionInfo"
+ "400":
+ description: Invalid input for createSession operation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ examples:
+ Generic400:
+ summary: Some parameter combinations or parameter values provided are not schema compliant
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Schema validation failed at ..."
+ DeviceMissing:
+ summary: Device must be specified
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Expected property is missing: device"
+ InsufficientDeviceProperties:
+ summary: Device must be identified by at least one parameter
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Insufficient properties specified: device"
+ InconsistentDeviceProperties:
+ summary: Device parameters provided identify different devices
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Multiple inconsistent parameters specified: device"
+ CannotIdentifyDevice:
+ summary: No device can be identified from provided parameters
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Unable to identify device from specified parameters: device"
+ InvalidDevicePublicPortValue:
+ summary: Invalid port specified for device public port
+ value:
+ status: 400
+ code: OUT_OF_RANGE
+ message: "Invalid port value specified: device.ipv4Address.publicPort"
+ ApplicationServerMissing:
+ summary: Application server must be specified
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Expected property is missing: applicationServer"
+ QoSProfileMissing:
+ summary: Required QoS profile must be specified
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Expected property is missing: qosProfile"
+ InvalidDevicePortsRanges:
+ summary: Invalid port ranges specified for devicePorts
+ value:
+ status: 400
+ code: OUT_OF_RANGE
+ message: "Invalid port ranges specified: devicePorts"
+ DurationOutOfRangeForQoSProfile:
+ summary: The requested duration is out of the allowed range for the specific QoS profile
+ value:
+ status: 400
+ code: OUT_OF_RANGE
+ message: "The requested duration is out of the allowed range for the specific QoS profile"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "409":
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 409
+ code: CONFLICT
+ message: "Another session is created for the same device"
+ "500":
+ $ref: "#/components/responses/Generic500"
+ "501":
+ $ref: "#/components/responses/Generic501"
+ "503":
+ $ref: "#/components/responses/Generic503"
+ security:
+ - oAuth2ClientCredentials: []
+ - threeLegged:
+ - "qod-sessions-write"
+
+ /sessions/{sessionId}:
+ get:
+ tags:
+ - QoS Sessions
+ summary: Get QoS session information
+ description: Querying for QoS session resource information details
+ operationId: getSession
+ parameters:
+ - name: sessionId
+ in: path
+ description: Session ID that was obtained from the createSession operation
+ required: true
+ schema:
+ $ref: "#/components/schemas/SessionId"
+ responses:
+ "200":
+ description: Contains information about active session
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SessionInfo"
+ "400":
+ $ref: "#/components/responses/Generic400"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "404":
+ $ref: "#/components/responses/SessionNotFound404"
+ "500":
+ $ref: "#/components/responses/Generic500"
+ "503":
+ $ref: "#/components/responses/Generic503"
+ security:
+ - oAuth2ClientCredentials: []
+ - threeLegged:
+ - "qod-sessions-read"
+
+ delete:
+ tags:
+ - QoS Sessions
+ summary: Delete a QoS session
+ description: |
+ Release resources related to QoS session
+
+ If the notification callback is provided and the `qosStatus` of the session was `AVAILABLE` the client will receive in addition to the response a `QOS_STATUS_CHANGED` event with
+ - `qosStatus` as `UNAVAILABLE` and
+ - `statusInfo` as `DELETE_REQUESTED`
+ There will be no notification event if the `qosStatus` was already `UNAVAILABLE`.
+ operationId: deleteSession
+ parameters:
+ - name: sessionId
+ in: path
+ description: Session ID that was obtained from the createSession operation
+ required: true
+ schema:
+ $ref: "#/components/schemas/SessionId"
+ responses:
+ "204":
+ description: Session deleted
+ "400":
+ $ref: "#/components/responses/Generic400"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "404":
+ $ref: "#/components/responses/SessionNotFound404"
+ "500":
+ $ref: "#/components/responses/Generic500"
+ "503":
+ $ref: "#/components/responses/Generic503"
+ security:
+ - oAuth2ClientCredentials: []
+ - threeLegged:
+ - "qod-sessions-delete"
+
+ /sessions/{sessionId}/extend:
+ post:
+ tags:
+ - QoS Sessions
+ summary: "Extend the duration of an active session"
+ description: |
+ Extend the overall duration of an active QoS session. If this operation is executed successfully, the new duration of the target session will be the original duration plus the additionally requested duration.
+ The new remaining duration of the QoS session shall not exceed the maximum remaining duration limit (currently fixed at 86,400 seconds) where the remaining duration is calculated as the difference between the `expiresAt` and current time when the request to extend the session duration is received. If this maximum limit would be exceeded, the overall duration shall be set such that the remaining duration is equal to this limit.
+ An example: A QoD session was originally created with duration 80,000 seconds. 10,000 seconds later, the developer requested to extend the session by 20,000 seconds.
+ - Original duration: 80,000 seconds
+ - Elapsed time: 10,000 seconds
+ - Remaining duration: 70,000 seconds
+ - New remaining duration: 86,400 seconds (the maximum allowed)
+ - New overall session duration: 96,400 seconds
+ operationId: extendQosSessionDuration
+ parameters:
+ - name: sessionId
+ in: path
+ description: Session ID that was obtained from the createSession operation
+ required: true
+ schema:
+ $ref: "#/components/schemas/SessionId"
+ requestBody:
+ description: Parameters to extend the duration of an active session
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ExtendSessionDuration"
+ required: true
+ responses:
+ "200":
+ description: Contains information about active session
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SessionInfo"
+ "400":
+ description: Invalid input for extendQosSessionDuration operation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ examples:
+ Generic400:
+ summary: Some parameter combinations or parameter values provided are not schema compliant
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Schema validation failed at ..."
+ InactiveSession:
+ summary: The target session is inactive
+ value:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "The target session is inactive"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "404":
+ $ref: "#/components/responses/SessionNotFound404"
+ "500":
+ $ref: "#/components/responses/Generic500"
+ "503":
+ $ref: "#/components/responses/Generic503"
+ security:
+ - oAuth2ClientCredentials: []
+ - threeLegged:
+ - "qod-sessions-write"
+
+ /qos-profiles:
+ get:
+ tags:
+ - QoS Profiles
+ summary: "Get All QoS Profiles"
+ description: |
+ Returns all QoS Profiles that match the given criteria.
+ If no criteria is given, all QoS Profiles are returned.
+ operationId: getQosProfiles
+ parameters:
+ - name: name
+ in: query
+ description: QoS Profile name
+ schema:
+ type: string
+ required: false
+ - name: status
+ in: query
+ schema:
+ $ref: '#/components/schemas/QosProfileStatusEnum'
+ required: false
+ responses:
+ "200":
+ description: Contains information about QoS Profiles
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/QosProfile"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "404":
+ $ref: "#/components/responses/QosProfilesNotFound404"
+ "500":
+ $ref: "#/components/responses/QoSProfile500"
+ "503":
+ $ref: "#/components/responses/Generic503"
+
+ /qos-profiles/{name}:
+ get:
+ tags:
+ - QoS Profiles
+ summary: "Get QoS Profile for a given name"
+ operationId: getQosProfile
+ description: |
+ Returns a QoS Profile that matches the given name.
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ $ref: "#/components/schemas/QosProfileName"
+ responses:
+ "200":
+ description: Contains information about QoS Profiles
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/QosProfile"
+ "400":
+ $ref: "#/components/responses/Generic400"
+ "401":
+ $ref: "#/components/responses/Generic401"
+ "403":
+ $ref: "#/components/responses/Generic403"
+ "404":
+ $ref: "#/components/responses/QosProfileNotFound404"
+ "500":
+ $ref: "#/components/responses/QoSProfile500"
+ "503":
+ $ref: "#/components/responses/Generic503"
+
+components:
+ securitySchemes:
+ oAuth2ClientCredentials:
+ description: |
+ The QoD API makes use of the OAUTH 2.0 client credentials grant which is applicable for server to server use cases involving trusted partners or clients without any protected user data involved. In this method the API invoker client is registered as a confidential client with an authorization grant type of client_credentials
+ type: oauth2
+ flows:
+ clientCredentials:
+ tokenUrl: https://api.example.com/oauth/token
+ scopes: {}
+ notificationsBearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: "{$request.body#/webhook/notificationAuthToken}"
+ threeLegged:
+ type: oauth2
+ description: This API uses OAuth 2 with the authorization code grant flow.
+ flows:
+ authorizationCode:
+ authorizationUrl: https://api.example.com/oauth2/authorize
+ tokenUrl: https://api.example.com/oauth/token
+ scopes:
+ qod-sessions-read: Retrieval of QoS sessions
+ qod-sessions-write: Creation and update of QoS sessions
+ qod-sessions-delete: Deletion of QoS sessions
+ qod-profiles-read: Retrieval of QoS profiles
+
+ schemas:
+ SessionId:
+ description: Session ID in UUID format
+ type: string
+ format: uuid
+
+ BaseSessionInfo:
+ description: Common attributes of a QoD session
+ type: object
+ properties:
+ device:
+ $ref: "#/components/schemas/Device"
+ applicationServer:
+ $ref: "#/components/schemas/ApplicationServer"
+ devicePorts:
+ description: The ports used locally by the device for flows to which the requested QoS profile should apply. If omitted, then the qosProfile will apply to all flows between the device and the specified application server address and ports
+ allOf:
+ - $ref: "#/components/schemas/PortsSpec"
+ applicationServerPorts:
+ description: A list of single ports or port ranges on the application server
+ allOf:
+ - $ref: "#/components/schemas/PortsSpec"
+ qosProfile:
+ $ref: "#/components/schemas/QosProfileName"
+ webhook:
+ type: object
+ required:
+ - notificationUrl
+ properties:
+ notificationUrl:
+ type: string
+ format: uri
+ example: "https://application-server.com"
+ description: Allows asynchronous delivery of session related events
+ notificationAuthToken:
+ type: string
+ minLength: 20
+ maxLength: 256
+ example: "c8974e592c2fa383d4a3960714"
+ description: Authentication token for callback API
+ required:
+ - device
+ - applicationServer
+ - qosProfile
+
+ SessionInfo:
+ description: Session related information.
+ allOf:
+ - $ref: "#/components/schemas/BaseSessionInfo"
+ - type: object
+ properties:
+ sessionId:
+ $ref: "#/components/schemas/SessionId"
+ duration:
+ type: integer
+ format: int32
+ minimum: 1
+ example: 86400
+ startedAt:
+ type: integer
+ example: 1639479600
+ description: Timestamp of session start in seconds since Unix epoch
+ format: int64
+ expiresAt:
+ type: integer
+ example: 1639566000
+ description: Timestamp of session expiration if the session was not deleted, in seconds since Unix epoch
+ format: int64
+ qosStatus:
+ $ref: "#/components/schemas/QosStatus"
+ messages:
+ type: array
+ items:
+ $ref: "#/components/schemas/Message"
+ required:
+ - sessionId
+ - duration
+ - startedAt
+ - expiresAt
+ - qosStatus
+
+ CreateSession:
+ description: Attributes required to create a session
+ allOf:
+ - $ref: "#/components/schemas/BaseSessionInfo"
+ - type: object
+ properties:
+ duration:
+ description: |
+ Session duration in seconds. Maximal value of 24 hours is used if not set.
+ After session is expired the, client will receive a `QOS_STATUS_CHANGED` event with
+ - `qosStatus` as `UNAVAILABLE`, and,
+ - `statusInfo` as `DURATION_EXPIRED`.
+ See notification callback.
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 86400
+ default: 86400
+ example: 86400
+
+ Port:
+ description: TCP or UDP port number
+ type: integer
+ minimum: 0
+ maximum: 65535
+
+ PortsSpec:
+ type: object
+ minProperties: 1
+ properties:
+ ranges:
+ type: array
+ minItems: 1
+ items:
+ type: object
+ required:
+ - from
+ - to
+ properties:
+ from:
+ $ref: "#/components/schemas/Port"
+ to:
+ $ref: "#/components/schemas/Port"
+ ports:
+ type: array
+ minItems: 1
+ items:
+ $ref: "#/components/schemas/Port"
+ example:
+ ranges:
+ - from: 5010
+ to: 5020
+ ports:
+ - 5060
+ - 5070
+
+ ExtendSessionDuration:
+ description: Attributes required to extend the duration of an active session
+ type: object
+ properties:
+ requestedAdditionalDuration:
+ description: |
+ Additional duration in seconds to be extended.
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 86399
+ example: 60
+ required:
+ - requestedAdditionalDuration
+
+
+ QosProfile:
+ description: |
+ Data type with attributes of a QosProfile
+ type: object
+ properties:
+ name:
+ $ref: "#/components/schemas/QosProfileName"
+ description:
+ description: |
+ A description of the QoS profile.
+ type: string
+ example: "QoS profile for video streaming"
+ status:
+ $ref: "#/components/schemas/QosProfileStatusEnum"
+ targetMinUpstreamRate:
+ description: |
+ This is the target minimum upstream rate for the QoS profile.
+ For 5G networks 3GPP Guaranteed Bit Rate (GBR) refers to a dedicated, fixed data rate assigned to
+ specific services, ensuring a minimum performance level. As per 3GPP TS 23.203,
+ GBR is a QoS parameter used to manage traffic classes in mobile networks. It
+ provides a stable data rate for latency-sensitive applications, such as voice calls or
+ video streaming, helping maintain a consistent user experience. When this attribute is set
+ this could imply that a GBR QCI is used, though mechanisms on the network can be used to
+ ensure a minimum performance level without using a GBR QCI.
+ The committed data rate allocated to specific services, ensuring a consistent level of
+ performance. For DOCSIS networks, the "Minimum Reserved Traffic Rate" is defined in the
+ DOCSIS 3.1 - MAC and Upper Layer Protocols Interface Specification"
+ and it ensures a consistent level of performance for specific services within the network.
+ allOf:
+ - $ref: "#/components/schemas/Rate"
+ maxUpstreamRate:
+ description: |
+ The maximum best effort data
+ allOf:
+ - $ref: "#/components/schemas/Rate"
+ maxUpstreamBurstRate:
+ description: |
+ When defined, this is the maximum upstream burst rate for the QoS profile, that will enable
+ the network to burst data at a higher rate than the maxUpstreamRate for a period of time.
+ allOf:
+ - $ref: "#/components/schemas/Rate"
+ targetMinDownstreamRate:
+ description: |
+ This is the target minimum downstream rate for the QoS profile.
+ For 5G networks 3GPP Guaranteed Bit Rate (GBR) refers to a dedicated, fixed data rate assigned to
+ specific services, ensuring a minimum performance level. As per 3GPP TS 23.203,
+ GBR is a QoS parameter used to manage traffic classes in mobile networks. It
+ provides a stable data rate for latency-sensitive applications, such as voice calls or
+ video streaming, helping maintain a consistent user experience. When this attribute is set
+ this could imply that a GBR QCI is used, though mechanisms on the network can be used to
+ ensure a minimum performance level without using a GBR QCI.
+ The committed data rate allocated to specific services, ensuring a consistent level of
+ performance. DOCSIS networks, the "Minimum Reserved Traffic Rate" is defined in the
+ DOCSIS 3.1 - MAC and Upper Layer Protocols Interface Specification"
+ and it ensures a consistent level of performance for specific services within the network.
+ allOf:
+ - $ref: "#/components/schemas/Rate"
+ maxDownstreamRate:
+ description: |
+ The maximum best effort rate
+ allOf:
+ - $ref: "#/components/schemas/Rate"
+ maxDownstreamBurstRate:
+ description: |
+ When defined, this is the maximum downstream burst rate for the QoS profile, that will enable
+ the network to burst data at a higher rate than the maxDownstreamRate for a period of time.
+ This can result in improved user experience when there is additional network capacity.
+ For instance, when a user is streaming a video, the network can burst data at a higher rate
+ to fill the buffer, and then return to the maxUpstreamRate once the buffer is full.
+ allOf:
+ - $ref: "#/components/schemas/Rate"
+ minDuration:
+ description: |
+ The shortest time period that this profile can be deployed.
+ allOf:
+ - $ref: "#/components/schemas/Duration"
+ maxDuration:
+ description: |
+ The maximum time period that this profile can be deployed.
+ NOTE: currently the duration within `sessionInfo` is limited to 86400 seconds (1 day).
+ The value of `maxDuration` shouldn't therefore exceed this time period. The limitation might be removed in later versions.
+ allOf:
+ - $ref: "#/components/schemas/Duration"
+ priority:
+ type: integer
+ example: 20
+ description: |
+ Priority levels allow efficient resource allocation and ensure optimal performance
+ for various services in each technology, with the highest priority traffic receiving
+ preferential treatment.
+ The lower value the higher priority.
+ Not all access networks use the same priority range, so this priority will be
+ scaled to the access network's priority range.
+ format: int32
+ minimum: 1
+ maximum: 100
+ packetDelayBudget:
+ description: |
+ The packet delay budget is the maximum allowable one-way latency between the customer's device
+ and the gateway from the operator's network to other networks. By limiting the delay, the network
+ can provide an acceptable level of performance for various services, such as voice calls,
+ video streaming, and data.
+ The end-to-end or round trip latency will be about two times this value plus the latency not controlled
+ by the operator
+ allOf:
+ - $ref: "#/components/schemas/Duration"
+ jitter:
+ description: |
+ The jitter requirement aims to limit the maximum variation in round-trip
+ packet delay for the 99th percentile of traffic, following ITU Y.1540
+ standards. It considers only acknowledged packets in a session, which are
+ packets that receive a confirmation of receipt from the recipient (e.g.,
+ using TCP). This requirement helps maintain consistent latency, essential
+ for real-time applications such as VoIP, video calls, and gaming.
+ allOf:
+ - $ref: "#/components/schemas/Duration"
+ packetErrorLossRate:
+ type: integer
+ description: |
+ The exponential power of the allowable error loss rate 10^(-N).
+ For instance 3 would be an error loss rate of 10 to the power of -3 (0.001)
+
+ For 5G network the 3GPP specification TS 23.203 defines the packet error loss rate QCI attribute. It
+ describes the Quality of Service (QoS) Class Identifier (QCI) parameters used to
+ differentiate traffic classes in mobile networks, ensuring appropriate resource
+ allocation and performance for various services.
+
+ The packet error loss rate is one of the QCI attributes, providing information on the
+ acceptable packet loss rate for a specific traffic class. This attribute helps maintain
+ the desired performance level for services like voice calls, video streaming, or data
+ transfers within the 3GPP mobile network.
+ format: int32
+ minimum: 1
+ maximum: 10
+ example: 3
+ required:
+ - name
+ - status
+
+ QosProfileName:
+ description: |
+ A unique name for identifying a specific QoS profile.
+ This may follow different formats depending on the service providers implementation.
+ Some options addresses:
+ - A UUID style string
+ - Support for predefined profiles QOS_S, QOS_M, QOS_L, and QOS_E
+ - A searchable descriptive name
+ type: string
+ example: QCI_1_voice
+ minLength: 3
+ maxLength: 256
+ format: string
+ pattern: "^[a-zA-Z0-9_.-]+$"
+
+ Rate:
+ type: object
+ properties:
+ value:
+ type: integer
+ example: 10
+ format: int32
+ minimum: 0
+ maximum: 1024
+ unit:
+ $ref: "#/components/schemas/RateUnitEnum"
+
+ Duration:
+ type: object
+ properties:
+ value:
+ type: integer
+ example: 12
+ format: int32
+ minimum: 1
+ unit:
+ allOf:
+ - $ref: "#/components/schemas/TimeUnitEnum"
+ - example: Minutes
+
+ TimeUnitEnum:
+ type: string
+ enum:
+ - Days
+ - Hours
+ - Minutes
+ - Seconds
+ - Milliseconds
+ - Microseconds
+ - Nanoseconds
+
+ QosProfileStatusEnum:
+ description: |
+ The current status of the QoS Profile
+ - `ACTIVE`- QoS Profile is available to be used
+ - `INACTIVE`- QoS Profile is not currently available to be deployed
+ - `DEPRECATED`- QoS profile is actively being used in a QoD session, but can not be deployed in new QoD sessions
+ type: string
+ enum:
+ - ACTIVE
+ - INACTIVE
+ - DEPRECATED
+
+ RateUnitEnum:
+ type: string
+ enum:
+ - bps
+ - kbps
+ - Mbps
+ - Gbps
+ - Tbps
+
+ CloudEvent:
+ description: Event compliant with the CloudEvents specification
+ required:
+ - id
+ - source
+ - specversion
+ - type
+ - time
+ properties:
+ id:
+ description: Identifier of this event, that must be unique in the source context.
+ type: string
+ source:
+ description: Identifies the context in which an event happened in the specific Provider Implementation.
+ type: string
+ format: uri-reference
+ type:
+ description: The type of the event.
+ type: string
+ enum:
+ - 'org.camaraproject.qod.v0.qos-status-changed'
+ specversion:
+ description: Version of the specification to which this event conforms (must be 1.0 if it conforms to cloudevents 1.0.2 version)
+ type: string
+ enum:
+ - '1.0'
+ datacontenttype:
+ description: 'media-type that describes the event payload encoding, must be "application/json" for CAMARA APIs'
+ type: string
+ enum:
+ - 'application/json'
+ data:
+ description: Event notification details payload, which depends on the event type
+ type: object
+ time:
+ description: |
+ Timestamp of when the occurrence happened. It must follow RFC 3339
+ type: string
+ format: date-time
+ discriminator:
+ propertyName: 'type'
+ mapping:
+ org.camaraproject.qod.v0.qos-status-changed: '#/components/schemas/EventQosStatusChanged'
+
+ EventQosStatusChanged:
+ allOf:
+ - $ref: "#/components/schemas/CloudEvent"
+ - type: object
+ properties:
+ data:
+ type: object
+ description: Event details depending on the event type
+ required:
+ - sessionId
+ - qosStatus
+ properties:
+ sessionId:
+ $ref: "#/components/schemas/SessionId"
+ qosStatus:
+ $ref: "#/components/schemas/EventQosStatus"
+ statusInfo:
+ $ref: "#/components/schemas/StatusInfo"
+ required:
+ - data
+
+ StatusInfo:
+ description: |
+ Reason for the new `qosStatus`. Currently `statusInfo` is only applicable when `qosStatus` is 'UNAVAILABLE'.
+ * `DURATION_EXPIRED` - Session terminated due to requested duration expired
+ * `NETWORK_TERMINATED` - Network terminated the session before the requested duration expired
+ * `DELETE_REQUESTED`- User requested the deletion of the session before the requested duration expired
+
+ type: string
+ enum:
+ - DURATION_EXPIRED
+ - NETWORK_TERMINATED
+ - DELETE_REQUESTED
+
+ Device:
+ description: |
+ End-user equipment 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`
+
+ NOTE: the MNO might support only a subset of these options. The API invoker can provide multiple identifiers to be compatible across different MNOs. In this case the identifiers MUST belong to the same device
+ 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
+
+ ApplicationServer:
+ description: |
+ A server hosting backend applications to deliver some business logic to clients.
+
+ The developer can choose to provide the below specified device identifiers:
+
+ * `ipv4Address`
+ * `ipv6Address`
+ type: object
+ properties:
+ ipv4Address:
+ $ref: "#/components/schemas/ApplicationServerIpv4Address"
+ ipv6Address:
+ $ref: "#/components/schemas/ApplicationServerIpv6Address"
+ minProperties: 1
+
+ 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"
+
+ 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, optionally prefixed with '+'.
+ type: string
+ pattern: '^\+?[0-9]{5,15}$'
+ example: "123456789"
+
+ 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"
+
+ 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).
+
+ The session shall apply to all IP flows between the device subnet and the specified application server, unless further restricted by the optional parameters devicePorts or applicationServerPorts.
+ type: string
+ format: ipv6
+ example: 2001:db8:85a3:8d3:1319:8a2e:370:7344
+
+ ApplicationServerIpv4Address:
+ type: string
+ example: "192.168.0.1/24"
+ description: |
+ IPv4 address may be specified in form
as:
+ - address - an IPv4 number in dotted-quad form 1.2.3.4. Only this exact IP number will match the flow control rule.
+ - address/mask - an IP number as above with a mask width of the form 1.2.3.4/24.
+ In this case, all IP numbers from 1.2.3.0 to 1.2.3.255 will match. The bit width MUST be valid for the IP version.
+
+ ApplicationServerIpv6Address:
+ type: string
+ example: "2001:db8:85a3:8d3:1319:8a2e:370:7344"
+ description: |
+ IPv6 address may be specified in form as:
+ - address - The /128 subnet is optional for single addresses:
+ - 2001:db8:85a3:8d3:1319:8a2e:370:7344
+ - 2001:db8:85a3:8d3:1319:8a2e:370:7344/128
+ - address/mask - an IP v6 number with a mask:
+ - 2001:db8:85a3:8d3::0/64
+ - 2001:db8:85a3:8d3::/64
+
+ Message:
+ type: object
+ properties:
+ severity:
+ description: Message severity
+ type: string
+ enum: ["INFO", "WARNING"]
+ description:
+ description: Detailed message text
+ type: string
+ required:
+ - severity
+ - description
+
+ QosStatus:
+ description: |
+ The current status of the requested QoS session. The status can be one of the following:
+ * `REQUESTED` - QoS has been requested by creating a session
+ * `AVAILABLE` - The requested QoS has been provided by the network
+ * `UNAVAILABLE` - The requested QoS cannot be provided by the network due to some reason
+ type: string
+ enum:
+ - REQUESTED
+ - AVAILABLE
+ - UNAVAILABLE
+
+ EventQosStatus:
+ description: |
+ The current status of a requested or previously available session. Applicable values in the event are:
+ * `AVAILABLE` - The requested QoS has been provided by the network.
+ * `UNAVAILABLE` - A requested or previously available QoS session is now unavailable. `statusInfo` may provide additional information about the reason for the unavailability.
+ type: string
+ enum:
+ - AVAILABLE
+ - UNAVAILABLE
+
+ ErrorInfo:
+ type: object
+ properties:
+ status:
+ type: integer
+ description: HTTP status code returned along with this error response
+ code:
+ type: string
+ description: Code given to this error
+ message:
+ type: string
+ description: Detailed error description
+ required:
+ - status
+ - code
+ - message
+
+ responses:
+ Generic400:
+ description: Invalid input
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 400
+ code: INVALID_ARGUMENT
+ message: "Schema validation failed at ..."
+
+ Generic401:
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 401
+ code: UNAUTHENTICATED
+ message: "Authorization failed: ..."
+
+ Generic403:
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 403
+ code: PERMISSION_DENIED
+ message: "Operation not allowed: ..."
+
+ SessionNotFound404:
+ description: Session not found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 404
+ code: NOT_FOUND
+ message: "Session Id does not exist"
+
+ QosProfilesNotFound404:
+ description: Qos Profiles not found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 404
+ code: NOT_FOUND
+ message: "No QoS Profiles found"
+
+ QosProfileNotFound404:
+ description: Qos Profile not found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 404
+ code: NOT_FOUND
+ message: "QosProfile Id does not exist"
+
+ Generic500:
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 500
+ code: INTERNAL
+ message: "Internal server error: ..."
+
+ QoSProfile500:
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 500
+ code: INTERNAL
+ message: "Internal server error: Could not get QoS Profile"
+
+ Generic501:
+ description: Not Implemented
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 501
+ code: NOT_IMPLEMENTED
+ message: "Service not implemented for the specified user device"
+
+ Generic503:
+ description: Service unavailable
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorInfo"
+ example:
+ status: 503
+ code: UNAVAILABLE
+ message: "Service unavailable"
+
+ examples:
+ QOS_STATUS_CHANGED_EXAMPLE:
+ summary: QoS status changed
+ value:
+ id: 83a0d986-0866-4f38-b8c0-fc65bfcda452
+ source: 'https://api.example.com/qod/v0/sessions/123e4567-e89b-12d3-a456-426614174000'
+ specversion: '1.0'
+ type: 'org.camaraproject.qod.v0.qos-status-changed'
+ time: '2021-12-12T00:00:00Z'
+ data:
+ sessionId: '123e4567-e89b-12d3-a456-426614174000'
+ qosStatus: 'UNAVAILABLE'
+ statusInfo: 'DURATION_EXPIRED'
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 92f02de2cdc61368c9fee5206f79401a36012b60..6ed649fef0e1c85143f6c702f77ce580c75720b0 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
@@ -1,16 +1,27 @@
-from typing import Annotated
+from typing import Annotated, Any, Optional
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, Query, Response, status
from open_exposure_gateway.api.camara.common import XCorrelatorHeader
from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
- QoDSessionRequest,
- QoDSessionResponse,
+ CreateSession,
+ ExtendSessionDuration,
+ SessionInfo,
)
from open_exposure_gateway.application.services.quality_on_demand_service import (
QualityOnDemandService,
)
-from open_exposure_gateway.dependencies import get_qod_service
+from open_exposure_gateway.core.exceptions import (
+ BadRequestException,
+ ConflictException,
+ DownstreamServiceException,
+ ForbiddenException,
+ NotFoundException,
+ NotImplementedException,
+ UnauthorizedException,
+)
+from open_exposure_gateway.dependencies import CallerContext, get_caller_context, get_qod_service
+from open_exposure_gateway.schemas.common import ErrorInfo
# CAMARA base path: the spec serves at {apiRoot}/qod/v0 (wire version of v0.10.1).
BASE_PATH = "/qod/v0"
@@ -18,35 +29,66 @@ BASE_PATH = "/qod/v0"
router = APIRouter(prefix=BASE_PATH)
QoDService = Annotated[QualityOnDemandService, Depends(get_qod_service)]
+Caller = Annotated[CallerContext, Depends(get_caller_context)]
+
+# OpenAPI response docs derived from core.exceptions' status_code/message
+# defaults, so codes aren't re-listed. 500 has no dedicated exception class
+# (it's the unhandled-exception catch-all in error_handlers.py).
+_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,
+ ConflictException,
+ NotImplementedException,
+ DownstreamServiceException,
+ )
+}
+_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(
"/sessions",
- tags=["Quality on Demand Functions"],
- summary="Creates a new QoD Session",
+ tags=["QoS Sessions"],
+ summary="Creates a new session",
+ response_model=SessionInfo,
+ response_model_exclude_none=True,
status_code=status.HTTP_201_CREATED,
+ responses=_responses(400, 401, 403, 409, 500, 501, 503),
)
async def create_qod_session(
- request: QoDSessionRequest,
+ request: CreateSession,
service: QoDService,
+ caller: Caller,
x_correlator: XCorrelatorHeader = None,
-) -> QoDSessionResponse:
+) -> Any:
return await service.create_session(
request=request,
+ tenant_id=caller.tenant_id,
+ app_provider_id=caller.app_provider_id,
x_correlator=x_correlator,
)
@router.get(
"/sessions/{sessionId}",
- tags=["Quality on Demand Functions"],
- summary="Retrieve details of a QoD Session",
+ tags=["QoS Sessions"],
+ summary="Get QoS session information",
+ response_model=SessionInfo,
+ response_model_exclude_none=True,
+ responses=_responses(400, 401, 403, 404, 500, 503),
)
async def get_qod_session(
sessionId: str,
service: QoDService,
x_correlator: XCorrelatorHeader = None,
-) -> QoDSessionResponse:
+) -> Any:
return await service.get_session(
session_id=sessionId,
x_correlator=x_correlator,
@@ -55,17 +97,54 @@ async def get_qod_session(
@router.delete(
"/sessions/{sessionId}",
- tags=["Quality on Demand Functions"],
- summary="Remove QoD Session",
+ tags=["QoS Sessions"],
+ summary="Delete a QoS session",
status_code=status.HTTP_204_NO_CONTENT,
+ responses=_responses(400, 401, 403, 404, 500, 503),
)
async def delete_qod_session(
sessionId: str,
service: QoDService,
+ caller: Caller,
x_correlator: XCorrelatorHeader = None,
) -> Response:
await service.delete_session(
session_id=sessionId,
+ tenant_id=caller.tenant_id,
+ app_provider_id=caller.app_provider_id,
x_correlator=x_correlator,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post(
+ "/sessions/{sessionId}/extend",
+ tags=["QoS Sessions"],
+ summary="Extend the duration of an active session",
+ responses=_responses(400, 401, 403, 404, 500, 501, 503),
+)
+async def extend_qod_session_duration(sessionId: str, request: ExtendSessionDuration) -> Any:
+ raise NotImplementedException(message="Session duration extension is not implemented")
+
+
+@router.get(
+ "/qos-profiles",
+ tags=["QoS Profiles"],
+ summary="Get All QoS Profiles",
+ responses=_responses(401, 403, 404, 500, 501, 503),
+)
+async def get_qos_profiles(
+ name: Annotated[Optional[str], Query()] = None,
+ status: Annotated[Optional[str], Query()] = None,
+) -> Any:
+ raise NotImplementedException(message="QoS profile catalog is not implemented in this release")
+
+
+@router.get(
+ "/qos-profiles/{name}",
+ tags=["QoS Profiles"],
+ summary="Get QoS Profile for a given name",
+ responses=_responses(400, 401, 403, 404, 500, 501, 503),
+)
+async def get_qos_profile(name: str) -> Any:
+ raise NotImplementedException(message="QoS profile catalog is not implemented in this release")
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 4a75dc91b51af8d79de16a33984844f33fb64209..904b33b0b11547e0d7d9adc9a3eef672f2076cbc 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
@@ -1,8 +1,8 @@
from enum import StrEnum
-from typing import Optional
+from typing import Literal, Optional
from uuid import UUID
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
class QosStatus(StrEnum):
@@ -11,27 +11,49 @@ class QosStatus(StrEnum):
UNAVAILABLE = "UNAVAILABLE"
-class Ipv4Address(BaseModel):
+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] = None
+ phoneNumber: Optional[str] = Field(default=None, pattern=r"^\+?[0-9]{5,15}$")
networkAccessIdentifier: Optional[str] = None
- ipv4Address: Optional[Ipv4Address] = 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 ApplicationServer(BaseModel):
ipv4Address: Optional[str] = None
ipv6Address: Optional[str] = None
+ @model_validator(mode="after")
+ def _require_at_least_one_address(self) -> "ApplicationServer":
+ if not any((self.ipv4Address, self.ipv6Address)):
+ raise ValueError("at least one of ipv4Address or ipv6Address must be provided")
+ return self
+
class PortRange(BaseModel):
- from_: int = Field(alias="from")
- to: int
+ from_: int = Field(alias="from", ge=0, le=65535)
+ to: int = Field(ge=0, le=65535)
model_config = {
"populate_by_name": True,
@@ -42,25 +64,44 @@ class PortsSpec(BaseModel):
ranges: Optional[list[PortRange]] = None
ports: Optional[list[int]] = None
+ @model_validator(mode="after")
+ def _require_ranges_or_ports(self) -> "PortsSpec":
+ if not self.ranges and not self.ports:
+ raise ValueError("at least one of ranges or ports must be provided")
+ return self
+
+
+class Webhook(BaseModel):
+ notificationUrl: str
+ notificationAuthToken: Optional[str] = Field(default=None, min_length=20, max_length=256)
+
+
+class Message(BaseModel):
+ severity: Literal["INFO", "WARNING"]
+ description: str
+
-class QoDSessionRequest(BaseModel):
+class BaseSessionInfo(BaseModel):
device: Device
applicationServer: ApplicationServer
- qosProfile: str
devicePorts: Optional[PortsSpec] = None
applicationServerPorts: Optional[PortsSpec] = None
- sink: Optional[str] = None
+ qosProfile: str = Field(min_length=3, max_length=256, pattern=r"^[a-zA-Z0-9_.-]+$")
+ webhook: Optional[Webhook] = None
+
+
+class CreateSession(BaseSessionInfo):
duration: int = Field(default=86400, ge=1, le=86400)
-class QoDSessionResponse(BaseModel):
+class SessionInfo(BaseSessionInfo):
sessionId: UUID
- device: Device
- applicationServer: ApplicationServer
- qosProfile: str
- duration: int
+ duration: int = Field(ge=1)
startedAt: int
expiresAt: int
qosStatus: QosStatus
- devicePorts: Optional[PortsSpec] = None
- applicationServerPorts: Optional[PortsSpec] = None
+ messages: Optional[list[Message]] = None
+
+
+class ExtendSessionDuration(BaseModel):
+ requestedAdditionalDuration: int = Field(ge=1, le=86399)
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
new file mode 100644
index 0000000000000000000000000000000000000000..1b1fd4cee98d233a8e57fb24a7306c7f6d0ea40a
--- /dev/null
+++ b/src/open_exposure_gateway/application/mappers/quality_on_demand_mapper.py
@@ -0,0 +1,182 @@
+from datetime import datetime, timezone
+from typing import Literal
+from uuid import UUID, uuid4
+
+from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
+ ApplicationServer,
+ CreateSession,
+ Device,
+ DeviceIpv4Addr,
+ PortsSpec,
+ QosStatus,
+ SessionInfo,
+)
+from open_exposure_gateway.domain.models import QodSession
+from open_exposure_gateway.domain.quality_on_demand import (
+ EventQosStatusChangedData,
+ NetworkCapabilityDeactivateTarget,
+ NetworkCapabilityParameters,
+ NetworkCapabilityPayload,
+ NetworkCapabilityPortRange,
+ NetworkCapabilityPorts,
+ NetworkCapabilityTarget,
+ NetworkCapabilityTargetApplicationServer,
+ NetworkCapabilityTargetDevice,
+ QosStatusChangedCloudEvent,
+ SRMNetworkCapability,
+ SRMNetworkCapabilityActivateCommand,
+ SRMNetworkCapabilityDeactivateCommand,
+)
+
+
+def _to_capability_ports(spec: PortsSpec | None) -> NetworkCapabilityPorts | None:
+ """CAMARA `PortsSpec` -> canonical `target.*.ports`."""
+ if spec is None:
+ return None
+ return NetworkCapabilityPorts(
+ ranges=(
+ [NetworkCapabilityPortRange(**{"from": r.from_, "to": r.to}) for r in spec.ranges]
+ if spec.ranges is not None
+ else None
+ ),
+ ports=spec.ports,
+ )
+
+
+def build_activate_command(
+ request: CreateSession,
+ operation_id: UUID,
+ correlation_id: str,
+ requested_at: str,
+ service_specification_id: UUID,
+ service_instance_id: UUID,
+ app_provider_id: str,
+) -> SRMNetworkCapabilityActivateCommand:
+ device = request.device
+ application_server = request.applicationServer
+
+ target = NetworkCapabilityTarget(
+ device=NetworkCapabilityTargetDevice(
+ phone_number=device.phoneNumber,
+ ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None,
+ ipv6=device.ipv6Address,
+ network_access_id=device.networkAccessIdentifier,
+ ports=_to_capability_ports(request.devicePorts),
+ ),
+ application_server=NetworkCapabilityTargetApplicationServer(
+ ipv4=application_server.ipv4Address,
+ ipv6=application_server.ipv6Address,
+ ports=_to_capability_ports(request.applicationServerPorts),
+ ),
+ )
+
+ return SRMNetworkCapabilityActivateCommand(
+ operation_id=str(operation_id),
+ correlation_id=correlation_id,
+ requested_at=requested_at,
+ app_provider_id=app_provider_id,
+ service_instance_id=str(service_instance_id),
+ service_specification_id=str(service_specification_id),
+ network_capability=NetworkCapabilityPayload(
+ target=target,
+ profile_ref=request.qosProfile,
+ parameters=NetworkCapabilityParameters(duration_seconds=request.duration),
+ ),
+ )
+
+
+def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability) -> SessionInfo:
+ """Combines `qod_sessions` (identity, ownership, cached `qosStatus`) with SRM's live
+ capability detail (target device/application server) into the CAMARA response -- the
+ read-side counterpart of `build_activate_command`, and the boundary that keeps SRM's
+ internal shape from leaking onto the CAMARA surface directly.
+ """
+ device_target = capability.parameters_snapshot.target.device
+ server_target = capability.parameters_snapshot.target.application_server
+
+ 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.
+ ipv4_address = DeviceIpv4Addr.model_construct(
+ publicAddress=device_target.ipv4, privateAddress=None, publicPort=None
+ )
+
+ device = Device.model_construct(
+ phoneNumber=device_target.phone_number,
+ networkAccessIdentifier=device_target.network_access_id,
+ ipv4Address=ipv4_address,
+ ipv6Address=device_target.ipv6,
+ )
+ application_server = ApplicationServer(
+ ipv4Address=server_target.ipv4,
+ ipv6Address=server_target.ipv6,
+ )
+ device_ports = (
+ PortsSpec.model_validate(qod_session.device_ports)
+ if qod_session.device_ports is not None
+ else None
+ )
+ application_server_ports = (
+ PortsSpec.model_validate(qod_session.application_server_ports)
+ if qod_session.application_server_ports is not None
+ else None
+ )
+
+ started_at = int((qod_session.created_at or datetime.now(timezone.utc)).timestamp())
+ return SessionInfo(
+ sessionId=qod_session.session_id,
+ device=device,
+ applicationServer=application_server,
+ devicePorts=device_ports,
+ applicationServerPorts=application_server_ports,
+ qosProfile=qod_session.qos_profile,
+ duration=qod_session.duration_seconds,
+ startedAt=started_at,
+ expiresAt=started_at + qod_session.duration_seconds,
+ qosStatus=QosStatus(qod_session.state.value),
+ )
+
+
+def build_deactivate_command(
+ service_instance_id: UUID,
+ operation_id: UUID,
+ correlation_id: str,
+ requested_at: str,
+ app_provider_id: str,
+ grace_period_seconds: int = 0,
+) -> SRMNetworkCapabilityDeactivateCommand:
+ return SRMNetworkCapabilityDeactivateCommand(
+ operation_id=str(operation_id),
+ correlation_id=correlation_id,
+ requested_at=requested_at,
+ app_provider_id=app_provider_id,
+ network_capability=NetworkCapabilityDeactivateTarget(
+ service_instance_id=str(service_instance_id),
+ grace_period_seconds=grace_period_seconds,
+ ),
+ )
+
+
+def build_qos_status_changed_event(
+ session_id: UUID,
+ qos_status: Literal["AVAILABLE", "UNAVAILABLE"],
+ status_info: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED", "DELETE_REQUESTED"] | None,
+ source: str,
+ occurred_at: str,
+) -> QosStatusChangedCloudEvent:
+ return QosStatusChangedCloudEvent(
+ id=uuid4(),
+ source=source,
+ time=occurred_at,
+ data=EventQosStatusChangedData(
+ sessionId=session_id,
+ qosStatus=qos_status,
+ statusInfo=status_info,
+ ),
+ )
diff --git a/src/open_exposure_gateway/application/services/quality_on_demand_service.py b/src/open_exposure_gateway/application/services/quality_on_demand_service.py
index 40501a570a1eb37a91bf4d35551c3f611fdc1e4f..7c736454b2e86e158cd4e0258a43b07f3343dbfe 100644
--- a/src/open_exposure_gateway/application/services/quality_on_demand_service.py
+++ b/src/open_exposure_gateway/application/services/quality_on_demand_service.py
@@ -1,42 +1,466 @@
-from typing import Optional
+from datetime import datetime, timezone
+from typing import Literal, Optional
+from uuid import UUID, uuid4
+
+import structlog
+from pydantic import BaseModel
from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
- QoDSessionRequest,
- QoDSessionResponse,
+ CreateSession,
+ QosStatus,
+ SessionInfo,
+)
+from open_exposure_gateway.application.mappers.quality_on_demand_mapper import (
+ build_activate_command,
+ build_deactivate_command,
+ build_qos_status_changed_event,
+ build_session_info,
+)
+from open_exposure_gateway.core.config import (
+ DEFAULT_QOD_SERVICE_SPECIFICATION_ID,
+ get_settings,
+)
+from open_exposure_gateway.core.exceptions import (
+ BadRequestException,
+ DownstreamServiceException,
+ NotFoundException,
+)
+from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted
+from open_exposure_gateway.domain.models import (
+ CallbackDelivery,
+ CallbackRegistration,
+ Operation,
+ OperationStatus,
+ OperationType,
+ QodSession,
+ QodSessionState,
)
+from open_exposure_gateway.domain.quality_on_demand import SRMOperationStatus, Subject
+from open_exposure_gateway.ports.database.callbacks import (
+ CallbackDeliveryRepository,
+ CallbackRegistrationRepository,
+)
+from open_exposure_gateway.ports.database.operations import OperationRepository
+from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository
+from open_exposure_gateway.ports.databus_port import DataBusPort
+from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort
from open_exposure_gateway.ports.srm_port import SRMClientPort
+logger: structlog.BoundLogger = structlog.get_logger(__name__)
+
+_OPERATION_COMPLETION_STATUS_MAP: dict[str, OperationStatus] = {
+ "completed": OperationStatus.COMPLETED,
+ "partially_completed": OperationStatus.PARTIALLY_COMPLETED,
+ "failed": OperationStatus.FAILED,
+}
+
+_QOS_STATUS_INFO_MAP: dict[str, Literal["DURATION_EXPIRED", "NETWORK_TERMINATED"]] = {
+ "duration_expired": "DURATION_EXPIRED",
+ "network_terminated": "NETWORK_TERMINATED",
+}
+
+_TERMINAL_STATES = frozenset(
+ {QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR}
+)
+
class QualityOnDemandService:
- def __init__(self, srm_client: SRMClientPort) -> None:
+ def __init__(
+ self,
+ srm_client: SRMClientPort,
+ publisher: Optional[DataBusPort] = None,
+ operation_repo: Optional[OperationRepository] = None,
+ qod_session_repo: Optional[QodSessionRepository] = None,
+ callback_registration_repo: Optional[CallbackRegistrationRepository] = None,
+ callback_delivery_repo: Optional[CallbackDeliveryRepository] = None,
+ callback_delivery_port: Optional[QodCallbackDeliveryPort] = None,
+ service_specification_id: UUID = DEFAULT_QOD_SERVICE_SPECIFICATION_ID,
+ ) -> None:
self.srm_client = srm_client
+ self._publisher = publisher
+ self._operation_repo = operation_repo
+ self._qod_session_repo = qod_session_repo
+ self._callback_registration_repo = callback_registration_repo
+ self._callback_delivery_repo = callback_delivery_repo
+ self._callback_delivery_port = callback_delivery_port
+ self._service_specification_id = service_specification_id
+
+ def _parse_session_id(self, session_id: str) -> UUID:
+ try:
+ return UUID(session_id)
+ except ValueError as exc:
+ raise BadRequestException(message=f"Malformed session ID: {session_id}") from exc
+
+ def _new_operation_metadata(self, x_correlator: Optional[str]) -> tuple[UUID, str, str]:
+ operation_id = uuid4()
+ correlation_id = x_correlator or str(uuid4())
+ requested_at = datetime.now(timezone.utc).isoformat()
+ return operation_id, correlation_id, requested_at
+
+ async def _publish(self, subject: Subject, command: BaseModel, error_msg: str) -> None:
+ if self._publisher is None:
+ raise RuntimeError("DataBus publisher is not available")
+ try:
+ await self._publisher.publish(subject, command.model_dump(mode="json", by_alias=True))
+ except Exception as exc:
+ raise DownstreamServiceException(error_msg) from exc
async def create_session(
self,
- request: QoDSessionRequest,
+ request: CreateSession,
+ tenant_id: str,
+ app_provider_id: str,
x_correlator: Optional[str] = None,
- ) -> QoDSessionResponse:
- return await self.srm_client.create_qod_session(
- payload=request.model_dump(mode="json"),
- x_correlator=x_correlator,
+ ) -> SessionInfo:
+ if self._operation_repo is None or self._qod_session_repo is None:
+ raise RuntimeError("Operation/QodSession repositories are not available")
+
+ operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator)
+ session_id = uuid4()
+ # The platform-seeded QoD specification, identical on every session (ADR-0035).
+ # Stored per row so the offering a session was provisioned under stays auditable
+ # once per-offering specifications exist.
+ service_specification_id = self._service_specification_id
+
+ command = build_activate_command(
+ request=request,
+ operation_id=operation_id,
+ correlation_id=correlation_id,
+ requested_at=requested_at,
+ service_specification_id=service_specification_id,
+ service_instance_id=session_id,
+ app_provider_id=app_provider_id,
+ )
+
+ await self._operation_repo.save(
+ Operation(
+ operation_id=operation_id,
+ correlation_id=correlation_id,
+ tenant_id=tenant_id,
+ app_provider_id=app_provider_id,
+ operation_type=OperationType.NETWORK_CAPABILITY,
+ status=OperationStatus.PENDING,
+ subject=Subject.TASK_ACTIVATE,
+ metadata={
+ "qos_profile": request.qosProfile,
+ "service_specification_id": str(service_specification_id),
+ },
+ )
+ )
+ await self._qod_session_repo.save(
+ QodSession(
+ session_id=session_id,
+ operation_id=operation_id,
+ service_specification_id=service_specification_id,
+ qos_profile=request.qosProfile,
+ duration_seconds=request.duration,
+ state=QodSessionState.REQUESTED,
+ device_ports=(
+ request.devicePorts.model_dump(mode="json")
+ if request.devicePorts is not None
+ else None
+ ),
+ application_server_ports=(
+ request.applicationServerPorts.model_dump(mode="json")
+ if request.applicationServerPorts is not None
+ else None
+ ),
+ )
+ )
+
+ if request.webhook is not None:
+ if self._callback_registration_repo is None:
+ raise RuntimeError("CallbackRegistrationRepository is not available")
+ await self._callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation_id,
+ tenant_id=tenant_id,
+ api_family="quality-on-demand",
+ sink=request.webhook.notificationUrl,
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ sink_credential_ref=(
+ f"secret://oeg/{operation_id}/notification-auth-token"
+ if request.webhook.notificationAuthToken
+ else None
+ ),
+ expires_at=None,
+ )
+ )
+
+ await self._publish(
+ Subject.TASK_ACTIVATE,
+ command,
+ "Failed to publish QoD session activation",
+ )
+
+ started_at = int(datetime.now(timezone.utc).timestamp())
+ return SessionInfo(
+ sessionId=session_id,
+ device=request.device,
+ applicationServer=request.applicationServer,
+ devicePorts=request.devicePorts,
+ applicationServerPorts=request.applicationServerPorts,
+ qosProfile=request.qosProfile,
+ webhook=request.webhook,
+ duration=request.duration,
+ startedAt=started_at,
+ expiresAt=started_at + request.duration,
+ qosStatus=QosStatus.REQUESTED,
+ )
+
+ async def _deliver_qos_status_changed(
+ self,
+ operation_id: UUID,
+ session_id: UUID,
+ qos_status: Literal["AVAILABLE", "UNAVAILABLE"],
+ status_info: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED", "DELETE_REQUESTED"] | None,
+ occurred_at: str,
+ ) -> None:
+ if self._callback_registration_repo is None:
+ return
+ registration = await self._callback_registration_repo.get_by_operation_id(operation_id)
+ if registration is None or not registration.is_active:
+ return
+ if self._callback_delivery_port is None or self._callback_delivery_repo is None:
+ raise RuntimeError("CallbackDeliveryPort/CallbackDeliveryRepository is not available")
+
+ source = f"{get_settings().public_base_url}/qod/v0/sessions/{session_id}"
+ cloud_event = build_qos_status_changed_event(
+ session_id=session_id,
+ qos_status=qos_status,
+ status_info=status_info,
+ source=source,
+ occurred_at=occurred_at,
+ )
+
+ prior_attempts = await self._callback_delivery_repo.list_by_callback_registration_id(
+ registration.id
+ )
+ last_error: Optional[str] = None
+ try:
+ await self._callback_delivery_port.deliver(registration.sink, cloud_event)
+ state = "delivered"
+ except Exception as exc:
+ state = "failed"
+ last_error = str(exc)
+ logger.warning("qod_callback_delivery_failed", sink=registration.sink, error=str(exc))
+
+ await self._callback_delivery_repo.save(
+ CallbackDelivery(
+ id=uuid4(),
+ callback_registration_id=registration.id,
+ operation_id=operation_id,
+ attempt=len(prior_attempts) + 1,
+ state=state,
+ last_error=last_error,
+ )
+ )
+
+ async def handle_completed(self, event: SRMOperationCompleted) -> None:
+ if self._operation_repo is None or self._qod_session_repo is None:
+ raise RuntimeError("Operation/QodSession repositories are not available")
+
+ operation_id = UUID(event.operation_id)
+ operation = await self._operation_repo.get_by_id(operation_id)
+ if operation is None:
+ logger.warning(
+ "operation_completed_for_unknown_operation", operation_id=event.operation_id
+ )
+ return
+ if operation.operation_type not in (
+ OperationType.NETWORK_CAPABILITY,
+ OperationType.NETWORK_CAPABILITY_DEACTIVATE,
+ ):
+ return
+
+ status = _OPERATION_COMPLETION_STATUS_MAP[event.status]
+ result = None
+ if status != OperationStatus.FAILED:
+ result = {"instances": [i.model_dump(mode="json") for i in event.instances]}
+ await self._operation_repo.save(
+ operation.model_copy(
+ update={
+ "status": status,
+ "result": result,
+ "error": event.error,
+ "completed_at": datetime.fromisoformat(event.completed_at),
+ }
+ )
+ )
+
+ if operation.operation_type == OperationType.NETWORK_CAPABILITY_DEACTIVATE:
+ await self._handle_deactivate_completed(operation, status)
+ return
+
+ qod_session = await self._qod_session_repo.get_by_operation_id(operation_id)
+ if qod_session is None:
+ logger.warning(
+ "qod_session_completed_for_unknown_operation", operation_id=event.operation_id
+ )
+ return
+
+ instance = event.instances[0] if event.instances else None
+ update: dict[str, object]
+ qos_status: Literal["AVAILABLE", "UNAVAILABLE"]
+ status_info: Literal["NETWORK_TERMINATED"] | None
+ if instance is not None and instance.status == "completed":
+ update = {"state": QodSessionState.AVAILABLE, "external_ref": instance.external_ref}
+ qos_status, status_info = "AVAILABLE", None
+ else:
+ update = {"state": QodSessionState.UNAVAILABLE}
+ qos_status, status_info = "UNAVAILABLE", "NETWORK_TERMINATED"
+
+ prior_state = qod_session.state
+ await self._qod_session_repo.save(qod_session.model_copy(update=update))
+
+ if prior_state == update["state"]:
+ return
+
+ await self._deliver_qos_status_changed(
+ operation_id=operation_id,
+ session_id=qod_session.session_id,
+ qos_status=qos_status,
+ status_info=status_info,
+ occurred_at=event.completed_at,
+ )
+
+ async def _handle_deactivate_completed(
+ self, operation: Operation, status: OperationStatus
+ ) -> None:
+ assert self._qod_session_repo is not None
+ session_id_raw = (operation.metadata or {}).get("session_id")
+ if session_id_raw is None:
+ logger.warning(
+ "deactivate_completed_without_session_id", operation_id=str(operation.operation_id)
+ )
+ return
+
+ qod_session = await self._qod_session_repo.get_by_id(UUID(session_id_raw))
+ if qod_session is None:
+ logger.warning("deactivate_completed_for_unknown_session", session_id=session_id_raw)
+ return
+
+ terminal_state = (
+ QodSessionState.ERROR if status == OperationStatus.FAILED else QodSessionState.DELETED
+ )
+ await self._qod_session_repo.save(qod_session.model_copy(update={"state": terminal_state}))
+
+ async def handle_status_changed(self, event: SRMOperationStatus) -> None:
+ if self._operation_repo is None or self._qod_session_repo is None:
+ raise RuntimeError("Operation/QodSession repositories are not available")
+
+ operation_id = UUID(event.operation_id)
+ operation = await self._operation_repo.get_by_id(operation_id)
+ if operation is None or operation.operation_type != OperationType.NETWORK_CAPABILITY:
+ return
+
+ qod_session = await self._qod_session_repo.get_by_operation_id(operation_id)
+ if qod_session is None:
+ return
+
+ qos_status = (event.metadata or {}).get("qos_status")
+ if qos_status not in ("AVAILABLE", "UNAVAILABLE"):
+ logger.warning(
+ "operation_status_unrecognized_qos_status",
+ operation_id=event.operation_id,
+ metadata=event.metadata,
+ )
+ return
+
+ status = (
+ QodSessionState.AVAILABLE if qos_status == "AVAILABLE" else QodSessionState.UNAVAILABLE
+ )
+ prior_state = qod_session.state
+ await self._qod_session_repo.save(qod_session.model_copy(update={"state": status}))
+
+ if prior_state == status:
+ return
+
+ status_info: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED"] | None = None
+ if qos_status == "UNAVAILABLE":
+ reason = (event.metadata or {}).get("qos_status_info")
+ status_info = _QOS_STATUS_INFO_MAP.get(
+ reason if isinstance(reason, str) else "", "NETWORK_TERMINATED"
+ )
+
+ await self._deliver_qos_status_changed(
+ operation_id=operation_id,
+ session_id=qod_session.session_id,
+ qos_status=qos_status,
+ status_info=status_info,
+ occurred_at=event.emitted_at,
)
async def get_session(
self,
session_id: str,
x_correlator: Optional[str] = None,
- ) -> QoDSessionResponse:
- return await self.srm_client.get_qod_session(
- session_id=session_id,
+ ) -> SessionInfo:
+ if self._qod_session_repo is None:
+ raise RuntimeError("QodSession repository is not available")
+
+ qod_session = await self._qod_session_repo.get_by_id(self._parse_session_id(session_id))
+ if qod_session is None or qod_session.state in _TERMINAL_STATES:
+ raise NotFoundException(message=f"Session {session_id} not found")
+
+ capability = await self.srm_client.get_network_capability(
+ service_instance_id=session_id,
x_correlator=x_correlator,
)
+ return build_session_info(qod_session, capability)
async def delete_session(
self,
session_id: str,
+ tenant_id: str,
+ app_provider_id: str,
x_correlator: Optional[str] = None,
) -> None:
- await self.srm_client.delete_qod_session(
- session_id=session_id,
- x_correlator=x_correlator,
+ if self._operation_repo is None or self._qod_session_repo is None:
+ raise RuntimeError("Operation/QodSession repositories are not available")
+
+ qod_session = await self._qod_session_repo.get_by_id(self._parse_session_id(session_id))
+ if qod_session is None or qod_session.state in _TERMINAL_STATES:
+ raise NotFoundException(message=f"Session {session_id} not found")
+
+ operation_id, correlation_id, requested_at = self._new_operation_metadata(x_correlator)
+ command = build_deactivate_command(
+ service_instance_id=qod_session.session_id,
+ operation_id=operation_id,
+ correlation_id=correlation_id,
+ requested_at=requested_at,
+ app_provider_id=app_provider_id,
)
+
+ await self._operation_repo.save(
+ Operation(
+ operation_id=operation_id,
+ correlation_id=correlation_id,
+ tenant_id=tenant_id,
+ app_provider_id=app_provider_id,
+ operation_type=OperationType.NETWORK_CAPABILITY_DEACTIVATE,
+ status=OperationStatus.PENDING,
+ subject=Subject.TASK_DEACTIVATE,
+ metadata={"session_id": session_id},
+ )
+ )
+
+ await self._publish(
+ Subject.TASK_DEACTIVATE,
+ command,
+ "Failed to publish QoD session deactivation",
+ )
+
+ await self._qod_session_repo.save(
+ qod_session.model_copy(update={"state": QodSessionState.DELETION_REQUESTED})
+ )
+
+ if qod_session.state == QodSessionState.AVAILABLE:
+ await self._deliver_qos_status_changed(
+ operation_id=qod_session.operation_id,
+ session_id=qod_session.session_id,
+ qos_status="UNAVAILABLE",
+ status_info="DELETE_REQUESTED",
+ occurred_at=requested_at,
+ )
diff --git a/src/open_exposure_gateway/core/config.py b/src/open_exposure_gateway/core/config.py
index 4335083144100207b473f3ce317094beadce4d8a..4f6066d0794767616960bb9b976847c312b82c55 100644
--- a/src/open_exposure_gateway/core/config.py
+++ b/src/open_exposure_gateway/core/config.py
@@ -1,8 +1,17 @@
from functools import lru_cache
+from uuid import UUID
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.
+
+DEFAULT_QOD_SERVICE_SPECIFICATION_ID = UUID("7608e902-b927-559f-b448-e7e9061dfa5c")
+
class SRMSettings(BaseModel):
base_url: HttpUrl = HttpUrl("http://localhost:8081")
@@ -21,12 +30,20 @@ class NatsSettings(BaseModel):
max_reconnect_attempts: int = 3
+class ObservabilitySettings(BaseModel):
+ log_level: str = "INFO"
+
+
class CallbackSettings(BaseModel):
timeout: float = 10.0
-class ObservabilitySettings(BaseModel):
- log_level: str = "INFO"
+class QodSettings(BaseModel):
+ service_specification_id: UUID = DEFAULT_QOD_SERVICE_SPECIFICATION_ID
+
+ @property
+ def uses_default_service_specification_id(self) -> bool:
+ return self.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID
class Settings(BaseSettings):
@@ -43,11 +60,13 @@ class Settings(BaseSettings):
debug: bool = False
host: str = "0.0.0.0"
port: int = 8080
+ public_base_url: str = "http://localhost:8080"
srm_settings: SRMSettings = SRMSettings()
postgresql_settings: PostgreSQLSettings = PostgreSQLSettings()
nats_settings: NatsSettings = NatsSettings()
observability_settings: ObservabilitySettings = ObservabilitySettings()
callback_settings: CallbackSettings = CallbackSettings()
+ qod_settings: QodSettings = QodSettings()
@lru_cache
diff --git a/src/open_exposure_gateway/core/state.py b/src/open_exposure_gateway/core/state.py
index 63090ac99d049d579dd4a9355ff927ef64b4526f..8af4926ba281bba5114a50f92b00907230db63a7 100644
--- a/src/open_exposure_gateway/core/state.py
+++ b/src/open_exposure_gateway/core/state.py
@@ -3,6 +3,7 @@ from typing import Protocol
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from open_exposure_gateway.ports.databus_port import DataBusPort
+from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort
from open_exposure_gateway.ports.srm_port import SRMClientPort
@@ -11,3 +12,4 @@ class AppState(Protocol):
publisher: DataBusPort
db_engine: AsyncEngine
session_maker: async_sessionmaker[AsyncSession]
+ qod_callback_client: QodCallbackDeliveryPort
diff --git a/src/open_exposure_gateway/dependencies.py b/src/open_exposure_gateway/dependencies.py
index f826c1934d2a908f22790676865e44fbf4edfc85..067e16b70bc0f88771401c4e8f5f11860b38e4a9 100644
--- a/src/open_exposure_gateway/dependencies.py
+++ b/src/open_exposure_gateway/dependencies.py
@@ -12,12 +12,18 @@ from open_exposure_gateway.adapters.database.repos.app_instances import (
from open_exposure_gateway.adapters.database.repos.app_registrations import (
SqlAppRegistrationRepository,
)
+from open_exposure_gateway.adapters.database.repos.callback_deliveries import (
+ SqlCallbackDeliveryRepository,
+)
from open_exposure_gateway.adapters.database.repos.callback_registrations import (
SqlCallbackRegistrationRepository,
)
from open_exposure_gateway.adapters.database.repos.operations import (
SqlOperationRepository,
)
+from open_exposure_gateway.adapters.database.repos.qod_sessions import (
+ SqlQodSessionRepository,
+)
from open_exposure_gateway.api.camara.common import XCorrelatorHeader
from open_exposure_gateway.application.services.edge_application_management_service import (
EdgeApplicationManagementService,
@@ -25,12 +31,18 @@ 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.config import get_settings
from open_exposure_gateway.core.state import AppState
-from open_exposure_gateway.ports.database.callbacks import CallbackRegistrationRepository
+from open_exposure_gateway.ports.database.callbacks import (
+ CallbackDeliveryRepository,
+ CallbackRegistrationRepository,
+)
from open_exposure_gateway.ports.database.instances import AppInstanceRepository
from open_exposure_gateway.ports.database.operations import OperationRepository
+from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository
from open_exposure_gateway.ports.database.registration import AppRegistrationRepository
from open_exposure_gateway.ports.databus_port import DataBusPort
+from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort
from open_exposure_gateway.ports.srm_port import SRMClientPort
@@ -111,6 +123,18 @@ def get_callback_registration_repo(session: SessionDep) -> CallbackRegistrationR
return SqlCallbackRegistrationRepository(session)
+def get_qod_session_repo(session: SessionDep) -> QodSessionRepository:
+ return SqlQodSessionRepository(session)
+
+
+def get_callback_delivery_repo(session: SessionDep) -> CallbackDeliveryRepository:
+ return SqlCallbackDeliveryRepository(session)
+
+
+def get_qod_callback_client(request: Request) -> QodCallbackDeliveryPort:
+ return get_app_state(request=request).qod_callback_client
+
+
def get_edge_app_service(
srm: SRMClientPort = Depends(get_client),
publisher: DataBusPort = Depends(get_publisher),
@@ -133,5 +157,22 @@ def get_edge_app_service(
def get_qod_service(
srm: SRMClientPort = Depends(get_client),
+ publisher: DataBusPort = Depends(get_publisher),
+ operation_repo: OperationRepository = Depends(get_operation_repo),
+ qod_session_repo: QodSessionRepository = Depends(get_qod_session_repo),
+ callback_registration_repo: CallbackRegistrationRepository = Depends(
+ get_callback_registration_repo
+ ),
+ callback_delivery_repo: CallbackDeliveryRepository = Depends(get_callback_delivery_repo),
+ callback_delivery_port: QodCallbackDeliveryPort = Depends(get_qod_callback_client),
) -> QualityOnDemandService:
- return QualityOnDemandService(srm)
+ return QualityOnDemandService(
+ srm,
+ publisher,
+ operation_repo,
+ qod_session_repo,
+ callback_registration_repo,
+ callback_delivery_repo,
+ callback_delivery_port,
+ get_settings().qod_settings.service_specification_id,
+ )
diff --git a/src/open_exposure_gateway/domain/models/__init__.py b/src/open_exposure_gateway/domain/models/__init__.py
index b3f87957957520ab91630bdc27c93922ea3b9932..c9c51c2940df04aaf4515d8106d1e534baf36652 100644
--- a/src/open_exposure_gateway/domain/models/__init__.py
+++ b/src/open_exposure_gateway/domain/models/__init__.py
@@ -8,6 +8,7 @@ from open_exposure_gateway.domain.models.operations import (
OperationStatus,
OperationType,
)
+from open_exposure_gateway.domain.models.qod_sessions import QodSession, QodSessionState
from open_exposure_gateway.domain.models.registration import (
AppRegistration,
AppRegistrationStatus,
@@ -25,4 +26,6 @@ __all__ = [
"OperationStatus",
"OperationType",
"PackageType",
+ "QodSession",
+ "QodSessionState",
]
diff --git a/src/open_exposure_gateway/domain/models/operations/enums.py b/src/open_exposure_gateway/domain/models/operations/enums.py
index 95399d4fdf953cb03cf23592020ebfab2b23ed4e..1f19d95d4da779385b5a2f1d5e9d6850ce102db6 100644
--- a/src/open_exposure_gateway/domain/models/operations/enums.py
+++ b/src/open_exposure_gateway/domain/models/operations/enums.py
@@ -6,6 +6,7 @@ class OperationType(StrEnum):
SCALE = "scale"
TERMINATE = "terminate"
NETWORK_CAPABILITY = "network_capability"
+ NETWORK_CAPABILITY_DEACTIVATE = "network_capability_deactivate"
class OperationStatus(StrEnum):
diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/__init__.py b/src/open_exposure_gateway/domain/models/qod_sessions/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..be92fc9cc495d56c2fe96636787d30c81030c9b4
--- /dev/null
+++ b/src/open_exposure_gateway/domain/models/qod_sessions/__init__.py
@@ -0,0 +1,4 @@
+from open_exposure_gateway.domain.models.qod_sessions.enums import QodSessionState
+from open_exposure_gateway.domain.models.qod_sessions.models import QodSession
+
+__all__ = ["QodSession", "QodSessionState"]
diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/enums.py b/src/open_exposure_gateway/domain/models/qod_sessions/enums.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3b517e09604207b1600b52c11813c567dc6c6d4
--- /dev/null
+++ b/src/open_exposure_gateway/domain/models/qod_sessions/enums.py
@@ -0,0 +1,10 @@
+from enum import StrEnum
+
+
+class QodSessionState(StrEnum):
+ REQUESTED = "REQUESTED"
+ AVAILABLE = "AVAILABLE"
+ UNAVAILABLE = "UNAVAILABLE"
+ DELETION_REQUESTED = "DELETION_REQUESTED"
+ DELETED = "DELETED"
+ ERROR = "ERROR"
diff --git a/src/open_exposure_gateway/domain/models/qod_sessions/models.py b/src/open_exposure_gateway/domain/models/qod_sessions/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..f96e68d9f0a163f8c2f46e8b53757be77f13fb25
--- /dev/null
+++ b/src/open_exposure_gateway/domain/models/qod_sessions/models.py
@@ -0,0 +1,21 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from pydantic import BaseModel
+
+from open_exposure_gateway.domain.models.qod_sessions.enums import QodSessionState
+
+
+class QodSession(BaseModel):
+ session_id: UUID
+ operation_id: UUID
+ service_specification_id: UUID
+ qos_profile: str
+ duration_seconds: int
+ state: QodSessionState
+ external_ref: str | None = None
+ device_ports: dict[str, Any] | None = None
+ application_server_ports: dict[str, Any] | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
diff --git a/src/open_exposure_gateway/domain/quality_on_demand.py b/src/open_exposure_gateway/domain/quality_on_demand.py
index f3a59ebb361e3d57d6a22733aabf26080a8a8f97..7f5a3101c4538cae9eed0cfb78e49cf417b791c9 100644
--- a/src/open_exposure_gateway/domain/quality_on_demand.py
+++ b/src/open_exposure_gateway/domain/quality_on_demand.py
@@ -1,3 +1,147 @@
-# TODO: Define internal OEG↔SRM domain models for Quality on Demand.
-# These will represent the platform-internal schema used for both the sync path
-# (SRM HTTP client) and the async path (DataBus), translated from the CAMARA schema.
+from __future__ import annotations
+
+from enum import StrEnum
+from typing import Any, Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+
+class Subject(StrEnum):
+ TASK_ACTIVATE = "command.srm.network.capability.activate"
+ TASK_DEACTIVATE = "command.srm.network.capability.deactivate"
+ OPERATION_COMPLETED = "event.srm.operation.completed"
+ OPERATION_STATUS = "event.srm.operation.status"
+
+
+class NetworkCapabilityPortRange(BaseModel):
+ from_: int = Field(alias="from")
+ to: int
+
+ model_config = {"populate_by_name": True}
+
+
+class NetworkCapabilityPorts(BaseModel):
+ """`target.device.ports` / `target.application_server.ports` (ADR-0037) — generic across
+ any capability with a device/application-server target, not a QoD-only extra."""
+
+ ranges: list[NetworkCapabilityPortRange] | None = None
+ ports: list[int] | None = None
+
+
+class NetworkCapabilityTargetDevice(BaseModel):
+ phone_number: str | None = None
+ ipv4: str | None = None
+ ipv6: str | None = None
+ network_access_id: str | None = None
+ ports: NetworkCapabilityPorts | None = None
+
+
+class NetworkCapabilityTargetApplicationServer(BaseModel):
+ ipv4: str | None = None
+ ipv6: str | None = None
+ ports: NetworkCapabilityPorts | None = None
+
+
+class NetworkCapabilityTarget(BaseModel):
+ device: NetworkCapabilityTargetDevice
+ application_server: NetworkCapabilityTargetApplicationServer
+
+
+class NetworkCapabilityParameters(BaseModel):
+ duration_seconds: int
+ # Always {} — nothing sources this yet; reserved by srm.params/v1.
+ extra: dict[str, Any] = Field(default_factory=dict)
+
+
+class NetworkCapabilityPayload(BaseModel):
+ capability_type: str = "qod_session"
+ target: NetworkCapabilityTarget
+ profile_ref: str
+ parameters: NetworkCapabilityParameters
+
+
+class SRMNetworkCapabilityActivateCommand(BaseModel):
+ schema_version: str = "1.0"
+ operation_id: str
+ correlation_id: str
+ requested_at: str
+ app_provider_id: str
+ source: str = "nbi_camara"
+ service_instance_id: str
+ service_specification_id: str
+ zone_id: str | None = None
+ domain_id: str | None = None
+ network_capability: NetworkCapabilityPayload
+
+
+class NetworkCapabilityDeactivateTarget(BaseModel):
+ capability_type: str = "qod_session"
+ service_instance_id: str | None = None
+ external_ref: str | None = None
+ grace_period_seconds: int = 0
+
+
+class SRMNetworkCapabilityDeactivateCommand(BaseModel):
+ schema_version: str = "1.0"
+ operation_id: str
+ correlation_id: str
+ requested_at: str
+ app_provider_id: str
+ source: str = "nbi_camara"
+ network_capability: NetworkCapabilityDeactivateTarget
+
+
+class NetworkCapabilityParametersSnapshot(BaseModel):
+ schema_version: str | None = None
+ target: NetworkCapabilityTarget
+ parameters: dict[str, Any] = Field(default_factory=dict)
+
+
+class SRMNetworkCapability(BaseModel):
+ service_instance_id: str
+ capability_type: str
+ state: str
+ zone_id: str | None = None
+ app_provider_id: str
+ external_ref: str | None = None
+ parameters_snapshot: NetworkCapabilityParametersSnapshot
+ result_summary: dict[str, Any] | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class SRMOperationStatus(BaseModel):
+ """`event.srm.operation.status` (srm/interface-contract.md §C.1).
+
+ Covers both pre-execution states (`accepted`, `failed_before_start`) and, per §C.3's
+ "backend-driven change" case, post-completion capability condition changes (e.g. a QoD
+ session dropped by the network) reusing the original realization's `operation_id`.
+ Backend-specific status (e.g. `qos_status`) lives in `metadata`, never in `state`.
+ """
+
+ schema_version: str
+ operation_id: str
+ service_order_id: str | None = None
+ service_instance_id: str | None = None
+ capability: str | None = None
+ state: Literal["accepted", "failed_before_start", "completed", "failed", "in_progress"]
+ metadata: dict[str, Any] | None = None
+ correlation_id: str
+ emitted_at: str
+
+
+class EventQosStatusChangedData(BaseModel):
+ sessionId: UUID
+ qosStatus: Literal["AVAILABLE", "UNAVAILABLE"]
+ statusInfo: Literal["DURATION_EXPIRED", "NETWORK_TERMINATED", "DELETE_REQUESTED"] | None = None
+
+
+class QosStatusChangedCloudEvent(BaseModel):
+ id: UUID
+ source: str
+ specversion: str = "1.0"
+ type: str = "org.camaraproject.qod.v0.qos-status-changed"
+ time: str
+ datacontenttype: str = "application/json"
+ data: EventQosStatusChangedData
diff --git a/src/open_exposure_gateway/main.py b/src/open_exposure_gateway/main.py
index fb4967d585e0cd6b7450bf237543c3a6a4b2f185..364e1f22635b6b72370db3441fcfc94a56e2de41 100644
--- a/src/open_exposure_gateway/main.py
+++ b/src/open_exposure_gateway/main.py
@@ -22,11 +22,14 @@ from open_exposure_gateway.adapters.database.repos.callback_registrations import
SqlCallbackRegistrationRepository,
)
from open_exposure_gateway.adapters.database.repos.operations import SqlOperationRepository
+from open_exposure_gateway.adapters.database.repos.qod_sessions import SqlQodSessionRepository
from open_exposure_gateway.adapters.databus.nats_adapter import (
NatsMessagePublisher,
NatsOperationConsumer,
+ NatsOperationStatusConsumer,
)
from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient
+from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient
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,
@@ -42,13 +45,19 @@ from open_exposure_gateway.api.platform.health import router as health_router
from open_exposure_gateway.application.services.edge_application_management_service import (
EdgeApplicationManagementService,
)
+from open_exposure_gateway.application.services.quality_on_demand_service import (
+ QualityOnDemandService,
+)
from open_exposure_gateway.core.config import get_settings
from open_exposure_gateway.core.logging import configure_logging
from open_exposure_gateway.domain.edge_application_management import (
SRMOperationCompleted,
Subject,
)
+from open_exposure_gateway.domain.quality_on_demand import SRMOperationStatus
+from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject
from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort
+from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort
from open_exposure_gateway.ports.srm_port import SRMClientPort
@@ -78,6 +87,58 @@ def _build_operation_completed_handler(
return handle
+def _build_qod_service(
+ session: AsyncSession,
+ srm_client: SRMClientPort,
+ qod_callback_client: QodCallbackDeliveryPort,
+) -> QualityOnDemandService:
+ return QualityOnDemandService(
+ srm_client=srm_client,
+ operation_repo=SqlOperationRepository(session),
+ qod_session_repo=SqlQodSessionRepository(session),
+ callback_registration_repo=SqlCallbackRegistrationRepository(session),
+ callback_delivery_repo=SqlCallbackDeliveryRepository(session),
+ callback_delivery_port=qod_callback_client,
+ service_specification_id=get_settings().qod_settings.service_specification_id,
+ )
+
+
+def _build_qod_operation_completed_handler(
+ session_maker: async_sessionmaker[AsyncSession],
+ srm_client: SRMClientPort,
+ qod_callback_client: QodCallbackDeliveryPort,
+) -> Callable[[SRMOperationCompleted], Awaitable[None]]:
+ async def handle(event: SRMOperationCompleted) -> None:
+ async with session_maker() as session:
+ try:
+ service = _build_qod_service(session, srm_client, qod_callback_client)
+ await service.handle_completed(event)
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+
+ return handle
+
+
+def _build_qod_operation_status_handler(
+ session_maker: async_sessionmaker[AsyncSession],
+ srm_client: SRMClientPort,
+ qod_callback_client: QodCallbackDeliveryPort,
+) -> Callable[[SRMOperationStatus], Awaitable[None]]:
+ async def handle(event: SRMOperationStatus) -> None:
+ async with session_maker() as session:
+ try:
+ service = _build_qod_service(session, srm_client, qod_callback_client)
+ await service.handle_status_changed(event)
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+
+ return handle
+
+
openapi_tags = [
{
"name": "Application",
@@ -110,6 +171,18 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger = structlog.get_logger()
logger.info("Starting", app=settings.app_name, version=settings.app_version)
+ if settings.qod_settings.uses_default_service_specification_id:
+ logger.warning(
+ "qod_service_specification_id_defaulted",
+ service_specification_id=str(settings.qod_settings.service_specification_id),
+ detail=("QOD_SETTINGS__SERVICE_SPECIFICATION_ID is unset; using a hardcoded id."),
+ )
+ else:
+ logger.info(
+ "qod_service_specification_id_configured",
+ service_specification_id=str(settings.qod_settings.service_specification_id),
+ )
+
try:
db_engine, session_maker = await build_engine_and_session_maker(
url=settings.postgresql_settings.url,
@@ -131,6 +204,8 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.error("Failed to initialize SRM client", error=str(e))
raise
+ qod_callback_client = HttpQodCallbackClient()
+
try:
publisher = NatsMessagePublisher(settings.nats_settings)
await publisher.connect()
@@ -139,18 +214,36 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.error("Failed to connect NATS publisher", error=str(e))
raise
- consumer = NatsOperationConsumer(
+ eam_operation_consumer = NatsOperationConsumer(
client=publisher.client,
subject=Subject.OPERATION_COMPLETED,
handler=_build_operation_completed_handler(session_maker, srm_client, HttpCallbackClient()),
)
- await consumer.start()
+ await eam_operation_consumer.start()
+
+ operation_completed_consumer = NatsOperationConsumer(
+ client=publisher.client,
+ subject=Subject.OPERATION_COMPLETED,
+ handler=_build_qod_operation_completed_handler(
+ session_maker, srm_client, qod_callback_client
+ ),
+ )
+ await operation_completed_consumer.start()
logger.info("NATS consumer started", subject=Subject.OPERATION_COMPLETED)
+ operation_status_consumer = NatsOperationStatusConsumer(
+ client=publisher.client,
+ subject=QodSubject.OPERATION_STATUS,
+ handler=_build_qod_operation_status_handler(session_maker, srm_client, qod_callback_client),
+ )
+ await operation_status_consumer.start()
+ logger.info("NATS consumer started", subject=QodSubject.OPERATION_STATUS)
+
app.state.srm_client = srm_client
app.state.publisher = publisher
app.state.db_engine = db_engine
app.state.session_maker = session_maker
+ app.state.qod_callback_client = qod_callback_client
yield
diff --git a/src/open_exposure_gateway/ports/database/__init__.py b/src/open_exposure_gateway/ports/database/__init__.py
index 340826b0b41a664fc53d793238bf17734a738252..9ad4646adc285d8067ebae21fc5a3754ab665902 100644
--- a/src/open_exposure_gateway/ports/database/__init__.py
+++ b/src/open_exposure_gateway/ports/database/__init__.py
@@ -4,6 +4,7 @@ from open_exposure_gateway.ports.database.callbacks import (
)
from open_exposure_gateway.ports.database.instances import AppInstanceRepository
from open_exposure_gateway.ports.database.operations import OperationRepository
+from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository
from open_exposure_gateway.ports.database.registration import AppRegistrationRepository
__all__ = [
@@ -12,4 +13,5 @@ __all__ = [
"CallbackDeliveryRepository",
"CallbackRegistrationRepository",
"OperationRepository",
+ "QodSessionRepository",
]
diff --git a/src/open_exposure_gateway/ports/database/qod_sessions.py b/src/open_exposure_gateway/ports/database/qod_sessions.py
new file mode 100644
index 0000000000000000000000000000000000000000..e163a7b0a75271ca919a7bef8c97c4faf504dcfc
--- /dev/null
+++ b/src/open_exposure_gateway/ports/database/qod_sessions.py
@@ -0,0 +1,20 @@
+"""QoD session repository ports."""
+
+from abc import ABC, abstractmethod
+from uuid import UUID
+
+from open_exposure_gateway.domain.models import QodSession
+
+
+class QodSessionRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, session_id: UUID) -> QodSession | None:
+ pass
+
+ @abstractmethod
+ async def get_by_operation_id(self, operation_id: UUID) -> QodSession | None:
+ pass
+
+ @abstractmethod
+ async def save(self, qod_session: QodSession) -> QodSession:
+ pass
diff --git a/src/open_exposure_gateway/ports/qod_callback_port.py b/src/open_exposure_gateway/ports/qod_callback_port.py
new file mode 100644
index 0000000000000000000000000000000000000000..46b6856bc618642eb679c3302f439a84a4ec2815
--- /dev/null
+++ b/src/open_exposure_gateway/ports/qod_callback_port.py
@@ -0,0 +1,7 @@
+from typing import Protocol
+
+from open_exposure_gateway.domain.quality_on_demand import QosStatusChangedCloudEvent
+
+
+class QodCallbackDeliveryPort(Protocol):
+ async def deliver(self, sink: str, event: QosStatusChangedCloudEvent) -> None: ...
diff --git a/src/open_exposure_gateway/ports/srm_port.py b/src/open_exposure_gateway/ports/srm_port.py
index fe0f3eeb38d065f27032acf17db5a0ffde90296e..e37fc33465e1386d8b36434e5ca2575a40c9ac09 100644
--- a/src/open_exposure_gateway/ports/srm_port.py
+++ b/src/open_exposure_gateway/ports/srm_port.py
@@ -1,15 +1,13 @@
from typing import Any, Protocol
from uuid import UUID
-from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
- QoDSessionResponse,
-)
from open_exposure_gateway.domain.edge_application_management import (
SRMCatalogPayload,
SRMCatalogServiceSpecificationCreated,
SRMServiceInstance,
SRMZone,
)
+from open_exposure_gateway.domain.quality_on_demand import SRMNetworkCapability
class SRMClientPort(Protocol):
@@ -38,12 +36,6 @@ class SRMClientPort(Protocol):
x_correlator: str | None,
) -> list[SRMServiceInstance]: ...
- async def create_qod_session(
- self, payload: dict[str, Any], x_correlator: str | None
- ) -> QoDSessionResponse: ...
-
- async def get_qod_session(
- self, session_id: str, x_correlator: str | None
- ) -> QoDSessionResponse: ...
-
- async def delete_qod_session(self, session_id: str, x_correlator: str | None) -> None: ...
+ async def get_network_capability(
+ self, service_instance_id: str, x_correlator: str | None
+ ) -> SRMNetworkCapability: ...
diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py
index 4885ac5c1e44419c25bf3a8d8220e1b6520320af..e0918d7349f9237e91a3cff590449670d1e25e4e 100644
--- a/tests/conformance/conftest.py
+++ b/tests/conformance/conftest.py
@@ -30,11 +30,15 @@ from tests.conformance.harness import app
from tests.unit.fakes import (
FakeAppInstanceRepository,
FakeAppRegistrationRepository,
+ FakeCallbackDeliveryRepository,
FakeCallbackRegistrationRepository,
FakeDataBus,
FakeOperationRepository,
+ FakeQodCallbackDeliveryPort,
+ FakeQodSessionRepository,
FakeSRMClient,
wire_operation_consumer,
+ wire_qod_operation_consumer,
wire_srm_worker,
)
@@ -66,7 +70,29 @@ def service_overrides() -> Generator[None, None, None]:
app_instance_repo=app_instance_repo,
callback_registration_repo=FakeCallbackRegistrationRepository(),
)
- app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService(srm_client=srm)
+
+ qod_operation_repo = FakeOperationRepository()
+ qod_session_repo = FakeQodSessionRepository()
+ qod_callback_registration_repo = FakeCallbackRegistrationRepository()
+ qod_callback_delivery_repo = FakeCallbackDeliveryRepository()
+ qod_callback_delivery_port = FakeQodCallbackDeliveryPort()
+ wire_qod_operation_consumer(
+ bus,
+ qod_operation_repo,
+ qod_session_repo,
+ qod_callback_registration_repo,
+ qod_callback_delivery_repo,
+ qod_callback_delivery_port,
+ )
+ app.dependency_overrides[get_qod_service] = lambda: QualityOnDemandService(
+ srm_client=srm,
+ publisher=bus,
+ operation_repo=qod_operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=qod_callback_registration_repo,
+ callback_delivery_repo=qod_callback_delivery_repo,
+ callback_delivery_port=qod_callback_delivery_port,
+ )
app.dependency_overrides[get_publisher] = lambda: bus
yield
app.dependency_overrides.clear()
diff --git a/tests/conformance/test_eam_conformance.py b/tests/conformance/test_eam_conformance.py
index 5caaeacd90362a0272f7f42a0bf58a843032a834..d87e7c992834c15a67013b37d4b53266ff5e12a4 100644
--- a/tests/conformance/test_eam_conformance.py
+++ b/tests/conformance/test_eam_conformance.py
@@ -33,20 +33,18 @@ SPEC = (
schema = schemathesis.openapi.from_path(SPEC)
schema.app = app
-# Keep runtime bounded; raise max_examples (and drop no_shrink) when digging
-# into a specific failure.
schema.config.generation.update(max_examples=10, no_shrink=True)
-# 501 is a deliberate, spec-documented response (ADR-0009: unsupported
-# packageType/infraKind variants), not a server crash; Schemathesis's
-# not_a_server_error check flags any 5xx by default regardless of the spec
-# (see schemathesis/schemathesis#2539).
-schema.config.checks.not_a_server_error.expected_statuses.append("501")
-# 400 for a schema-valid but unregistered appId is a deliberate choice to stay
-# within createAppInstance's literally-documented response codes (400/401/403/
-# 409/500/501/503 — no 404 listed), even though CAMARA's own Generic400 vs
-# Generic404 semantics would suggest 404. Schemathesis's positive_data_acceptance
-# check otherwise flags this as rejecting schema-compliant data.
-schema.config.checks.positive_data_acceptance.expected_statuses.append("400")
+# 501 is spec-documented, not a crash; not_a_server_error flags all 5xx.
+schema.config.checks.not_a_server_error.expected_statuses = [
+ *schema.config.checks.not_a_server_error.expected_statuses,
+ "501",
+]
+# 400 for a schema-valid but unregistered appId stays within createAppInstance's
+# documented codes (no 404 listed); positive_data_acceptance otherwise flags it.
+schema.config.checks.positive_data_acceptance.expected_statuses = [
+ *schema.config.checks.positive_data_acceptance.expected_statuses,
+ "400",
+]
@schema.parametrize()
diff --git a/tests/conformance/test_qod_conformance.py b/tests/conformance/test_qod_conformance.py
new file mode 100644
index 0000000000000000000000000000000000000000..b15993148bd45ba4f798d070db76f674ae8da263
--- /dev/null
+++ b/tests/conformance/test_qod_conformance.py
@@ -0,0 +1,46 @@
+"""CAMARA Quality on Demand 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"
+ / "quality_on_demand"
+ / "API_definitions"
+ / "qod-api.yaml"
+)
+
+schema = schemathesis.openapi.from_path(SPEC)
+schema.app = app
+schema.config.generation.update(max_examples=10, no_shrink=True)
+# ApplicationServer accepts empty ipv4/ipv6 strings at the schema level; our
+# validator correctly rejects them, which positive_data_acceptance otherwise flags.
+schema.config.checks.positive_data_acceptance.expected_statuses = [
+ *schema.config.checks.positive_data_acceptance.expected_statuses,
+ "400",
+]
+
+
+@schema.parametrize()
+def test_qod_conformance(case: "OpenApiCase") -> None:
+ case.call_and_validate()
diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py
index ad620beb7c12745a1e1b6451ee3841062c0cca2d..5abe6ffa29be25db8895e573df76ac3b8645b21f 100644
--- a/tests/integration/test_postgres.py
+++ b/tests/integration/test_postgres.py
@@ -17,6 +17,7 @@ from open_exposure_gateway.adapters.database.repos.callback_registrations import
SqlCallbackRegistrationRepository,
)
from open_exposure_gateway.adapters.database.repos.operations import SqlOperationRepository
+from open_exposure_gateway.adapters.database.repos.qod_sessions import SqlQodSessionRepository
from open_exposure_gateway.adapters.errors import (
DuplicateAppRegistrationError,
DuplicateOperationError,
@@ -32,6 +33,8 @@ from open_exposure_gateway.domain.models import (
OperationStatus,
OperationType,
PackageType,
+ QodSession,
+ QodSessionState,
)
@@ -85,6 +88,17 @@ def _callback_registration(operation_id: UUID) -> CallbackRegistration:
)
+def _qod_session(operation_id: UUID) -> QodSession:
+ return QodSession(
+ session_id=uuid4(),
+ operation_id=operation_id,
+ service_specification_id=uuid4(),
+ qos_profile="QOS_E",
+ duration_seconds=3600,
+ state=QodSessionState.REQUESTED,
+ )
+
+
def _callback_delivery(callback_registration_id: UUID, operation_id: UUID) -> CallbackDelivery:
return CallbackDelivery(
id=uuid4(),
@@ -339,3 +353,87 @@ async def test_callback_delivery_repo_persists_and_lists(db_session: AsyncSessio
assert len(deliveries) == 1
assert deliveries[0].id == saved.id
assert deliveries[0].state == "delivered"
+
+
+async def test_qod_session_repo_persists_and_loads(db_session: AsyncSession) -> None:
+ operation = await SqlOperationRepository(db_session).save(_operation())
+ repo = SqlQodSessionRepository(db_session)
+ qod_session = _qod_session(operation_id=operation.operation_id)
+
+ saved = await repo.save(qod_session)
+
+ assert saved.session_id == qod_session.session_id
+ assert saved.created_at is not None
+ assert saved.updated_at is not None
+
+ by_id = await repo.get_by_id(saved.session_id)
+ by_operation_id = await repo.get_by_operation_id(operation.operation_id)
+
+ assert by_id is not None
+ assert by_operation_id is not None
+ assert by_id == by_operation_id
+ assert by_id.service_specification_id == qod_session.service_specification_id
+ assert by_id.qos_profile == qod_session.qos_profile
+ assert by_id.duration_seconds == qod_session.duration_seconds
+ assert by_id.state == QodSessionState.REQUESTED
+ assert by_id.external_ref is None
+ assert by_id.device_ports is None
+ assert by_id.application_server_ports is None
+
+
+async def test_qod_session_repo_persists_optional_fields(db_session: AsyncSession) -> None:
+ operation = await SqlOperationRepository(db_session).save(_operation())
+ repo = SqlQodSessionRepository(db_session)
+ qod_session = _qod_session(operation_id=operation.operation_id)
+ qod_session.external_ref = "external-ref-1"
+ qod_session.device_ports = {"ports": [1000, 2000]}
+ qod_session.application_server_ports = {"ports": [3000]}
+
+ saved = await repo.save(qod_session)
+ reloaded = await repo.get_by_id(saved.session_id)
+
+ assert reloaded is not None
+ assert reloaded.external_ref == "external-ref-1"
+ assert reloaded.device_ports == {"ports": [1000, 2000]}
+ assert reloaded.application_server_ports == {"ports": [3000]}
+
+
+async def test_qod_session_repo_get_by_id_returns_none_when_missing(
+ db_session: AsyncSession,
+) -> None:
+ repo = SqlQodSessionRepository(db_session)
+
+ assert await repo.get_by_id(uuid4()) is None
+
+
+async def test_qod_session_repo_get_by_operation_id_returns_none_when_missing(
+ db_session: AsyncSession,
+) -> None:
+ repo = SqlQodSessionRepository(db_session)
+
+ assert await repo.get_by_operation_id(uuid4()) is None
+
+
+async def test_qod_session_repo_updates_updated_at_on_second_save(db_engine: AsyncEngine) -> None:
+ session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False)
+
+ async with session_factory() as session:
+ operation = await SqlOperationRepository(session).save(_operation())
+ saved = await SqlQodSessionRepository(session).save(
+ _qod_session(operation_id=operation.operation_id)
+ )
+ await session.commit()
+
+ await asyncio.sleep(0.01)
+
+ async with session_factory() as session:
+ repo = SqlQodSessionRepository(session)
+ saved.state = QodSessionState.AVAILABLE
+ updated = await repo.save(saved)
+ await session.commit()
+
+ assert updated.created_at == saved.created_at
+ assert updated.created_at is not None
+ assert updated.updated_at is not None
+ assert updated.updated_at > updated.created_at
+ assert updated.state == QodSessionState.AVAILABLE
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 075f84795eee88190e5872091797859067201c62..5e2b0dc90c4ff7d59bead10cddd08a4b69531319 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -29,8 +29,11 @@ from tests.unit.fakes import (
FakeCallbackRegistrationRepository,
FakeDataBus,
FakeOperationRepository,
+ FakeQodCallbackDeliveryPort,
+ FakeQodSessionRepository,
FakeSRMClient,
wire_operation_consumer,
+ wire_qod_operation_consumer,
wire_srm_worker,
)
@@ -121,6 +124,16 @@ def live_srm(
return fake_srm
+@pytest.fixture()
+def qod_session_repo() -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+
+@pytest.fixture()
+def qod_callback_delivery_port() -> FakeQodCallbackDeliveryPort:
+ return FakeQodCallbackDeliveryPort()
+
+
@pytest.fixture()
def eam_service(
fake_srm: FakeSRMClient,
@@ -141,8 +154,45 @@ def eam_service(
@pytest.fixture()
-def qod_service(fake_srm: FakeSRMClient) -> QualityOnDemandService:
- return QualityOnDemandService(srm_client=fake_srm)
+def live_qod(
+ fake_bus: FakeDataBus,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_repo: FakeCallbackDeliveryRepository,
+ qod_callback_delivery_port: FakeQodCallbackDeliveryPort,
+) -> None:
+ """Wires QoD's real completion/status handlers behind the fake bus, sharing the
+ same repo/port instances the api_client's qod_service writes to and delivers with."""
+ wire_qod_operation_consumer(
+ fake_bus,
+ operation_repo,
+ qod_session_repo,
+ callback_registration_repo,
+ callback_delivery_repo,
+ qod_callback_delivery_port,
+ )
+
+
+@pytest.fixture()
+def qod_service(
+ fake_srm: FakeSRMClient,
+ fake_bus: FakeDataBus,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_repo: FakeCallbackDeliveryRepository,
+ qod_callback_delivery_port: FakeQodCallbackDeliveryPort,
+) -> QualityOnDemandService:
+ return QualityOnDemandService(
+ srm_client=fake_srm,
+ publisher=fake_bus,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=qod_callback_delivery_port,
+ )
@pytest.fixture()
diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py
index ec7dfef5dc252be9a6619a4d47d87e9f4e256d81..346960f55948ceff6462e174d531f126ef55f4ce 100644
--- a/tests/unit/fakes.py
+++ b/tests/unit/fakes.py
@@ -22,18 +22,20 @@ from typing import Any
from unittest.mock import AsyncMock
from uuid import UUID, uuid4
-from open_exposure_gateway.adapters.databus.nats_adapter import NatsOperationConsumer
+from open_exposure_gateway.adapters.databus.nats_adapter import (
+ NatsOperationConsumer,
+ NatsOperationStatusConsumer,
+)
from open_exposure_gateway.adapters.errors import (
DuplicateAppRegistrationError,
DuplicateOperationError,
)
-from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
- QoDSessionResponse,
- QosStatus,
-)
from open_exposure_gateway.application.services.edge_application_management_service import (
EdgeApplicationManagementService,
)
+from open_exposure_gateway.application.services.quality_on_demand_service import (
+ QualityOnDemandService,
+)
from open_exposure_gateway.core.exceptions import NotFoundException
from open_exposure_gateway.domain.edge_application_management import (
AppInstanceStatusChangeCloudEvent,
@@ -51,7 +53,15 @@ from open_exposure_gateway.domain.models import (
CallbackDelivery,
CallbackRegistration,
Operation,
+ QodSession,
+)
+from open_exposure_gateway.domain.quality_on_demand import (
+ NetworkCapabilityParametersSnapshot,
+ NetworkCapabilityTarget,
+ QosStatusChangedCloudEvent,
+ SRMNetworkCapability,
)
+from open_exposure_gateway.domain.quality_on_demand import Subject as QodSubject
from open_exposure_gateway.ports.callback_delivery_port import CallbackDeliveryPort
from open_exposure_gateway.ports.database.callbacks import (
CallbackDeliveryRepository,
@@ -59,7 +69,9 @@ from open_exposure_gateway.ports.database.callbacks import (
)
from open_exposure_gateway.ports.database.instances import AppInstanceRepository
from open_exposure_gateway.ports.database.operations import OperationRepository
+from open_exposure_gateway.ports.database.qod_sessions import QodSessionRepository
from open_exposure_gateway.ports.database.registration import AppRegistrationRepository
+from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort
Handler = Callable[[dict[str, Any]], Awaitable[None]]
@@ -99,7 +111,7 @@ class FakeSRMClient:
self.zones: list[SRMZone] = []
self.catalog: dict[str, dict[str, Any]] = {}
self.instances: dict[str, SRMServiceInstance] = {}
- self.qod_sessions: dict[str, QoDSessionResponse] = {}
+ self.network_capabilities: dict[str, SRMNetworkCapability] = {}
async def get_zones(
self,
@@ -142,32 +154,13 @@ class FakeSRMClient:
result = [i for i in result if i.service_instance_id == str(app_instance_id)]
return result
- async def create_qod_session(
- self, payload: dict[str, Any], x_correlator: str | None = None
- ) -> QoDSessionResponse:
- duration = payload.get("duration", 86400)
- session = QoDSessionResponse(
- sessionId=uuid4(),
- device=payload["device"],
- applicationServer=payload["applicationServer"],
- qosProfile=payload["qosProfile"],
- duration=duration,
- startedAt=1_750_000_000,
- expiresAt=1_750_000_000 + duration,
- qosStatus=QosStatus.REQUESTED,
- devicePorts=payload.get("devicePorts"),
- applicationServerPorts=payload.get("applicationServerPorts"),
- )
- self.qod_sessions[str(session.sessionId)] = session
- return session
-
- async def get_qod_session(
- self, session_id: str, x_correlator: str | None = None
- ) -> QoDSessionResponse:
- return self.qod_sessions[session_id]
-
- async def delete_qod_session(self, session_id: str, x_correlator: str | None = None) -> None:
- self.qod_sessions.pop(session_id, None)
+ async def get_network_capability(
+ self, service_instance_id: str, x_correlator: str | None = None
+ ) -> SRMNetworkCapability:
+ capability = self.network_capabilities.get(service_instance_id)
+ if capability is None:
+ raise NotFoundException(message=f"Network capability {service_instance_id} not found")
+ return capability
def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]:
@@ -189,6 +182,19 @@ def completion_payload(operation_id: str, **overrides: Any) -> dict[str, Any]:
return payload
+def operation_status_payload(operation_id: str, **overrides: Any) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "schema_version": "1.0",
+ "operation_id": operation_id,
+ "state": "completed",
+ "metadata": {"qos_status": "UNAVAILABLE"},
+ "correlation_id": str(uuid4()),
+ "emitted_at": "2026-07-03T12:00:00+00:00",
+ }
+ payload.update(overrides)
+ return payload
+
+
def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None:
async def on_deploy(command: dict[str, Any]) -> None:
# POST /appinstances always carries exactly one targets[] entry
@@ -240,8 +246,30 @@ def wire_srm_worker(bus: FakeDataBus, srm: FakeSRMClient) -> None:
),
)
+ async def on_qod_activate(command: dict[str, Any]) -> None:
+ service_instance_id = command["service_instance_id"]
+ payload = command["network_capability"]
+ srm.network_capabilities[service_instance_id] = SRMNetworkCapability(
+ service_instance_id=service_instance_id,
+ capability_type=payload["capability_type"],
+ state="active",
+ zone_id=command.get("zone_id"),
+ app_provider_id=command["app_provider_id"],
+ parameters_snapshot=NetworkCapabilityParametersSnapshot(
+ schema_version="srm.params/v1",
+ target=NetworkCapabilityTarget.model_validate(payload["target"]),
+ parameters={**payload["parameters"], "profile_ref": payload["profile_ref"]},
+ ),
+ )
+
+ async def on_qod_deactivate(command: dict[str, Any]) -> None:
+ service_instance_id = command["network_capability"]["service_instance_id"]
+ srm.network_capabilities.pop(service_instance_id, None)
+
bus.subscribe(Subject.TASK_DEPLOY, on_deploy)
bus.subscribe(Subject.TASK_TERMINATE, on_terminate)
+ bus.subscribe(QodSubject.TASK_ACTIVATE, on_qod_activate)
+ bus.subscribe(QodSubject.TASK_DEACTIVATE, on_qod_deactivate)
def wire_operation_consumer(
@@ -280,6 +308,51 @@ def wire_operation_consumer(
return consumer
+def wire_qod_operation_consumer(
+ bus: FakeDataBus,
+ operation_repo: OperationRepository,
+ qod_session_repo: QodSessionRepository,
+ callback_registration_repo: CallbackRegistrationRepository | None = None,
+ callback_delivery_repo: CallbackDeliveryRepository | None = None,
+ callback_delivery_port: QodCallbackDeliveryPort | None = None,
+) -> NatsOperationConsumer:
+ """Wires QualityOnDemandService.handle_completed and handle_status_changed behind
+ the fake bus -- pass the same repo instances used to build the service under test
+ so a completion/status event updates the rows the test can see."""
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ completed_consumer = NatsOperationConsumer(
+ client=AsyncMock(), subject=QodSubject.OPERATION_COMPLETED, handler=service.handle_completed
+ )
+
+ async def deliver_completed(payload: dict[str, Any]) -> None:
+ await completed_consumer._handle_message(
+ FakeMsg(data=json.dumps(payload).encode(), subject=str(QodSubject.OPERATION_COMPLETED))
+ )
+
+ bus.subscribe(QodSubject.OPERATION_COMPLETED, deliver_completed)
+
+ status_consumer = NatsOperationStatusConsumer(
+ client=AsyncMock(),
+ subject=QodSubject.OPERATION_STATUS,
+ handler=service.handle_status_changed,
+ )
+
+ async def deliver_status(payload: dict[str, Any]) -> None:
+ await status_consumer._handle_message(
+ FakeMsg(data=json.dumps(payload).encode(), subject=str(QodSubject.OPERATION_STATUS))
+ )
+
+ bus.subscribe(QodSubject.OPERATION_STATUS, deliver_status)
+ return completed_consumer
+
+
class FakeAppRegistrationRepository(AppRegistrationRepository):
def __init__(self) -> None:
self.rows: dict[UUID, AppRegistration] = {}
@@ -382,6 +455,26 @@ class FakeAppInstanceRepository(AppInstanceRepository):
return stored.model_copy(deep=True)
+class FakeQodSessionRepository(QodSessionRepository):
+ def __init__(self) -> None:
+ self.rows: dict[UUID, QodSession] = {}
+
+ async def get_by_id(self, session_id: UUID) -> QodSession | None:
+ found = self.rows.get(session_id)
+ return found.model_copy(deep=True) if found is not None else None
+
+ async def get_by_operation_id(self, operation_id: UUID) -> QodSession | None:
+ for row in self.rows.values():
+ if row.operation_id == operation_id:
+ return row.model_copy(deep=True)
+ return None
+
+ async def save(self, qod_session: QodSession) -> QodSession:
+ stored = qod_session.model_copy(deep=True)
+ self.rows[stored.session_id] = stored
+ return stored.model_copy(deep=True)
+
+
class FakeCallbackRegistrationRepository(CallbackRegistrationRepository):
def __init__(self) -> None:
self.rows: dict[UUID, CallbackRegistration] = {}
@@ -423,3 +516,11 @@ class FakeCallbackDeliveryPort:
async def deliver(self, sink: str, event: AppInstanceStatusChangeCloudEvent) -> None:
self.delivered.append((sink, event))
+
+
+class FakeQodCallbackDeliveryPort:
+ def __init__(self) -> None:
+ self.delivered: list[tuple[str, QosStatusChangedCloudEvent]] = []
+
+ async def deliver(self, sink: str, event: QosStatusChangedCloudEvent) -> None:
+ self.delivered.append((sink, event))
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 777a930def85e016cc7f963cc7eabeda92586272..fff5fbdf546e744e06d28def32b18fdae3df173d 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -1,8 +1,13 @@
import os
+from uuid import NAMESPACE_URL, UUID, uuid5
import pytest
-from open_exposure_gateway.core.config import Settings, get_settings
+from open_exposure_gateway.core.config import (
+ DEFAULT_QOD_SERVICE_SPECIFICATION_ID,
+ Settings,
+ get_settings,
+)
class TestNestedEnvironmentVariables:
@@ -47,6 +52,16 @@ class TestNestedEnvironmentVariables:
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.observability_settings.log_level == "DEBUG"
+ def test_qod_service_specification_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(
+ "QOD_SETTINGS__SERVICE_SPECIFICATION_ID", "11111111-2222-3333-4444-555555555555"
+ )
+ settings = Settings(_env_file=None) # type: ignore[call-arg]
+ assert settings.qod_settings.service_specification_id == UUID(
+ "11111111-2222-3333-4444-555555555555"
+ )
+ assert settings.qod_settings.uses_default_service_specification_id is False
+
def test_flat_top_level_fields(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PORT", "9000")
monkeypatch.setenv("DEBUG", "true")
@@ -66,6 +81,21 @@ class TestDefaults:
assert settings.port == 8080
assert settings.postgresql_settings.echo is False
assert settings.postgresql_settings.create_schema_on_startup is False
+ assert (
+ settings.qod_settings.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID
+ )
+ assert settings.qod_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 TestGetSettingsCaching:
diff --git a/tests/unit/test_nats_adapter.py b/tests/unit/test_nats_adapter.py
index b6355e8b2c7f45f0ad79573e29fc8428b5f8d5ad..c0caae7380c7038c0ada1c79cb3e71a4cd053f21 100644
--- a/tests/unit/test_nats_adapter.py
+++ b/tests/unit/test_nats_adapter.py
@@ -8,6 +8,7 @@ import pytest
from open_exposure_gateway.adapters.databus.nats_adapter import (
NatsMessagePublisher,
NatsOperationConsumer,
+ NatsOperationStatusConsumer,
)
from open_exposure_gateway.core.config import NatsSettings
@@ -195,3 +196,64 @@ async def test_handler_exception_is_caught() -> None:
handler.side_effect = RuntimeError("db down")
await consumer._handle_message(Msg(data=json.dumps(_valid_completed_payload()).encode()))
+
+
+def make_status_consumer() -> tuple[NatsOperationStatusConsumer, AsyncMock]:
+ handler = AsyncMock()
+ consumer = NatsOperationStatusConsumer(
+ client=AsyncMock(),
+ subject="operation.status",
+ handler=handler,
+ )
+ return consumer, handler
+
+
+def _valid_status_payload() -> dict[str, Any]:
+ return {
+ "schema_version": "1.0",
+ "operation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ "state": "accepted",
+ "correlation_id": "corr-1",
+ "emitted_at": "2026-07-03T12:00:00+00:00",
+ }
+
+
+async def test_status_invalid_json_is_ignored() -> None:
+ consumer, handler = make_status_consumer()
+
+ await consumer._handle_message(Msg(data=b"not json", subject="operation.status"))
+
+ handler.assert_not_awaited()
+
+
+async def test_status_schema_invalid_message_is_ignored() -> None:
+ consumer, handler = make_status_consumer()
+ payload = {"state": "accepted"}
+
+ await consumer._handle_message(
+ Msg(data=json.dumps(payload).encode(), subject="operation.status")
+ )
+
+ handler.assert_not_awaited()
+
+
+async def test_status_valid_message_is_handled() -> None:
+ consumer, handler = make_status_consumer()
+
+ await consumer._handle_message(
+ Msg(data=json.dumps(_valid_status_payload()).encode(), subject="operation.status")
+ )
+
+ handler.assert_awaited_once()
+ assert handler.await_args is not None
+ (event,) = handler.await_args.args
+ assert event.operation_id == "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+
+async def test_status_handler_exception_is_caught() -> None:
+ consumer, handler = make_status_consumer()
+ handler.side_effect = RuntimeError("db down")
+
+ await consumer._handle_message(
+ Msg(data=json.dumps(_valid_status_payload()).encode(), subject="operation.status")
+ )
diff --git a/tests/unit/test_qod_flows.py b/tests/unit/test_qod_flows.py
index b910a2bba046d2816f6644ca349cd55f85bd79df..7f3c51f281701668c72224215ad84c47a7170147 100644
--- a/tests/unit/test_qod_flows.py
+++ b/tests/unit/test_qod_flows.py
@@ -1,20 +1,39 @@
"""End-to-end flow tests for Quality on Demand.
-Same setup as test_eam_flows.py: real HTTP API against the in-memory FakeSRMClient.
+Same setup as test_eam_flows.py: real HTTP API against the in-memory fakes.
A failing test here means a real hole in the flow, not a broken test.
"""
from typing import Any
+from uuid import UUID, uuid4
from fastapi.testclient import TestClient
from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.router import (
BASE_PATH as QOD_BASE,
)
-from tests.unit.fakes import FakeSRMClient
+from open_exposure_gateway.domain.models import OperationStatus, QodSessionState
+from open_exposure_gateway.domain.quality_on_demand import (
+ SRMNetworkCapabilityActivateCommand,
+ SRMNetworkCapabilityDeactivateCommand,
+ Subject,
+)
+from tests.unit.fakes import (
+ FakeCallbackDeliveryRepository,
+ FakeDataBus,
+ FakeOperationRepository,
+ FakeQodCallbackDeliveryPort,
+ FakeQodSessionRepository,
+ FakeSRMClient,
+ completion_payload,
+ operation_status_payload,
+ wire_srm_worker,
+)
SESSION_BODY: dict[str, Any] = {
- "device": {"ipv4Address": {"publicAddress": "84.125.93.10"}},
+ "device": {
+ "ipv4Address": {"publicAddress": "84.125.93.10", "publicPort": 59765},
+ },
"applicationServer": {"ipv4Address": "192.168.0.1"},
"qosProfile": "QOS_E",
"duration": 3600,
@@ -22,32 +41,306 @@ SESSION_BODY: dict[str, Any] = {
class TestQodSessionFlow:
- def test_create_session_returns_201_and_reserves_downstream(
- self, api_client: TestClient, fake_srm: FakeSRMClient
- ) -> None:
+ def test_create_session_returns_201(self, api_client: TestClient) -> None:
response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY)
assert response.status_code == 201
body = response.json()
assert body["qosStatus"] == "REQUESTED"
assert body["qosProfile"] == "QOS_E"
- assert body["sessionId"] in fake_srm.qod_sessions
+ assert "sessionId" in body
+
+ 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)
+ assert response.status_code == 201
+
+ def test_srm_receives_a_valid_activate_command(
+ self, api_client: TestClient, fake_bus: FakeDataBus
+ ) -> None:
+ api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY)
+
+ activates = [p for s, p in fake_bus.published if s == Subject.TASK_ACTIVATE]
+ assert len(activates) == 1
+ command = SRMNetworkCapabilityActivateCommand.model_validate(activates[0])
+ 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.source == "nbi_camara"
+
+ def test_persists_operation_and_qod_session_rows(
+ self,
+ api_client: TestClient,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """Proves the DI wiring end-to-end: the router/service must reach the
+ real repositories, not just the constructor accepting them."""
+ response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY)
+ session_id = UUID(response.json()["sessionId"])
+
+ operations = list(operation_repo.rows.values())
+ assert len(operations) == 1
+ assert operations[0].status == OperationStatus.PENDING
+
+ stored_session = qod_session_repo.rows.get(session_id)
+ assert stored_session is not None
+ assert stored_session.state == QodSessionState.REQUESTED
+ assert stored_session.operation_id == operations[0].operation_id
+
+ async def test_completion_event_marks_session_available(
+ self,
+ api_client: TestClient,
+ live_qod: None,
+ fake_bus: FakeDataBus,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY)
+ session_id = UUID(response.json()["sessionId"])
+ operation_id = qod_session_repo.rows[session_id].operation_id
+
+ await fake_bus.publish(
+ str(Subject.OPERATION_COMPLETED),
+ completion_payload(
+ str(operation_id),
+ instances=[
+ {
+ "service_instance_id": str(uuid4()),
+ "zone_id": str(uuid4()),
+ "status": "completed",
+ "external_ref": "qod-session-nef-123",
+ }
+ ],
+ ),
+ )
+
+ updated = qod_session_repo.rows[session_id]
+ assert updated.state == QodSessionState.AVAILABLE
+ assert updated.external_ref == "qod-session-nef-123"
+
+ async def test_webhook_receives_qos_status_changed_on_completion_and_status_events(
+ self,
+ api_client: TestClient,
+ live_qod: None,
+ fake_bus: FakeDataBus,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_delivery_repo: FakeCallbackDeliveryRepository,
+ qod_callback_delivery_port: FakeQodCallbackDeliveryPort,
+ ) -> None:
+ body = {**SESSION_BODY, "webhook": {"notificationUrl": "https://client.example.com/cb"}}
+ response = api_client.post(f"{QOD_BASE}/sessions", json=body)
+ session_id = UUID(response.json()["sessionId"])
+ operation_id = qod_session_repo.rows[session_id].operation_id
+
+ await fake_bus.publish(
+ str(Subject.OPERATION_COMPLETED),
+ completion_payload(
+ str(operation_id),
+ instances=[
+ {
+ "service_instance_id": str(uuid4()),
+ "zone_id": str(uuid4()),
+ "status": "completed",
+ "external_ref": "qod-session-nef-123",
+ }
+ ],
+ ),
+ )
+
+ assert len(qod_callback_delivery_port.delivered) == 1
+ sink, first_event = qod_callback_delivery_port.delivered[0]
+ assert sink == "https://client.example.com/cb"
+ assert first_event.data.qosStatus == "AVAILABLE"
+ assert qod_session_repo.rows[session_id].state == QodSessionState.AVAILABLE
+
+ await fake_bus.publish(
+ str(Subject.OPERATION_STATUS),
+ operation_status_payload(str(operation_id)),
+ )
+
+ assert len(qod_callback_delivery_port.delivered) == 2
+ _, second_event = qod_callback_delivery_port.delivered[1]
+ assert second_event.data.qosStatus == "UNAVAILABLE"
+ assert second_event.data.statusInfo == "NETWORK_TERMINATED"
+ assert qod_session_repo.rows[session_id].state == QodSessionState.UNAVAILABLE
+ assert len(callback_delivery_repo.rows) == 2
def test_get_session_returns_created_session(
- self, api_client: TestClient, fake_srm: FakeSRMClient
+ self, api_client: TestClient, fake_bus: FakeDataBus, fake_srm: FakeSRMClient
) -> None:
+ wire_srm_worker(fake_bus, fake_srm)
+
session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"]
response = api_client.get(f"{QOD_BASE}/sessions/{session_id}")
+
assert response.status_code == 200
- assert response.json()["sessionId"] == session_id
+ body = response.json()
+ assert body["sessionId"] == session_id
+ assert body["qosStatus"] == "REQUESTED"
+ assert body["applicationServer"]["ipv4Address"] == "192.168.0.1"
- def test_delete_session_releases_the_downstream_reservation(
- self, api_client: TestClient, fake_srm: FakeSRMClient
+ def test_get_session_returns_404_when_unknown(self, api_client: TestClient) -> None:
+ response = api_client.get(f"{QOD_BASE}/sessions/{uuid4()}")
+ assert response.status_code == 404
+
+ def test_delete_session_succeeds_before_activation_confirmed(
+ self, api_client: TestClient, fake_bus: FakeDataBus
) -> None:
- """DELETE must actually tear the session down at SRM: a 204 with the
- reservation still alive downstream silently leaks QoS resources."""
session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"]
response = api_client.delete(f"{QOD_BASE}/sessions/{session_id}")
assert response.status_code == 204
- assert session_id not in fake_srm.qod_sessions
+ deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE]
+ assert len(deactivates) == 1
+ command = SRMNetworkCapabilityDeactivateCommand.model_validate(deactivates[0])
+ assert command.network_capability.service_instance_id == session_id
+
+ def test_get_session_returns_404_after_delete_requested(self, api_client: TestClient) -> None:
+ session_id = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"]
+ api_client.delete(f"{QOD_BASE}/sessions/{session_id}")
+
+ response = api_client.get(f"{QOD_BASE}/sessions/{session_id}")
+
+ assert response.status_code == 404
+
+ def test_delete_session_returns_404_when_unknown(self, api_client: TestClient) -> None:
+ response = api_client.delete(f"{QOD_BASE}/sessions/{uuid4()}")
+ assert response.status_code == 404
+
+ async def test_delete_session_publishes_deactivate_command_once_available(
+ self,
+ api_client: TestClient,
+ live_qod: None,
+ fake_bus: FakeDataBus,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """DELETE must actually tear the session down at SRM: a 204 with no deactivate
+ command published would silently leak QoS resources."""
+ response = api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY)
+ session_id = UUID(response.json()["sessionId"])
+ operation_id = qod_session_repo.rows[session_id].operation_id
+
+ await fake_bus.publish(
+ str(Subject.OPERATION_COMPLETED),
+ completion_payload(
+ str(operation_id),
+ instances=[
+ {
+ "service_instance_id": str(uuid4()),
+ "zone_id": str(uuid4()),
+ "status": "completed",
+ "external_ref": "qod-session-nef-123",
+ }
+ ],
+ ),
+ )
+
+ delete_response = api_client.delete(f"{QOD_BASE}/sessions/{session_id}")
+
+ assert delete_response.status_code == 204
+ deactivates = [p for s, p in fake_bus.published if s == Subject.TASK_DEACTIVATE]
+ assert len(deactivates) == 1
+ command = SRMNetworkCapabilityDeactivateCommand.model_validate(deactivates[0])
+ assert command.network_capability.service_instance_id == str(session_id)
+
+ async def test_deactivate_completion_drives_session_to_deleted(
+ self,
+ api_client: TestClient,
+ live_qod: None,
+ fake_bus: FakeDataBus,
+ qod_session_repo: FakeQodSessionRepository,
+ operation_repo: FakeOperationRepository,
+ ) -> None:
+ session_id = UUID(
+ api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"]
+ )
+ api_client.delete(f"{QOD_BASE}/sessions/{session_id}")
+ assert qod_session_repo.rows[session_id].state == QodSessionState.DELETION_REQUESTED
+
+ deactivate_operation_id = next(
+ op.operation_id
+ for op in operation_repo.rows.values()
+ if op.metadata == {"session_id": str(session_id)}
+ )
+
+ await fake_bus.publish(
+ str(Subject.OPERATION_COMPLETED),
+ completion_payload(str(deactivate_operation_id)),
+ )
+
+ assert qod_session_repo.rows[session_id].state == QodSessionState.DELETED
+
+ async def test_failed_deactivate_completion_drives_session_to_error(
+ self,
+ api_client: TestClient,
+ live_qod: None,
+ fake_bus: FakeDataBus,
+ qod_session_repo: FakeQodSessionRepository,
+ operation_repo: FakeOperationRepository,
+ ) -> None:
+ session_id = UUID(
+ api_client.post(f"{QOD_BASE}/sessions", json=SESSION_BODY).json()["sessionId"]
+ )
+ api_client.delete(f"{QOD_BASE}/sessions/{session_id}")
+
+ deactivate_operation_id = next(
+ op.operation_id
+ for op in operation_repo.rows.values()
+ if op.metadata == {"session_id": str(session_id)}
+ )
+
+ await fake_bus.publish(
+ str(Subject.OPERATION_COMPLETED),
+ completion_payload(
+ str(deactivate_operation_id),
+ status="failed",
+ instances=[],
+ error={"status": 500, "code": "INTERNAL", "message": "backend rejected"},
+ ),
+ )
+
+ assert qod_session_repo.rows[session_id].state == QodSessionState.ERROR
+
+
+class TestQosProfiles:
+ """QoS profile catalog is not implemented yet -- both routes exist but 501."""
+
+ def test_get_qos_profiles_returns_501(self, api_client: TestClient) -> None:
+ response = api_client.get(f"{QOD_BASE}/qos-profiles")
+ assert response.status_code == 501
+
+ def test_get_qos_profile_by_name_returns_501(self, api_client: TestClient) -> None:
+ response = api_client.get(f"{QOD_BASE}/qos-profiles/voice")
+ assert response.status_code == 501
+
+
+class TestQodSessionValidation:
+ """CAMARA qod-api.yaml constraints the request schema must enforce (400s)."""
+
+ def test_device_with_no_identifier_is_rejected(self, api_client: TestClient) -> None:
+ body = {**SESSION_BODY, "device": {}}
+ response = api_client.post(f"{QOD_BASE}/sessions", json=body)
+ assert response.status_code == 400
+
+ def test_application_server_with_no_address_is_rejected(self, api_client: TestClient) -> None:
+ body = {**SESSION_BODY, "applicationServer": {}}
+ response = api_client.post(f"{QOD_BASE}/sessions", json=body)
+ assert response.status_code == 400
+
+ def test_device_ipv4_public_address_alone_is_rejected(self, api_client: TestClient) -> None:
+ """Spec anyOf: publicAddress must be paired with privateAddress or publicPort."""
+ body = {**SESSION_BODY, "device": {"ipv4Address": {"publicAddress": "84.125.93.10"}}}
+ response = api_client.post(f"{QOD_BASE}/sessions", json=body)
+ assert response.status_code == 400
+
+ def test_invalid_qos_profile_name_is_rejected(self, api_client: TestClient) -> None:
+ body = {**SESSION_BODY, "qosProfile": "no spaces allowed"}
+ response = api_client.post(f"{QOD_BASE}/sessions", json=body)
+ assert response.status_code == 400
+
+ def test_devports_with_neither_ranges_nor_ports_is_rejected(
+ self, api_client: TestClient
+ ) -> None:
+ body = {**SESSION_BODY, "devicePorts": {}}
+ response = api_client.post(f"{QOD_BASE}/sessions", json=body)
+ assert response.status_code == 400
diff --git a/tests/unit/test_qod_service.py b/tests/unit/test_qod_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..29e0e3ee0d2ce96d540d0c45d91abcfd0c880736
--- /dev/null
+++ b/tests/unit/test_qod_service.py
@@ -0,0 +1,1708 @@
+from typing import Any
+from unittest.mock import AsyncMock
+from uuid import UUID, uuid4
+
+import pytest
+
+from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
+ ApplicationServer,
+ CreateSession,
+ Device,
+ PortRange,
+ PortsSpec,
+ Webhook,
+)
+from open_exposure_gateway.application.services.quality_on_demand_service import (
+ QualityOnDemandService,
+)
+from open_exposure_gateway.core.config import DEFAULT_QOD_SERVICE_SPECIFICATION_ID
+from open_exposure_gateway.core.exceptions import DownstreamServiceException, NotFoundException
+from open_exposure_gateway.domain.edge_application_management import SRMOperationCompleted
+from open_exposure_gateway.domain.models import (
+ CallbackDelivery,
+ CallbackRegistration,
+ Operation,
+ OperationStatus,
+ OperationType,
+ QodSession,
+ QodSessionState,
+)
+from open_exposure_gateway.domain.quality_on_demand import (
+ SRMNetworkCapability,
+ SRMNetworkCapabilityActivateCommand,
+ SRMNetworkCapabilityDeactivateCommand,
+ SRMOperationStatus,
+ Subject,
+)
+from tests.unit.fakes import (
+ FakeCallbackDeliveryRepository,
+ FakeCallbackRegistrationRepository,
+ FakeOperationRepository,
+ FakeQodCallbackDeliveryPort,
+ FakeQodSessionRepository,
+ completion_payload,
+ operation_status_payload,
+)
+
+
+def _make_request(
+ webhook: Webhook | None = None,
+ device_ports: PortsSpec | None = None,
+ application_server_ports: PortsSpec | None = None,
+) -> CreateSession:
+ return CreateSession(
+ device=Device(phoneNumber="123456789"),
+ applicationServer=ApplicationServer(ipv4Address="198.51.100.0/24"),
+ devicePorts=device_ports,
+ applicationServerPorts=application_server_ports,
+ qosProfile="voice",
+ duration=3600,
+ webhook=webhook,
+ )
+
+
+class TestCreateSession:
+ @pytest.fixture()
+ def operation_repo(self) -> FakeOperationRepository:
+ return FakeOperationRepository()
+
+ @pytest.fixture()
+ def qod_session_repo(self) -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+ @pytest.fixture()
+ def service(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> QualityOnDemandService:
+ return QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+
+ async def test_persists_pending_operation_row(
+ self, service: QualityOnDemandService, operation_repo: FakeOperationRepository
+ ) -> None:
+ await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+ operations = list(operation_repo.rows.values())
+ assert len(operations) == 1
+ operation = operations[0]
+ assert operation.status == OperationStatus.PENDING
+ assert operation.operation_type == OperationType.NETWORK_CAPABILITY
+ assert operation.subject == Subject.TASK_ACTIVATE
+ assert operation.tenant_id == "tenant-1"
+ assert operation.app_provider_id == "provider-1"
+ assert operation.metadata["qos_profile"] == "voice"
+
+ async def test_persists_requested_qod_session_row(
+ self, service: QualityOnDemandService, qod_session_repo: FakeQodSessionRepository
+ ) -> None:
+ result = await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+ stored = await qod_session_repo.get_by_id(result.sessionId)
+ assert stored is not None
+ assert stored.state == QodSessionState.REQUESTED
+ assert stored.qos_profile == "voice"
+ assert stored.duration_seconds == 3600
+
+ async def test_persists_ports_on_the_session_row(
+ self, service: QualityOnDemandService, qod_session_repo: FakeQodSessionRepository
+ ) -> None:
+ result = await service.create_session(
+ request=_make_request(
+ device_ports=PortsSpec(ports=[80, 443]),
+ application_server_ports=PortsSpec(
+ ranges=[PortRange(**{"from": 5000, "to": 5010})]
+ ),
+ ),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+ stored = await qod_session_repo.get_by_id(result.sessionId)
+ assert stored is not None
+ assert stored.device_ports == {"ranges": None, "ports": [80, 443]}
+ assert stored.application_server_ports is not None
+ assert stored.application_server_ports["ranges"][0]["to"] == 5010
+
+ async def test_sends_ports_to_srm_on_the_activate_command(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """ADR-0037: ports are canonical target.device.ports / target.application_server.ports,
+ not dropped before reaching SRM."""
+ publisher = AsyncMock()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+
+ await service.create_session(
+ request=_make_request(
+ device_ports=PortsSpec(ports=[80, 443]),
+ application_server_ports=PortsSpec(
+ ranges=[PortRange(**{"from": 5000, "to": 5010})]
+ ),
+ ),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ _, payload = publisher.publish.call_args.args
+ command = SRMNetworkCapabilityActivateCommand.model_validate(payload)
+ target = command.network_capability.target
+ assert target.device.ports is not None
+ assert target.device.ports.ports == [80, 443]
+ assert target.application_server.ports is not None
+ assert target.application_server.ports.ranges is not None
+ assert target.application_server.ports.ranges[0].to == 5010
+ # Wire shape uses "from"/"to" (canonical schema), not the Python-side "from_" alias.
+ assert (
+ payload["network_capability"]["target"]["application_server"]["ports"]["ranges"][0][
+ "from"
+ ]
+ == 5000
+ )
+
+ async def test_returns_session_info_with_requested_status(
+ self, service: QualityOnDemandService
+ ) -> None:
+ result = await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+ assert result.qosStatus == "REQUESTED"
+ assert result.qosProfile == "voice"
+ assert result.duration == 3600
+ assert result.expiresAt - result.startedAt == 3600
+
+ async def test_raises_when_repositories_unavailable(self) -> None:
+ service = QualityOnDemandService(srm_client=AsyncMock(), publisher=AsyncMock())
+ with pytest.raises(RuntimeError, match="Operation/QodSession repositories"):
+ await service.create_session(
+ request=_make_request(), tenant_id="t", app_provider_id="p"
+ )
+
+ async def test_raises_when_publisher_unavailable(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=None,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+ with pytest.raises(RuntimeError, match="DataBus publisher is not available"):
+ await service.create_session(
+ request=_make_request(), tenant_id="t", app_provider_id="p"
+ )
+
+ async def test_publish_failure_maps_to_downstream_exception(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ publisher = AsyncMock()
+ publisher.publish = AsyncMock(side_effect=RuntimeError("nats down"))
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+ with pytest.raises(DownstreamServiceException):
+ await service.create_session(
+ request=_make_request(), tenant_id="t", app_provider_id="p"
+ )
+
+ async def test_persists_callback_registration_when_webhook_present(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ )
+ result = await service.create_session(
+ request=_make_request(webhook=Webhook(notificationUrl="https://client.example.com/cb")),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ registrations = list(callback_registration_repo.rows.values())
+ assert len(registrations) == 1
+ registration = registrations[0]
+ assert registration.sink == "https://client.example.com/cb"
+ assert registration.api_family == "quality-on-demand"
+ assert registration.event_types == ["org.camaraproject.qod.v0.qos-status-changed"]
+ stored_session = await qod_session_repo.get_by_id(result.sessionId)
+ assert stored_session is not None
+ assert registration.operation_id == stored_session.operation_id
+
+ async def test_no_callback_registration_when_webhook_absent(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ )
+ await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+ assert callback_registration_repo.rows == {}
+
+ async def test_sink_credential_ref_never_stores_raw_token(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ )
+ raw_token = "a" * 32
+ await service.create_session(
+ request=_make_request(
+ webhook=Webhook(
+ notificationUrl="https://client.example.com/cb",
+ notificationAuthToken=raw_token,
+ )
+ ),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ registration = next(iter(callback_registration_repo.rows.values()))
+ assert registration.sink_credential_ref is not None
+ assert raw_token not in registration.sink_credential_ref
+
+ async def test_raises_when_webhook_present_but_callback_registration_repo_unavailable(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+ with pytest.raises(RuntimeError, match="CallbackRegistrationRepository"):
+ await service.create_session(
+ request=_make_request(
+ webhook=Webhook(notificationUrl="https://client.example.com/cb")
+ ),
+ tenant_id="t",
+ app_provider_id="p",
+ )
+
+
+class TestHandleCompleted:
+ @pytest.fixture()
+ def operation_repo(self) -> FakeOperationRepository:
+ return FakeOperationRepository()
+
+ @pytest.fixture()
+ def qod_session_repo(self) -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+ @pytest.fixture()
+ def service(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> QualityOnDemandService:
+ return QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+
+ async def _seed(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ operation_type: OperationType = OperationType.NETWORK_CAPABILITY,
+ ) -> tuple[Operation, QodSession]:
+ operation = await operation_repo.save(
+ Operation(
+ operation_id=uuid4(),
+ correlation_id="corr-1",
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ operation_type=operation_type,
+ status=OperationStatus.PENDING,
+ subject=Subject.TASK_ACTIVATE,
+ )
+ )
+ qod_session = await qod_session_repo.save(
+ QodSession(
+ session_id=uuid4(),
+ operation_id=operation.operation_id,
+ service_specification_id=uuid4(),
+ qos_profile="voice",
+ duration_seconds=3600,
+ state=QodSessionState.REQUESTED,
+ )
+ )
+ return operation, qod_session
+
+ async def test_completed_event_marks_session_available(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ operation, qod_session = await self._seed(operation_repo, qod_session_repo)
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ instances=[
+ {
+ "service_instance_id": str(uuid4()),
+ "zone_id": str(uuid4()),
+ "status": "completed",
+ "external_ref": "qod-session-123",
+ }
+ ],
+ )
+ )
+
+ await service.handle_completed(event)
+
+ updated_operation = await operation_repo.get_by_id(operation.operation_id)
+ assert updated_operation is not None
+ assert updated_operation.status == OperationStatus.COMPLETED
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.AVAILABLE
+ assert updated_session.external_ref == "qod-session-123"
+
+ async def test_failed_event_marks_session_unavailable(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ operation, qod_session = await self._seed(operation_repo, qod_session_repo)
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ status="failed",
+ instances=[],
+ error={"status": 500, "code": "INTERNAL", "message": "backend rejected"},
+ )
+ )
+
+ await service.handle_completed(event)
+
+ updated_operation = await operation_repo.get_by_id(operation.operation_id)
+ assert updated_operation is not None
+ assert updated_operation.status == OperationStatus.FAILED
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.UNAVAILABLE
+
+ async def test_ignores_completion_for_a_different_domains_operation(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """event.srm.operation.completed is shared across domains (deploy/terminate
+ and network-capability activate/deactivate all land on it) -- a DEPLOY
+ operation must be left untouched, not mistaken for one of QoD's own."""
+ operation, qod_session = await self._seed(
+ operation_repo, qod_session_repo, operation_type=OperationType.DEPLOY
+ )
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(str(operation.operation_id))
+ )
+
+ await service.handle_completed(event)
+
+ unchanged_operation = await operation_repo.get_by_id(operation.operation_id)
+ assert unchanged_operation is not None
+ assert unchanged_operation.status == OperationStatus.PENDING
+
+ unchanged_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert unchanged_session is not None
+ assert unchanged_session.state == QodSessionState.REQUESTED
+
+ async def test_unknown_operation_id_is_a_noop(self, service: QualityOnDemandService) -> None:
+ event = SRMOperationCompleted.model_validate(completion_payload(str(uuid4())))
+
+ await service.handle_completed(event)
+
+ async def _seed_deactivate(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> tuple[Operation, QodSession]:
+ qod_session = await qod_session_repo.save(
+ QodSession(
+ session_id=uuid4(),
+ operation_id=uuid4(),
+ service_specification_id=uuid4(),
+ qos_profile="voice",
+ duration_seconds=3600,
+ state=QodSessionState.DELETION_REQUESTED,
+ external_ref="qod-session-nef-123",
+ )
+ )
+ operation = await operation_repo.save(
+ Operation(
+ operation_id=uuid4(),
+ correlation_id="corr-1",
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ operation_type=OperationType.NETWORK_CAPABILITY_DEACTIVATE,
+ status=OperationStatus.PENDING,
+ subject=Subject.TASK_DEACTIVATE,
+ metadata={"session_id": str(qod_session.session_id)},
+ )
+ )
+ return operation, qod_session
+
+ async def test_completed_deactivate_marks_session_deleted(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ operation, qod_session = await self._seed_deactivate(operation_repo, qod_session_repo)
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(str(operation.operation_id))
+ )
+
+ await service.handle_completed(event)
+
+ updated_operation = await operation_repo.get_by_id(operation.operation_id)
+ assert updated_operation is not None
+ assert updated_operation.status == OperationStatus.COMPLETED
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.DELETED
+
+ async def test_failed_deactivate_marks_session_error(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ operation, qod_session = await self._seed_deactivate(operation_repo, qod_session_repo)
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ status="failed",
+ instances=[],
+ error={"status": 500, "code": "INTERNAL", "message": "backend rejected"},
+ )
+ )
+
+ await service.handle_completed(event)
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.ERROR
+
+ async def test_raises_when_repositories_unavailable(self) -> None:
+ service = QualityOnDemandService(srm_client=AsyncMock())
+ event = SRMOperationCompleted.model_validate(completion_payload(str(uuid4())))
+ with pytest.raises(RuntimeError, match="Operation/QodSession repositories"):
+ await service.handle_completed(event)
+
+ async def test_delivers_qos_status_changed_on_success(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ callback_delivery_repo = FakeCallbackDeliveryRepository()
+ callback_delivery_port = FakeQodCallbackDeliveryPort()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ operation, qod_session = await self._seed(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ instances=[
+ {
+ "service_instance_id": str(uuid4()),
+ "zone_id": str(uuid4()),
+ "status": "completed",
+ "external_ref": "qod-session-123",
+ }
+ ],
+ )
+ )
+
+ await service.handle_completed(event)
+
+ assert len(callback_delivery_port.delivered) == 1
+ sink, cloud_event = callback_delivery_port.delivered[0]
+ assert sink == "https://client.example.com/cb"
+ assert cloud_event.data.sessionId == qod_session.session_id
+ assert cloud_event.data.qosStatus == "AVAILABLE"
+ assert cloud_event.data.statusInfo is None
+
+ deliveries = list(callback_delivery_repo.rows.values())
+ assert len(deliveries) == 1
+ assert deliveries[0].state == "delivered"
+ assert deliveries[0].attempt == 1
+
+ async def test_delivers_qos_status_changed_unavailable_on_failure(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ callback_delivery_repo = FakeCallbackDeliveryRepository()
+ callback_delivery_port = FakeQodCallbackDeliveryPort()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ operation, _ = await self._seed(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ status="failed",
+ instances=[],
+ error={"status": 500, "code": "INTERNAL", "message": "backend rejected"},
+ )
+ )
+
+ await service.handle_completed(event)
+
+ _, cloud_event = callback_delivery_port.delivered[0]
+ assert cloud_event.data.qosStatus == "UNAVAILABLE"
+ assert cloud_event.data.statusInfo == "NETWORK_TERMINATED"
+
+ async def test_redelivery_of_same_event_does_not_duplicate_notification(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """The databus can redeliver event.srm.operation.completed at least
+ once (e.g. handler crashes after acting but before acking). Replaying
+ the exact same event must not re-notify a session that is already
+ UNAVAILABLE -- the CAMARA QoD spec forbids sending a qos-status-changed
+ event when qosStatus was already UNAVAILABLE."""
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ callback_delivery_repo = FakeCallbackDeliveryRepository()
+ callback_delivery_port = FakeQodCallbackDeliveryPort()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ operation, qod_session = await self._seed(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ status="failed",
+ instances=[],
+ error={"status": 500, "code": "INTERNAL", "message": "backend rejected"},
+ )
+ )
+
+ await service.handle_completed(event)
+ await service.handle_completed(event) # simulated redelivery of the same message
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.UNAVAILABLE
+
+ assert len(callback_delivery_port.delivered) == 1
+ deliveries = list(callback_delivery_repo.rows.values())
+ assert len(deliveries) == 1
+ assert deliveries[0].attempt == 1
+
+ async def test_failed_delivery_still_persists_session_update(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ callback_delivery_repo = FakeCallbackDeliveryRepository()
+ callback_delivery_port = AsyncMock()
+ callback_delivery_port.deliver = AsyncMock(side_effect=RuntimeError("sink unreachable"))
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ operation, qod_session = await self._seed(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(
+ str(operation.operation_id),
+ instances=[
+ {
+ "service_instance_id": str(uuid4()),
+ "zone_id": str(uuid4()),
+ "status": "completed",
+ "external_ref": "qod-session-123",
+ }
+ ],
+ )
+ )
+
+ await service.handle_completed(event)
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.AVAILABLE
+
+ deliveries = list(callback_delivery_repo.rows.values())
+ assert len(deliveries) == 1
+ assert deliveries[0].state == "failed"
+ assert deliveries[0].last_error is not None
+
+ async def test_no_delivery_when_no_registration_exists(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """service fixture has no callback repos/port wired at all -- handle_completed
+ must not error just because nobody registered a webhook."""
+ operation, _ = await self._seed(operation_repo, qod_session_repo)
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(str(operation.operation_id))
+ )
+
+ await service.handle_completed(event)
+
+ async def test_raises_when_registration_exists_but_delivery_port_unavailable(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ )
+ operation, _ = await self._seed(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ event = SRMOperationCompleted.model_validate(
+ completion_payload(str(operation.operation_id))
+ )
+ with pytest.raises(RuntimeError, match="CallbackDeliveryPort"):
+ await service.handle_completed(event)
+
+
+class TestHandleStatusChanged:
+ @pytest.fixture()
+ def operation_repo(self) -> FakeOperationRepository:
+ return FakeOperationRepository()
+
+ @pytest.fixture()
+ def qod_session_repo(self) -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+ @pytest.fixture()
+ def callback_registration_repo(self) -> FakeCallbackRegistrationRepository:
+ return FakeCallbackRegistrationRepository()
+
+ @pytest.fixture()
+ def callback_delivery_repo(self) -> FakeCallbackDeliveryRepository:
+ return FakeCallbackDeliveryRepository()
+
+ @pytest.fixture()
+ def callback_delivery_port(self) -> FakeQodCallbackDeliveryPort:
+ return FakeQodCallbackDeliveryPort()
+
+ @pytest.fixture()
+ def service(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_repo: FakeCallbackDeliveryRepository,
+ callback_delivery_port: FakeQodCallbackDeliveryPort,
+ ) -> QualityOnDemandService:
+ return QualityOnDemandService(
+ srm_client=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+
+ async def _seed_available(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ operation_type: OperationType = OperationType.NETWORK_CAPABILITY,
+ ) -> tuple[Operation, QodSession]:
+ operation = await operation_repo.save(
+ Operation(
+ operation_id=uuid4(),
+ correlation_id="corr-1",
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ operation_type=operation_type,
+ status=OperationStatus.COMPLETED,
+ subject=Subject.TASK_ACTIVATE,
+ )
+ )
+ qod_session = await qod_session_repo.save(
+ QodSession(
+ session_id=uuid4(),
+ operation_id=operation.operation_id,
+ service_specification_id=uuid4(),
+ qos_profile="voice",
+ duration_seconds=3600,
+ state=QodSessionState.AVAILABLE,
+ external_ref="qod-session-123",
+ )
+ )
+ return operation, qod_session
+
+ @pytest.mark.parametrize(
+ ("metadata", "expected_status_info"),
+ [
+ (
+ {"qos_status": "UNAVAILABLE", "qos_status_info": "duration_expired"},
+ "DURATION_EXPIRED",
+ ),
+ (
+ {"qos_status": "UNAVAILABLE", "qos_status_info": "network_terminated"},
+ "NETWORK_TERMINATED",
+ ),
+ ({"qos_status": "UNAVAILABLE"}, "NETWORK_TERMINATED"),
+ (
+ {"qos_status": "UNAVAILABLE", "qos_status_info": "something_new"},
+ "NETWORK_TERMINATED",
+ ),
+ ],
+ ids=["duration_expired", "network_terminated", "absent", "unknown"],
+ )
+ async def test_maps_qos_status_info_to_camara_status_info(
+ self,
+ metadata: dict[str, Any],
+ expected_status_info: str,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_port: FakeQodCallbackDeliveryPort,
+ ) -> None:
+ operation, qod_session = await self._seed_available(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+
+ await service.handle_status_changed(
+ SRMOperationStatus.model_validate(
+ operation_status_payload(str(operation.operation_id), metadata=metadata)
+ )
+ )
+
+ assert len(callback_delivery_port.delivered) == 1
+ _, cloud_event = callback_delivery_port.delivered[0]
+ assert cloud_event.data.qosStatus == "UNAVAILABLE"
+ assert cloud_event.data.statusInfo == expected_status_info
+
+ async def test_available_carries_no_status_info(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_port: FakeQodCallbackDeliveryPort,
+ ) -> None:
+ """CAMARA: statusInfo is only applicable when qosStatus is UNAVAILABLE."""
+ operation, qod_session = await self._seed_available(operation_repo, qod_session_repo)
+ await qod_session_repo.save(
+ qod_session.model_copy(update={"state": QodSessionState.UNAVAILABLE})
+ )
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+
+ await service.handle_status_changed(
+ SRMOperationStatus.model_validate(
+ operation_status_payload(
+ str(operation.operation_id),
+ metadata={"qos_status": "AVAILABLE", "qos_status_info": "duration_expired"},
+ )
+ )
+ )
+
+ assert len(callback_delivery_port.delivered) == 1
+ _, cloud_event = callback_delivery_port.delivered[0]
+ assert cloud_event.data.qosStatus == "AVAILABLE"
+ assert cloud_event.data.statusInfo is None
+
+ async def test_updates_session_and_delivers_second_event_on_network_drop(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_repo: FakeCallbackDeliveryRepository,
+ callback_delivery_port: FakeQodCallbackDeliveryPort,
+ ) -> None:
+ operation, qod_session = await self._seed_available(operation_repo, qod_session_repo)
+ registration = await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ # A prior delivery already happened (the AVAILABLE notification from
+ # handle_completed) -- this event's delivery must be attempt 2, not 1.
+ await callback_delivery_repo.save(
+ CallbackDelivery(
+ id=uuid4(),
+ callback_registration_id=registration.id,
+ operation_id=operation.operation_id,
+ attempt=1,
+ state="delivered",
+ )
+ )
+ event = SRMOperationStatus.model_validate(
+ operation_status_payload(str(operation.operation_id))
+ )
+
+ await service.handle_status_changed(event)
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.UNAVAILABLE
+
+ assert len(callback_delivery_port.delivered) == 1
+ _, cloud_event = callback_delivery_port.delivered[0]
+ assert cloud_event.data.sessionId == qod_session.session_id
+ assert cloud_event.data.qosStatus == "UNAVAILABLE"
+ assert cloud_event.data.statusInfo == "NETWORK_TERMINATED"
+
+ deliveries = list(callback_delivery_repo.rows.values())
+ assert len(deliveries) == 2
+ second_delivery = next(d for d in deliveries if d.attempt == 2)
+ assert second_delivery.state == "delivered"
+
+ async def test_redelivery_of_same_event_does_not_duplicate_notification(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ callback_registration_repo: FakeCallbackRegistrationRepository,
+ callback_delivery_repo: FakeCallbackDeliveryRepository,
+ callback_delivery_port: FakeQodCallbackDeliveryPort,
+ ) -> None:
+ """event.srm.operation.status can be redelivered at least once (ADR-0036
+ notes redelivery/clock-skew can cause duplicate processing). Replaying
+ the same UNAVAILABLE status a second time must not send a second
+ qos-status-changed notification -- the session is already UNAVAILABLE."""
+ operation, qod_session = await self._seed_available(operation_repo, qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=operation.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+ event = SRMOperationStatus.model_validate(
+ operation_status_payload(str(operation.operation_id))
+ )
+
+ await service.handle_status_changed(event)
+ await service.handle_status_changed(event) # simulated redelivery of the same message
+
+ updated_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert updated_session is not None
+ assert updated_session.state == QodSessionState.UNAVAILABLE
+
+ assert len(callback_delivery_port.delivered) == 1
+ deliveries = list(callback_delivery_repo.rows.values())
+ assert len(deliveries) == 1
+
+ async def test_ignores_status_for_a_different_domains_operation(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ operation, qod_session = await self._seed_available(
+ operation_repo, qod_session_repo, operation_type=OperationType.DEPLOY
+ )
+ event = SRMOperationStatus.model_validate(
+ operation_status_payload(str(operation.operation_id))
+ )
+
+ await service.handle_status_changed(event)
+
+ unchanged_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert unchanged_session is not None
+ assert unchanged_session.state == QodSessionState.AVAILABLE
+
+ async def test_ignores_unrecognized_metadata(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ operation, qod_session = await self._seed_available(operation_repo, qod_session_repo)
+ event = SRMOperationStatus.model_validate(
+ operation_status_payload(str(operation.operation_id), metadata={"foo": "bar"})
+ )
+
+ await service.handle_status_changed(event)
+
+ unchanged_session = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert unchanged_session is not None
+ assert unchanged_session.state == QodSessionState.AVAILABLE
+
+ async def test_noop_when_no_qod_session_matches_operation(
+ self,
+ service: QualityOnDemandService,
+ operation_repo: FakeOperationRepository,
+ ) -> None:
+ operation = await operation_repo.save(
+ Operation(
+ operation_id=uuid4(),
+ correlation_id="corr-1",
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ operation_type=OperationType.NETWORK_CAPABILITY,
+ status=OperationStatus.COMPLETED,
+ subject=Subject.TASK_ACTIVATE,
+ )
+ )
+ event = SRMOperationStatus.model_validate(
+ operation_status_payload(str(operation.operation_id))
+ )
+
+ await service.handle_status_changed(event)
+
+ async def test_unknown_operation_id_is_a_noop(self, service: QualityOnDemandService) -> None:
+ event = SRMOperationStatus.model_validate(operation_status_payload(str(uuid4())))
+
+ await service.handle_status_changed(event)
+
+ async def test_raises_when_repositories_unavailable(self) -> None:
+ service = QualityOnDemandService(srm_client=AsyncMock())
+ event = SRMOperationStatus.model_validate(operation_status_payload(str(uuid4())))
+ with pytest.raises(RuntimeError, match="Operation/QodSession repositories"):
+ await service.handle_status_changed(event)
+
+
+class TestServiceSpecificationId:
+ @pytest.fixture()
+ def operation_repo(self) -> FakeOperationRepository:
+ return FakeOperationRepository()
+
+ @pytest.fixture()
+ def qod_session_repo(self) -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+ def _service(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ service_specification_id: UUID | None = None,
+ ) -> QualityOnDemandService:
+ kwargs: dict[str, Any] = {}
+ if service_specification_id is not None:
+ kwargs["service_specification_id"] = service_specification_id
+ return QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ **kwargs,
+ )
+
+ async def test_configured_id_is_sent_on_the_activate_command(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ configured = uuid4()
+ publisher = AsyncMock()
+ service = self._service(operation_repo, qod_session_repo, publisher, configured)
+
+ await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+
+ _, payload = publisher.publish.call_args.args
+ command = SRMNetworkCapabilityActivateCommand.model_validate(payload)
+ assert command.service_specification_id == str(configured)
+
+ async def test_configured_id_is_persisted_on_the_session_row(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ configured = uuid4()
+ service = self._service(operation_repo, qod_session_repo, AsyncMock(), configured)
+
+ result = await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+
+ stored = await qod_session_repo.get_by_id(result.sessionId)
+ assert stored is not None
+ assert stored.service_specification_id == configured
+
+ async def test_every_session_carries_the_same_id(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """The regression guard: a per-request id is what ADR-0035 exists to remove."""
+ configured = uuid4()
+ service = self._service(operation_repo, qod_session_repo, AsyncMock(), configured)
+
+ for _ in range(3):
+ await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+
+ stored_ids = {row.service_specification_id for row in qod_session_repo.rows.values()}
+ assert stored_ids == {configured}
+
+ async def test_falls_back_to_the_well_known_id_when_unconfigured(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ """A deployment that sets nothing still sends an id SRM's bootstrap can seed."""
+ service = self._service(operation_repo, qod_session_repo, AsyncMock())
+
+ result = await service.create_session(
+ request=_make_request(), tenant_id="tenant-1", app_provider_id="provider-1"
+ )
+
+ stored = await qod_session_repo.get_by_id(result.sessionId)
+ assert stored is not None
+ assert stored.service_specification_id == DEFAULT_QOD_SERVICE_SPECIFICATION_ID
+
+
+class TestSessionIdMinting:
+ async def test_each_request_gets_distinct_session_and_operation_ids(self) -> None:
+ operation_repo = FakeOperationRepository()
+ qod_session_repo = FakeQodSessionRepository()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=AsyncMock(),
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+ first = await service.create_session(
+ request=_make_request(), tenant_id="t", app_provider_id="p"
+ )
+ second = await service.create_session(
+ request=_make_request(), tenant_id="t", app_provider_id="p"
+ )
+ assert first.sessionId != second.sessionId
+ assert len(operation_repo.rows) == 2
+ assert len({str(uuid) for uuid in operation_repo.rows}) == 2
+
+
+class TestDeleteSession:
+ @pytest.fixture()
+ def operation_repo(self) -> FakeOperationRepository:
+ return FakeOperationRepository()
+
+ @pytest.fixture()
+ def qod_session_repo(self) -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+ @pytest.fixture()
+ def publisher(self) -> AsyncMock:
+ return AsyncMock()
+
+ @pytest.fixture()
+ def service(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ ) -> QualityOnDemandService:
+ return QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ )
+
+ async def _seed_available_session(
+ self, qod_session_repo: FakeQodSessionRepository
+ ) -> QodSession:
+ return await qod_session_repo.save(
+ QodSession(
+ session_id=uuid4(),
+ operation_id=uuid4(),
+ service_specification_id=uuid4(),
+ qos_profile="voice",
+ duration_seconds=3600,
+ state=QodSessionState.AVAILABLE,
+ external_ref="qod-session-nef-123",
+ )
+ )
+
+ async def test_publishes_deactivate_command_keyed_on_service_instance_id(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ ) -> None:
+ qod_session = await self._seed_available_session(qod_session_repo)
+
+ await service.delete_session(
+ session_id=str(qod_session.session_id),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ publisher.publish.assert_awaited_once()
+ subject, payload = publisher.publish.call_args.args
+ assert subject == Subject.TASK_DEACTIVATE
+ command = SRMNetworkCapabilityDeactivateCommand.model_validate(payload)
+ assert command.network_capability.service_instance_id == str(qod_session.session_id)
+ assert command.network_capability.external_ref is None
+ assert command.app_provider_id == "provider-1"
+
+ async def test_moves_session_to_deletion_requested(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> None:
+ qod_session = await self._seed_available_session(qod_session_repo)
+
+ await service.delete_session(
+ session_id=str(qod_session.session_id),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ stored = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert stored is not None
+ assert stored.state == QodSessionState.DELETION_REQUESTED
+
+ async def test_persists_pending_deactivate_operation_row(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ operation_repo: FakeOperationRepository,
+ ) -> None:
+ qod_session = await self._seed_available_session(qod_session_repo)
+
+ await service.delete_session(
+ session_id=str(qod_session.session_id),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ operations = list(operation_repo.rows.values())
+ assert len(operations) == 1
+ assert operations[0].operation_type == OperationType.NETWORK_CAPABILITY_DEACTIVATE
+ assert operations[0].status == OperationStatus.PENDING
+ assert operations[0].subject == Subject.TASK_DEACTIVATE
+
+ async def test_raises_not_found_when_session_unknown(
+ self, service: QualityOnDemandService
+ ) -> None:
+ with pytest.raises(NotFoundException):
+ await service.delete_session(
+ session_id=str(uuid4()), tenant_id="t", app_provider_id="p"
+ )
+
+ async def test_succeeds_when_external_ref_not_yet_confirmed(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ ) -> None:
+ qod_session = await qod_session_repo.save(
+ QodSession(
+ session_id=uuid4(),
+ operation_id=uuid4(),
+ service_specification_id=uuid4(),
+ qos_profile="voice",
+ duration_seconds=3600,
+ state=QodSessionState.REQUESTED,
+ )
+ )
+
+ await service.delete_session(
+ session_id=str(qod_session.session_id), tenant_id="t", app_provider_id="p"
+ )
+
+ subject, payload = publisher.publish.call_args.args
+ assert subject == Subject.TASK_DEACTIVATE
+ command = SRMNetworkCapabilityDeactivateCommand.model_validate(payload)
+ assert command.network_capability.service_instance_id == str(qod_session.session_id)
+ stored = await qod_session_repo.get_by_id(qod_session.session_id)
+ assert stored is not None
+ assert stored.state == QodSessionState.DELETION_REQUESTED
+
+ @pytest.mark.parametrize(
+ "state",
+ [QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR],
+ )
+ async def test_repeat_delete_neither_republishes_nor_renotifies(
+ self,
+ state: QodSessionState,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ ) -> None:
+ callback_delivery_port = FakeQodCallbackDeliveryPort()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=FakeCallbackRegistrationRepository(),
+ callback_delivery_repo=FakeCallbackDeliveryRepository(),
+ callback_delivery_port=callback_delivery_port,
+ )
+ qod_session = await self._seed_available_session(qod_session_repo)
+ await qod_session_repo.save(qod_session.model_copy(update={"state": state}))
+
+ with pytest.raises(NotFoundException):
+ await service.delete_session(
+ session_id=str(qod_session.session_id), tenant_id="t", app_provider_id="p"
+ )
+
+ publisher.publish.assert_not_awaited()
+ assert operation_repo.rows == {}
+ assert callback_delivery_port.delivered == []
+
+ async def test_raises_when_repositories_unavailable(self) -> None:
+ service = QualityOnDemandService(srm_client=AsyncMock(), publisher=AsyncMock())
+ with pytest.raises(RuntimeError, match="Operation/QodSession repositories"):
+ await service.delete_session(
+ session_id=str(uuid4()), tenant_id="t", app_provider_id="p"
+ )
+
+ async def test_delivers_delete_requested_notification_when_session_available(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ callback_delivery_repo = FakeCallbackDeliveryRepository()
+ callback_delivery_port = FakeQodCallbackDeliveryPort()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ qod_session = await self._seed_available_session(qod_session_repo)
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=qod_session.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+
+ await service.delete_session(
+ session_id=str(qod_session.session_id),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ assert len(callback_delivery_port.delivered) == 1
+ sink, cloud_event = callback_delivery_port.delivered[0]
+ assert sink == "https://client.example.com/cb"
+ assert cloud_event.data.sessionId == qod_session.session_id
+ assert cloud_event.data.qosStatus == "UNAVAILABLE"
+ assert cloud_event.data.statusInfo == "DELETE_REQUESTED"
+
+ async def test_no_notification_when_session_already_unavailable(
+ self,
+ operation_repo: FakeOperationRepository,
+ qod_session_repo: FakeQodSessionRepository,
+ publisher: AsyncMock,
+ ) -> None:
+ callback_registration_repo = FakeCallbackRegistrationRepository()
+ callback_delivery_repo = FakeCallbackDeliveryRepository()
+ callback_delivery_port = FakeQodCallbackDeliveryPort()
+ service = QualityOnDemandService(
+ srm_client=AsyncMock(),
+ publisher=publisher,
+ operation_repo=operation_repo,
+ qod_session_repo=qod_session_repo,
+ callback_registration_repo=callback_registration_repo,
+ callback_delivery_repo=callback_delivery_repo,
+ callback_delivery_port=callback_delivery_port,
+ )
+ qod_session = await qod_session_repo.save(
+ QodSession(
+ session_id=uuid4(),
+ operation_id=uuid4(),
+ service_specification_id=uuid4(),
+ qos_profile="voice",
+ duration_seconds=3600,
+ state=QodSessionState.UNAVAILABLE,
+ external_ref="qod-session-nef-123",
+ )
+ )
+ await callback_registration_repo.save(
+ CallbackRegistration(
+ id=uuid4(),
+ operation_id=qod_session.operation_id,
+ tenant_id="tenant-1",
+ api_family="quality-on-demand",
+ sink="https://client.example.com/cb",
+ event_types=["org.camaraproject.qod.v0.qos-status-changed"],
+ )
+ )
+
+ await service.delete_session(
+ session_id=str(qod_session.session_id),
+ tenant_id="tenant-1",
+ app_provider_id="provider-1",
+ )
+
+ assert callback_delivery_port.delivered == []
+
+
+class TestGetSession:
+ @pytest.fixture()
+ def qod_session_repo(self) -> FakeQodSessionRepository:
+ return FakeQodSessionRepository()
+
+ @pytest.fixture()
+ def srm_client(self) -> AsyncMock:
+ return AsyncMock()
+
+ @pytest.fixture()
+ def service(
+ self,
+ srm_client: AsyncMock,
+ qod_session_repo: FakeQodSessionRepository,
+ ) -> QualityOnDemandService:
+ return QualityOnDemandService(srm_client=srm_client, qod_session_repo=qod_session_repo)
+
+ def _capability(
+ self,
+ service_instance_id: UUID,
+ phone_number: str = "123456789",
+ application_server_ipv4: str = "192.168.0.1",
+ ) -> SRMNetworkCapability:
+ return SRMNetworkCapability.model_validate(
+ {
+ "service_instance_id": str(service_instance_id),
+ "capability_type": "qod_session",
+ "state": "active",
+ "app_provider_id": "provider-1",
+ "external_ref": "qod-session-nef-123",
+ "parameters_snapshot": {
+ "schema_version": "srm.params/v1",
+ "target": {
+ "device": {"phone_number": phone_number},
+ "application_server": {"ipv4": application_server_ipv4},
+ },
+ "parameters": {"profile_ref": "voice", "duration_seconds": 3600},
+ "source_spec": {
+ "family": "camara",
+ "api": "quality-on-demand",
+ "version": "0.10.1",
+ },
+ },
+ }
+ )
+
+ async def _seed_session(
+ self, qod_session_repo: FakeQodSessionRepository, **overrides: Any
+ ) -> QodSession:
+ fields: dict[str, Any] = {
+ "session_id": uuid4(),
+ "operation_id": uuid4(),
+ "service_specification_id": uuid4(),
+ "qos_profile": "voice",
+ "duration_seconds": 3600,
+ "state": QodSessionState.AVAILABLE,
+ "external_ref": "qod-session-nef-123",
+ }
+ fields.update(overrides)
+ return await qod_session_repo.save(QodSession(**fields))
+
+ async def test_raises_not_found_when_session_unknown(
+ self, service: QualityOnDemandService, srm_client: AsyncMock
+ ) -> None:
+ with pytest.raises(NotFoundException):
+ await service.get_session(session_id=str(uuid4()))
+ srm_client.get_network_capability.assert_not_awaited()
+
+ async def test_raises_when_repository_unavailable(self) -> None:
+ service = QualityOnDemandService(srm_client=AsyncMock())
+ with pytest.raises(RuntimeError, match="QodSession repository"):
+ await service.get_session(session_id=str(uuid4()))
+
+ @pytest.mark.parametrize(
+ "state",
+ [QodSessionState.DELETION_REQUESTED, QodSessionState.DELETED, QodSessionState.ERROR],
+ )
+ async def test_raises_not_found_for_terminal_states(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ srm_client: AsyncMock,
+ state: QodSessionState,
+ ) -> None:
+ qod_session = await self._seed_session(qod_session_repo, state=state)
+
+ with pytest.raises(NotFoundException):
+ await service.get_session(session_id=str(qod_session.session_id))
+ srm_client.get_network_capability.assert_not_awaited()
+
+ async def test_queries_srm_keyed_on_the_session_id(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ srm_client: AsyncMock,
+ ) -> None:
+ qod_session = await self._seed_session(qod_session_repo)
+ srm_client.get_network_capability.return_value = self._capability(qod_session.session_id)
+
+ await service.get_session(session_id=str(qod_session.session_id), x_correlator="corr-1")
+
+ srm_client.get_network_capability.assert_awaited_once_with(
+ service_instance_id=str(qod_session.session_id), x_correlator="corr-1"
+ )
+
+ async def test_returns_session_info_combining_local_status_and_srm_target(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ srm_client: AsyncMock,
+ ) -> None:
+ qod_session = await self._seed_session(qod_session_repo, state=QodSessionState.AVAILABLE)
+ srm_client.get_network_capability.return_value = self._capability(
+ qod_session.session_id,
+ phone_number="5550100",
+ application_server_ipv4="10.0.0.5",
+ )
+
+ result = await service.get_session(session_id=str(qod_session.session_id))
+
+ assert result.sessionId == qod_session.session_id
+ assert result.qosStatus == "AVAILABLE"
+ assert result.qosProfile == "voice"
+ assert result.duration == 3600
+ assert result.device.phoneNumber == "5550100"
+ assert result.applicationServer.ipv4Address == "10.0.0.5"
+ assert result.expiresAt - result.startedAt == 3600
+
+ async def test_get_session_echoes_ports_persisted_at_creation(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ srm_client: AsyncMock,
+ ) -> None:
+ qod_session = await self._seed_session(
+ qod_session_repo,
+ device_ports={"ranges": None, "ports": [80, 443]},
+ application_server_ports={"ranges": [{"from_": 5000, "to": 5010}], "ports": None},
+ )
+ srm_client.get_network_capability.return_value = self._capability(qod_session.session_id)
+
+ result = await service.get_session(session_id=str(qod_session.session_id))
+
+ assert result.devicePorts is not None
+ assert result.devicePorts.ports == [80, 443]
+ assert result.applicationServerPorts is not None
+ assert result.applicationServerPorts.ranges is not None
+ assert result.applicationServerPorts.ranges[0].to == 5010
+
+ async def test_get_session_omits_ports_when_none_were_set(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ srm_client: AsyncMock,
+ ) -> None:
+ qod_session = await self._seed_session(qod_session_repo)
+ srm_client.get_network_capability.return_value = self._capability(qod_session.session_id)
+
+ result = await service.get_session(session_id=str(qod_session.session_id))
+
+ assert result.devicePorts is None
+ assert result.applicationServerPorts is None
+
+ async def test_qos_status_reflects_cached_local_state_not_srm(
+ self,
+ service: QualityOnDemandService,
+ qod_session_repo: FakeQodSessionRepository,
+ srm_client: AsyncMock,
+ ) -> None:
+ """qosStatus is the row's cached status (persistence-model.md); SRM's
+ `state` on the capability projection is a different vocabulary and is
+ not the source of `qosStatus`."""
+ qod_session = await self._seed_session(qod_session_repo, state=QodSessionState.REQUESTED)
+ srm_client.get_network_capability.return_value = self._capability(qod_session.session_id)
+
+ result = await service.get_session(session_id=str(qod_session.session_id))
+
+ assert result.qosStatus == "REQUESTED"
+
+
+class TestNetworkCapabilitySnapshotShape:
+ def _snapshot(self, **overrides: Any) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "service_instance_id": str(uuid4()),
+ "capability_type": "qod_session",
+ "state": "active",
+ "app_provider_id": "provider-1",
+ "parameters_snapshot": {
+ "schema_version": "srm.params/v1",
+ "target": {
+ "device": {"phone_number": "123456789"},
+ "application_server": {"ipv4": "192.168.0.1"},
+ },
+ "parameters": {"profile_ref": "voice", "duration_seconds": 3600},
+ "source_spec": {"family": "camara", "api": "quality-on-demand"},
+ },
+ }
+ payload.update(overrides)
+ return payload
+
+ def test_accepts_canonical_snapshot_with_profile_ref_nested_under_parameters(self) -> None:
+ capability = SRMNetworkCapability.model_validate(self._snapshot())
+
+ assert capability.parameters_snapshot.parameters["profile_ref"] == "voice"
+ assert capability.parameters_snapshot.target.device.phone_number == "123456789"
+
+ def test_accepts_snapshot_without_the_commands_top_level_profile_ref(self) -> None:
+ """The regression guard: §B.5 says the top-level field never reaches the snapshot."""
+ snapshot = self._snapshot()
+ assert "profile_ref" not in snapshot["parameters_snapshot"]
+
+ capability = SRMNetworkCapability.model_validate(snapshot)
+
+ assert capability.parameters_snapshot.target.application_server.ipv4 == "192.168.0.1"
diff --git a/tests/unit/test_vendored_spec_refs.py b/tests/unit/test_vendored_spec_refs.py
index 67f22d5d970be6a77c5adc2ca7af9c2e6b141984..9b51c56bdcc696dda87c0ce52ceb636bb885e49f 100644
--- a/tests/unit/test_vendored_spec_refs.py
+++ b/tests/unit/test_vendored_spec_refs.py
@@ -1,29 +1,29 @@
-"""Every $ref in the vendored CAMARA EAM spec must resolve.
+"""Every $ref in the vendored CAMARA specs must resolve.
Schemathesis's schema.parametrize() only walks path operations, not
`components.callbacks` — so a dangling $ref inside a callback body schema
(the onAppInstanceStatusChange CloudEvent, referencing an external file that
wasn't vendored in) was never dereferenced and never failed conformance.
-This test walks every $ref in the spec, including callbacks, and fails on
+This test walks every $ref in each spec, including callbacks, and fails on
any target file or JSON pointer that doesn't resolve.
"""
from pathlib import Path
from typing import Any
+import pytest
import yaml
-SPEC = (
- Path(__file__).parents[2]
- / "src"
- / "open_exposure_gateway"
- / "api"
- / "camara"
+_CAMARA = Path(__file__).parents[2] / "src" / "open_exposure_gateway" / "api" / "camara"
+
+SPECS = {
+ "eam": _CAMARA
/ "edge_application_management"
/ "vwip"
/ "API_definitions"
- / "edge-application-management.yaml"
-)
+ / "edge-application-management.yaml",
+ "qod": _CAMARA / "quality_on_demand" / "API_definitions" / "qod-api.yaml",
+}
def _load(path: Path) -> Any:
@@ -65,6 +65,7 @@ def _walk(node: Any, path: Path, document: Any, seen: set[tuple[Path, str]]) ->
_walk(item, path, document, seen)
-def test_every_ref_in_the_vendored_eam_spec_resolves() -> None:
- document = _load(SPEC)
- _walk(document, SPEC, document, set())
+@pytest.mark.parametrize("spec", SPECS.values(), ids=SPECS.keys())
+def test_every_ref_in_the_vendored_spec_resolves(spec: Path) -> None:
+ document = _load(spec)
+ _walk(document, spec, document, set())