Commit d7d827c3 authored by Paris Stentoumis's avatar Paris Stentoumis
Browse files

SRM Refactor: Publish catalog update events and return RFC 7807 HTTP errors

parent 4b5d1b8f
Loading
Loading
Loading
Loading
+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,
@@ -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:
@@ -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(

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)
+5 −5
Changes for src/srm/api/rest/router.py: 5 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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,
@@ -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.",
        }
    },
@@ -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.",
        }
    },
@@ -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.",
        },
    },
+0 −4
Changes for src/srm/api/rest/schemas.py: 0 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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
+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:
@@ -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