Loading src/open_exposure_gateway/adapters/database/repos/app_instances.py +7 −0 Original line number Diff line number Diff line Loading @@ -23,6 +23,13 @@ class SqlAppInstanceRepository(AppInstanceRepository): row = await self._session.scalar(stmt) return AppInstanceMapper.to_domain(row) if row is not None else None async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: stmt = select(AppInstanceRow.app_instance_id).where( AppInstanceRow.app_registration_id == app_registration_id ) row = await self._session.scalar(stmt.limit(1)) return row is not None async def save(self, app_instance: AppInstance) -> AppInstance: merged = await self._session.merge(AppInstanceMapper.to_row(app_instance)) await self._session.flush() Loading src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py +78 −26 Original line number Diff line number Diff line Loading @@ -17,12 +17,14 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import ( AbortedException, AlreadyExistsException, BadRequestException, ConflictException, DownstreamServiceException, ForbiddenException, NotFoundException, NotImplementedException, OEGException, UnauthorizedException, ) from open_exposure_gateway.dependencies import ( Loading @@ -41,26 +43,28 @@ router = APIRouter(prefix=BASE_PATH) EdgeAppService = Annotated[EdgeApplicationManagementService, Depends(get_edge_app_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, ) # 500 has no dedicated exception class (it's the unhandled-exception catch-all # in error_handlers.py), so every endpoint gets it for free here. _INTERNAL_SERVER_ERROR_RESPONSE: dict[str, Any] = { "model": ErrorInfo, "description": "Internal server error", } _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} def _responses(*exceptions: OEGException) -> dict[int | str, dict[str, Any]]: """Build OpenAPI response docs from the exceptions a route actually raises. Takes instances (not classes) so each one's default message is read directly, with no need to guess whether a given exception class is callable with no args. The description (and CAMARA error `code`, via error_code) comes straight from each exception, so two endpoints sharing the same HTTP status (e.g. 409) can still document different spec-accurate bodies — ALREADY_EXISTS for submit_app/create_app_instance vs. ABORTED for delete_app. """ responses: dict[int | str, dict[str, Any]] = {500: _INTERNAL_SERVER_ERROR_RESPONSE} for exc in exceptions: responses[exc.status_code] = {"model": ErrorInfo, "description": exc.message} return responses @router.get( Loading @@ -69,7 +73,12 @@ def _responses(*codes: int) -> dict[int | str, dict[str, Any]]: summary="Retrieve a list of the operators Edge Cloud Zones and their status", response_model=list[EdgeCloudZone], response_model_exclude_none=True, responses=_responses(400, 401, 403, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), DownstreamServiceException(), ), ) async def get_edge_cloud_zones( service: EdgeAppService, Loading @@ -89,7 +98,12 @@ async def get_edge_cloud_zones( tags=["Application"], summary="Retrieve a list of existing Applications", response_model=list[AppManifest], responses=_responses(400, 401, 403, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), DownstreamServiceException(), ), ) async def get_apps( service: EdgeAppService, Loading @@ -103,7 +117,13 @@ async def get_apps( tags=["Application"], summary="Retrieve the information of an Application", response_model=AppManifestEnvelope, responses=_responses(400, 401, 403, 404, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), NotFoundException(), DownstreamServiceException(), ), ) async def get_app( appId: UUID, Loading @@ -119,7 +139,14 @@ async def get_app( status_code=201, summary="Submit application metadata to the Edge Cloud Provider.", response_model=SubmittedApp, responses=_responses(400, 401, 403, 409, 500, 501, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), AlreadyExistsException(), NotImplementedException(), DownstreamServiceException(), ), ) async def submit_app( request: AppManifest, Loading @@ -141,7 +168,14 @@ async def submit_app( tags=["Application"], status_code=204, summary="Delete an Application from an Edge Cloud Provider", responses=_responses(400, 401, 403, 404, 409, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), NotFoundException(), AbortedException(), DownstreamServiceException(), ), ) async def delete_app( appId: UUID, Loading @@ -163,7 +197,14 @@ async def delete_app( summary="Instantiation of an Application", response_model=AppInstanceInfo, response_model_exclude_none=True, responses=_responses(400, 401, 403, 409, 500, 501, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), AlreadyExistsException(), NotImplementedException(), DownstreamServiceException(), ), ) async def create_app_instance( request: CreateAppInstanceRequest, Loading Loading @@ -193,7 +234,12 @@ async def create_app_instance( summary="Retrieve the information of Application Instances for a given App", response_model=list[AppInstanceInfo], response_model_exclude_none=True, responses=_responses(400, 401, 403, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), DownstreamServiceException(), ), ) async def get_app_instances( service: EdgeAppService, Loading @@ -215,7 +261,13 @@ async def get_app_instances( tags=["Application"], status_code=202, summary="Terminate an Application Instance", responses=_responses(400, 401, 403, 404, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), NotFoundException(), DownstreamServiceException(), ), ) async def delete_app_instance( appInstanceId: UUID, Loading src/open_exposure_gateway/application/services/edge_application_management_service.py +17 −3 Original line number Diff line number Diff line Loading @@ -29,8 +29,9 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_terminate_instance_command, ) from open_exposure_gateway.core.exceptions import ( AbortedException, AlreadyExistsException, BadRequestException, ConflictException, DownstreamServiceException, NotFoundException, NotImplementedException, Loading Loading @@ -203,7 +204,7 @@ class EdgeApplicationManagementService: ) ) except DuplicateAppRegistrationError as exc: raise ConflictException( raise AlreadyExistsException( message=f"App {translation.app_id} is already registered" ) from exc Loading @@ -229,9 +230,22 @@ class EdgeApplicationManagementService: app_provider_id: str, x_correlator: Optional[str] = None, ) -> None: await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) if self._app_registration_repo is None: raise RuntimeError("AppRegistrationRepository is not available") if self._app_instance_repo is None: raise RuntimeError("AppInstanceRepository is not available") app_registration = await self._app_registration_repo.get_by_app_id(app_id) if app_registration is not None: has_instances = await self._app_instance_repo.exists_for_app_registration( app_registration.app_registration_id ) if has_instances: raise AbortedException( message="App with a running application instance cannot be deleted" ) await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) await self._app_registration_repo.delete_by_app_id(app_id) async def create_app_instance( Loading src/open_exposure_gateway/ports/database/instances.py +4 −0 Original line number Diff line number Diff line Loading @@ -15,6 +15,10 @@ class AppInstanceRepository(ABC): async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None: pass @abstractmethod async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: pass @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass Loading tests/unit/fakes.py +3 −0 Original line number Diff line number Diff line Loading @@ -355,6 +355,9 @@ class FakeAppInstanceRepository(AppInstanceRepository): return row.model_copy(deep=True) return None async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: return any(row.app_registration_id == app_registration_id for row in self.rows.values()) async def save(self, app_instance: AppInstance) -> AppInstance: stored = app_instance.model_copy(deep=True) self.rows[stored.app_instance_id] = stored Loading Loading
src/open_exposure_gateway/adapters/database/repos/app_instances.py +7 −0 Original line number Diff line number Diff line Loading @@ -23,6 +23,13 @@ class SqlAppInstanceRepository(AppInstanceRepository): row = await self._session.scalar(stmt) return AppInstanceMapper.to_domain(row) if row is not None else None async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: stmt = select(AppInstanceRow.app_instance_id).where( AppInstanceRow.app_registration_id == app_registration_id ) row = await self._session.scalar(stmt.limit(1)) return row is not None async def save(self, app_instance: AppInstance) -> AppInstance: merged = await self._session.merge(AppInstanceMapper.to_row(app_instance)) await self._session.flush() Loading
src/open_exposure_gateway/api/camara/edge_application_management/vwip/router.py +78 −26 Original line number Diff line number Diff line Loading @@ -17,12 +17,14 @@ from open_exposure_gateway.application.services.edge_application_management_serv EdgeApplicationManagementService, ) from open_exposure_gateway.core.exceptions import ( AbortedException, AlreadyExistsException, BadRequestException, ConflictException, DownstreamServiceException, ForbiddenException, NotFoundException, NotImplementedException, OEGException, UnauthorizedException, ) from open_exposure_gateway.dependencies import ( Loading @@ -41,26 +43,28 @@ router = APIRouter(prefix=BASE_PATH) EdgeAppService = Annotated[EdgeApplicationManagementService, Depends(get_edge_app_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, ) # 500 has no dedicated exception class (it's the unhandled-exception catch-all # in error_handlers.py), so every endpoint gets it for free here. _INTERNAL_SERVER_ERROR_RESPONSE: dict[str, Any] = { "model": ErrorInfo, "description": "Internal server error", } _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} def _responses(*exceptions: OEGException) -> dict[int | str, dict[str, Any]]: """Build OpenAPI response docs from the exceptions a route actually raises. Takes instances (not classes) so each one's default message is read directly, with no need to guess whether a given exception class is callable with no args. The description (and CAMARA error `code`, via error_code) comes straight from each exception, so two endpoints sharing the same HTTP status (e.g. 409) can still document different spec-accurate bodies — ALREADY_EXISTS for submit_app/create_app_instance vs. ABORTED for delete_app. """ responses: dict[int | str, dict[str, Any]] = {500: _INTERNAL_SERVER_ERROR_RESPONSE} for exc in exceptions: responses[exc.status_code] = {"model": ErrorInfo, "description": exc.message} return responses @router.get( Loading @@ -69,7 +73,12 @@ def _responses(*codes: int) -> dict[int | str, dict[str, Any]]: summary="Retrieve a list of the operators Edge Cloud Zones and their status", response_model=list[EdgeCloudZone], response_model_exclude_none=True, responses=_responses(400, 401, 403, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), DownstreamServiceException(), ), ) async def get_edge_cloud_zones( service: EdgeAppService, Loading @@ -89,7 +98,12 @@ async def get_edge_cloud_zones( tags=["Application"], summary="Retrieve a list of existing Applications", response_model=list[AppManifest], responses=_responses(400, 401, 403, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), DownstreamServiceException(), ), ) async def get_apps( service: EdgeAppService, Loading @@ -103,7 +117,13 @@ async def get_apps( tags=["Application"], summary="Retrieve the information of an Application", response_model=AppManifestEnvelope, responses=_responses(400, 401, 403, 404, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), NotFoundException(), DownstreamServiceException(), ), ) async def get_app( appId: UUID, Loading @@ -119,7 +139,14 @@ async def get_app( status_code=201, summary="Submit application metadata to the Edge Cloud Provider.", response_model=SubmittedApp, responses=_responses(400, 401, 403, 409, 500, 501, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), AlreadyExistsException(), NotImplementedException(), DownstreamServiceException(), ), ) async def submit_app( request: AppManifest, Loading @@ -141,7 +168,14 @@ async def submit_app( tags=["Application"], status_code=204, summary="Delete an Application from an Edge Cloud Provider", responses=_responses(400, 401, 403, 404, 409, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), NotFoundException(), AbortedException(), DownstreamServiceException(), ), ) async def delete_app( appId: UUID, Loading @@ -163,7 +197,14 @@ async def delete_app( summary="Instantiation of an Application", response_model=AppInstanceInfo, response_model_exclude_none=True, responses=_responses(400, 401, 403, 409, 500, 501, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), AlreadyExistsException(), NotImplementedException(), DownstreamServiceException(), ), ) async def create_app_instance( request: CreateAppInstanceRequest, Loading Loading @@ -193,7 +234,12 @@ async def create_app_instance( summary="Retrieve the information of Application Instances for a given App", response_model=list[AppInstanceInfo], response_model_exclude_none=True, responses=_responses(400, 401, 403, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), DownstreamServiceException(), ), ) async def get_app_instances( service: EdgeAppService, Loading @@ -215,7 +261,13 @@ async def get_app_instances( tags=["Application"], status_code=202, summary="Terminate an Application Instance", responses=_responses(400, 401, 403, 404, 500, 503), responses=_responses( BadRequestException(), UnauthorizedException(), ForbiddenException(), NotFoundException(), DownstreamServiceException(), ), ) async def delete_app_instance( appInstanceId: UUID, Loading
src/open_exposure_gateway/application/services/edge_application_management_service.py +17 −3 Original line number Diff line number Diff line Loading @@ -29,8 +29,9 @@ from open_exposure_gateway.application.mappers.edge_application_mapper import ( build_terminate_instance_command, ) from open_exposure_gateway.core.exceptions import ( AbortedException, AlreadyExistsException, BadRequestException, ConflictException, DownstreamServiceException, NotFoundException, NotImplementedException, Loading Loading @@ -203,7 +204,7 @@ class EdgeApplicationManagementService: ) ) except DuplicateAppRegistrationError as exc: raise ConflictException( raise AlreadyExistsException( message=f"App {translation.app_id} is already registered" ) from exc Loading @@ -229,9 +230,22 @@ class EdgeApplicationManagementService: app_provider_id: str, x_correlator: Optional[str] = None, ) -> None: await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) if self._app_registration_repo is None: raise RuntimeError("AppRegistrationRepository is not available") if self._app_instance_repo is None: raise RuntimeError("AppInstanceRepository is not available") app_registration = await self._app_registration_repo.get_by_app_id(app_id) if app_registration is not None: has_instances = await self._app_instance_repo.exists_for_app_registration( app_registration.app_registration_id ) if has_instances: raise AbortedException( message="App with a running application instance cannot be deleted" ) await self.srm_client.delete_app(app_id=app_id, x_correlator=x_correlator) await self._app_registration_repo.delete_by_app_id(app_id) async def create_app_instance( Loading
src/open_exposure_gateway/ports/database/instances.py +4 −0 Original line number Diff line number Diff line Loading @@ -15,6 +15,10 @@ class AppInstanceRepository(ABC): async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None: pass @abstractmethod async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: pass @abstractmethod async def save(self, app_instance: AppInstance) -> AppInstance: pass Loading
tests/unit/fakes.py +3 −0 Original line number Diff line number Diff line Loading @@ -355,6 +355,9 @@ class FakeAppInstanceRepository(AppInstanceRepository): return row.model_copy(deep=True) return None async def exists_for_app_registration(self, app_registration_id: UUID) -> bool: return any(row.app_registration_id == app_registration_id for row in self.rows.values()) async def save(self, app_instance: AppInstance) -> AppInstance: stored = app_instance.model_copy(deep=True) self.rows[stored.app_instance_id] = stored Loading