Loading src/srm/api/dependencies.py +13 −3 Changes for src/srm/api/dependencies.py: 13 added lines, 3 removed lines. Original line number Diff line number Diff line from typing import Annotated, AsyncGenerator, cast from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.adapters.databus.nats_publisher import NatsPublisher from srm.app_state import AppState from srm.application.use_cases.catalog import ( CreateServiceSpecificationUseCase, Loading @@ -16,6 +17,10 @@ def get_app_state(request: Request) -> AppState: return cast(AppState, request.app.state) def get_session_maker(request: Request) -> async_sessionmaker[AsyncSession]: return get_app_state(request=request).session_maker async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: async with get_app_state(request=request).session_maker() as session: try: Loading @@ -27,12 +32,17 @@ async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionMakerDep = Annotated[async_sessionmaker[AsyncSession], Depends(get_session_maker)] def get_create_service_specification_use_case( session: SessionDep, request: Request, session_maker: SessionMakerDep, ) -> CreateServiceSpecificationUseCase: return CreateServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) return CreateServiceSpecificationUseCase( session_maker, NatsPublisher(get_app_state(request).databus_connection_manager), ) def get_service_specification_use_case( Loading src/srm/api/errors.py 0 → 100644 +101 −0 Changes for src/srm/api/errors.py: 101 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import Field from starlette.exceptions import HTTPException as StarletteHTTPException from srm.domain.models.common import DomainModel PROBLEM_JSON = "application/problem+json" class ProblemDetails(DomainModel): type: str title: str status: int detail: str | None = None instance: str | None = None errors: list[dict[str, Any]] = Field(default_factory=list) def _title_for_status(status_code: int) -> str: return { 404: "Not Found", 409: "Conflict", 422: "Unprocessable Entity", 500: "Internal Server Error", 503: "Service Unavailable", }.get(status_code, "HTTP Error") def _problem_response(problem: ProblemDetails) -> JSONResponse: return JSONResponse( status_code=problem.status, content=problem.model_dump(mode="json", exclude_none=True, exclude_defaults=True), media_type=PROBLEM_JSON, ) async def http_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: if not isinstance(exc, StarletteHTTPException): raise exc detail = exc.detail if isinstance(exc.detail, str) else None problem = ProblemDetails( type="about:blank", title=_title_for_status(exc.status_code), status=exc.status_code, detail=detail, instance=str(request.url.path), ) response = _problem_response(problem) if exc.headers: response.headers.update(exc.headers) return response async def validation_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: if not isinstance(exc, RequestValidationError): raise exc problem = ProblemDetails( type="about:blank", title=_title_for_status(422), status=422, detail="Request validation failed.", instance=str(request.url.path), errors=[ { "loc": list(error["loc"]), "msg": str(error["msg"]), "type": str(error["type"]), } for error in exc.errors() ], ) return _problem_response(problem) async def unhandled_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: problem = ProblemDetails( type="about:blank", title=_title_for_status(500), status=500, instance=str(request.url.path), ) return _problem_response(problem) def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler) src/srm/api/rest/router.py +5 −5 Changes for src/srm/api/rest/router.py: 5 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -9,10 +9,10 @@ from srm.api.dependencies import ( DeleteServiceSpecificationUseCaseDep, GetServiceSpecificationUseCaseDep, ) from srm.api.errors import ProblemDetails from srm.api.rest.schemas import ( CreateServiceSpecificationRequest, CreateServiceSpecificationResponse, ErrorResponse, GetServiceSpecificationResponse, ServiceCapabilityRequirementResponseSchema, ServiceDeploymentUnitResponseSchema, Loading Loading @@ -141,7 +141,7 @@ def _build_get_response(saved: ServiceSpecification) -> GetServiceSpecificationR status_code=status.HTTP_201_CREATED, responses={ status.HTTP_409_CONFLICT: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification already exists.", } }, Loading Loading @@ -178,7 +178,7 @@ async def create_service_specification( "/catalog/service-specifications/{id}", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification not found.", } }, Loading Loading @@ -208,11 +208,11 @@ async def get_service_specification( status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification not found.", }, status.HTTP_409_CONFLICT: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification has live service instances.", }, }, Loading src/srm/api/rest/schemas.py +0 −4 Changes for src/srm/api/rest/schemas.py: 0 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -97,7 +97,3 @@ class GetServiceSpecificationResponse(DomainModel): service_specification: ServiceSpecificationCreateSchema service_deployment_units: list[ServiceDeploymentUnitResponseSchema] service_capability_requirements: list[ServiceCapabilityRequirementResponseSchema] class ErrorResponse(DomainModel): detail: str src/srm/application/use_cases/catalog.py +51 −3 Changes for src/srm/application/use_cases/catalog.py: 51 added lines, 3 removed lines. Original line number Diff line number Diff line from uuid import UUID import structlog from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.domain.models.catalog import ServiceSpecification from srm.domain.ports.database.catalog import ServiceSpecificationRepository from srm.domain.ports.databus.events import SrmCatalogUpdatedV1 from srm.domain.ports.databus.publisher import DataBusPublisher logger: structlog.BoundLogger = structlog.getLogger(__name__) class CreateServiceSpecificationUseCase: def __init__(self, repository: ServiceSpecificationRepository) -> None: self._repository = repository def __init__( self, session_maker: async_sessionmaker[AsyncSession], publisher: DataBusPublisher, ) -> None: self._session_maker = session_maker self._publisher = publisher async def execute( self, specification: ServiceSpecification, requirement_deployment_unit_refs: dict[str, str], ) -> ServiceSpecification: async with self._session_maker() as session: try: repo = SqlServiceSpecificationRepository(session=session) res = await self.create_specification( specification=specification, requirement_deployment_unit_refs=requirement_deployment_unit_refs, repository=repo, ) await session.commit() except Exception: await session.rollback() raise try: await self._publisher.publish( "event.srm.catalog.updated", SrmCatalogUpdatedV1(schema_version="1.0").model_dump(mode="json"), ) except Exception as exc: logger.error( "CreateServiceSpecificationUseCase_publish_failed", svc_spec_id=res.id, exc_info=exc, ) raise return res async def create_specification( self, specification: ServiceSpecification, requirement_deployment_unit_refs: dict[str, str], repository: SqlServiceSpecificationRepository, ) -> ServiceSpecification: deployment_unit_ids = {unit.ref: unit.id for unit in specification.deployment_units} for requirement in specification.capability_requirements: Loading @@ -19,7 +67,7 @@ class CreateServiceSpecificationUseCase: if deployment_unit_ref is not None: requirement.deployment_unit_id = deployment_unit_ids[deployment_unit_ref] return await self._repository.create(specification) return await repository.create(specification) class GetServiceSpecificationUseCase: Loading Loading
src/srm/api/dependencies.py +13 −3 Changes for src/srm/api/dependencies.py: 13 added lines, 3 removed lines. Original line number Diff line number Diff line from typing import Annotated, AsyncGenerator, cast from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.adapters.databus.nats_publisher import NatsPublisher from srm.app_state import AppState from srm.application.use_cases.catalog import ( CreateServiceSpecificationUseCase, Loading @@ -16,6 +17,10 @@ def get_app_state(request: Request) -> AppState: return cast(AppState, request.app.state) def get_session_maker(request: Request) -> async_sessionmaker[AsyncSession]: return get_app_state(request=request).session_maker async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: async with get_app_state(request=request).session_maker() as session: try: Loading @@ -27,12 +32,17 @@ async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionMakerDep = Annotated[async_sessionmaker[AsyncSession], Depends(get_session_maker)] def get_create_service_specification_use_case( session: SessionDep, request: Request, session_maker: SessionMakerDep, ) -> CreateServiceSpecificationUseCase: return CreateServiceSpecificationUseCase(SqlServiceSpecificationRepository(session)) return CreateServiceSpecificationUseCase( session_maker, NatsPublisher(get_app_state(request).databus_connection_manager), ) def get_service_specification_use_case( Loading
src/srm/api/errors.py 0 → 100644 +101 −0 Changes for src/srm/api/errors.py: 101 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import Field from starlette.exceptions import HTTPException as StarletteHTTPException from srm.domain.models.common import DomainModel PROBLEM_JSON = "application/problem+json" class ProblemDetails(DomainModel): type: str title: str status: int detail: str | None = None instance: str | None = None errors: list[dict[str, Any]] = Field(default_factory=list) def _title_for_status(status_code: int) -> str: return { 404: "Not Found", 409: "Conflict", 422: "Unprocessable Entity", 500: "Internal Server Error", 503: "Service Unavailable", }.get(status_code, "HTTP Error") def _problem_response(problem: ProblemDetails) -> JSONResponse: return JSONResponse( status_code=problem.status, content=problem.model_dump(mode="json", exclude_none=True, exclude_defaults=True), media_type=PROBLEM_JSON, ) async def http_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: if not isinstance(exc, StarletteHTTPException): raise exc detail = exc.detail if isinstance(exc.detail, str) else None problem = ProblemDetails( type="about:blank", title=_title_for_status(exc.status_code), status=exc.status_code, detail=detail, instance=str(request.url.path), ) response = _problem_response(problem) if exc.headers: response.headers.update(exc.headers) return response async def validation_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: if not isinstance(exc, RequestValidationError): raise exc problem = ProblemDetails( type="about:blank", title=_title_for_status(422), status=422, detail="Request validation failed.", instance=str(request.url.path), errors=[ { "loc": list(error["loc"]), "msg": str(error["msg"]), "type": str(error["type"]), } for error in exc.errors() ], ) return _problem_response(problem) async def unhandled_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: problem = ProblemDetails( type="about:blank", title=_title_for_status(500), status=500, instance=str(request.url.path), ) return _problem_response(problem) def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler)
src/srm/api/rest/router.py +5 −5 Changes for src/srm/api/rest/router.py: 5 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -9,10 +9,10 @@ from srm.api.dependencies import ( DeleteServiceSpecificationUseCaseDep, GetServiceSpecificationUseCaseDep, ) from srm.api.errors import ProblemDetails from srm.api.rest.schemas import ( CreateServiceSpecificationRequest, CreateServiceSpecificationResponse, ErrorResponse, GetServiceSpecificationResponse, ServiceCapabilityRequirementResponseSchema, ServiceDeploymentUnitResponseSchema, Loading Loading @@ -141,7 +141,7 @@ def _build_get_response(saved: ServiceSpecification) -> GetServiceSpecificationR status_code=status.HTTP_201_CREATED, responses={ status.HTTP_409_CONFLICT: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification already exists.", } }, Loading Loading @@ -178,7 +178,7 @@ async def create_service_specification( "/catalog/service-specifications/{id}", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification not found.", } }, Loading Loading @@ -208,11 +208,11 @@ async def get_service_specification( status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification not found.", }, status.HTTP_409_CONFLICT: { "model": ErrorResponse, "model": ProblemDetails, "description": "Service specification has live service instances.", }, }, Loading
src/srm/api/rest/schemas.py +0 −4 Changes for src/srm/api/rest/schemas.py: 0 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -97,7 +97,3 @@ class GetServiceSpecificationResponse(DomainModel): service_specification: ServiceSpecificationCreateSchema service_deployment_units: list[ServiceDeploymentUnitResponseSchema] service_capability_requirements: list[ServiceCapabilityRequirementResponseSchema] class ErrorResponse(DomainModel): detail: str
src/srm/application/use_cases/catalog.py +51 −3 Changes for src/srm/application/use_cases/catalog.py: 51 added lines, 3 removed lines. Original line number Diff line number Diff line from uuid import UUID import structlog from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository from srm.domain.models.catalog import ServiceSpecification from srm.domain.ports.database.catalog import ServiceSpecificationRepository from srm.domain.ports.databus.events import SrmCatalogUpdatedV1 from srm.domain.ports.databus.publisher import DataBusPublisher logger: structlog.BoundLogger = structlog.getLogger(__name__) class CreateServiceSpecificationUseCase: def __init__(self, repository: ServiceSpecificationRepository) -> None: self._repository = repository def __init__( self, session_maker: async_sessionmaker[AsyncSession], publisher: DataBusPublisher, ) -> None: self._session_maker = session_maker self._publisher = publisher async def execute( self, specification: ServiceSpecification, requirement_deployment_unit_refs: dict[str, str], ) -> ServiceSpecification: async with self._session_maker() as session: try: repo = SqlServiceSpecificationRepository(session=session) res = await self.create_specification( specification=specification, requirement_deployment_unit_refs=requirement_deployment_unit_refs, repository=repo, ) await session.commit() except Exception: await session.rollback() raise try: await self._publisher.publish( "event.srm.catalog.updated", SrmCatalogUpdatedV1(schema_version="1.0").model_dump(mode="json"), ) except Exception as exc: logger.error( "CreateServiceSpecificationUseCase_publish_failed", svc_spec_id=res.id, exc_info=exc, ) raise return res async def create_specification( self, specification: ServiceSpecification, requirement_deployment_unit_refs: dict[str, str], repository: SqlServiceSpecificationRepository, ) -> ServiceSpecification: deployment_unit_ids = {unit.ref: unit.id for unit in specification.deployment_units} for requirement in specification.capability_requirements: Loading @@ -19,7 +67,7 @@ class CreateServiceSpecificationUseCase: if deployment_unit_ref is not None: requirement.deployment_unit_id = deployment_unit_ids[deployment_unit_ref] return await self._repository.create(specification) return await repository.create(specification) class GetServiceSpecificationUseCase: Loading