Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/api/django/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.http import HttpRequest, JsonResponse


def custom_handler404(request: HttpRequest, exception: Exception) -> JsonResponse:
return JsonResponse(data={"detail": "Not Found"}, status=404)
15 changes: 15 additions & 0 deletions app/api/django/items/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from app.api.django.types import EnhancedHttpRequest
from app.core.django.context import Context
from app.domain.dev.commands import ItemCreateError, create_item_error_command
from app.domain.items.commands import (
create_item_command,
delete_item_command,
Expand Down Expand Up @@ -56,3 +57,17 @@ def delete(self, request: HttpRequest, item_id: uuid.UUID) -> JsonResponse:
context = Context()
delete_item_command(context, item_id=item_id)
return JsonResponse(data="", status=204, safe=False)


class ItemViewSpecial(View):
body_models: ClassVar[dict[str, type[BaseModel]]] = {"POST": ItemCreateError}

def post(self, request: EnhancedHttpRequest[ItemCreateError]) -> JsonResponse:
error_type = request.GET.get("error_type")
context = Context()
item = create_item_error_command(
context,
item_create=request.validated_data,
error_type=error_type, # ty:ignore[invalid-argument-type]
)
return JsonResponse(data=item.model_dump(), safe=False)
57 changes: 34 additions & 23 deletions app/api/django/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from typing import Any

from django.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.http import HttpRequest, HttpResponse
from pydantic import BaseModel, ValidationError

from app.api.django.types import EnhancedHttpRequest
from app.api.utils import ERROR_MAPPING
from app.domain.exceptions import DomainError

DJANGO_MIDDLEWARES = [
"app.api.django.middlewares.DomainExceptionMiddleware",
"app.api.django.middlewares.PydanticValidationMiddleware",
"app.api.django.middlewares.TransactionMiddleware",
]
Expand All @@ -35,27 +34,6 @@ def get_json_body(self, request: HttpRequest) -> HttpResponse | dict[str, Any]:
)


class DomainExceptionMiddleware(BaseMiddleware):
def process_exception(
self, request: HttpRequest, exc: Exception
) -> HttpResponse | None:
if not isinstance(exc, DomainError):
return None

for error_cls in type(exc).mro():
if issubclass(error_cls, DomainError) and error_cls in ERROR_MAPPING:
return JsonResponse(
data={"detail": str(exc)},
status=ERROR_MAPPING[error_cls],
)

return HttpResponse(
content="Internal Server Error",
status=500,
content_type="text/plain",
)


class PydanticValidationMiddleware(BaseMiddleware):
def process_view[T: BaseModel](
self,
Expand Down Expand Up @@ -92,3 +70,36 @@ class TransactionMiddleware(BaseMiddleware):
def __call__(self, request: HttpRequest) -> HttpResponse:
with transaction.atomic():
return self.get_response(request)

def process_exception(
self,
request: HttpRequest,
exc: Exception,
) -> HttpResponse:
# TODO: `transaction.atomic()` context manager does not handle rollback on error
# is there a better way to do that?
transaction.set_rollback(True)

if isinstance(exc, DomainError):
return handle_domain_exceptions(exc)

return HttpResponse(
content=json.dumps({"detail": "Internal Server Error"}),
status=500,
content_type="application/json",
)


def handle_domain_exceptions(exc: DomainError) -> HttpResponse:
status_code = 500

for error_cls in type(exc).mro():
if issubclass(error_cls, DomainError) and error_cls in ERROR_MAPPING:
status_code = ERROR_MAPPING[error_cls]
break

return HttpResponse(
content=json.dumps({"detail": str(exc)}),
status=status_code,
content_type="application/json",
)
7 changes: 6 additions & 1 deletion app/api/django/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from django.urls import path
from django.urls.resolvers import URLPattern, URLResolver

from app.api.django.items.routes import ItemView, ItemViewDetail
from app.api.django.handlers import custom_handler404
from app.api.django.items.routes import ItemView, ItemViewDetail, ItemViewSpecial

urlpatterns: list[URLPattern | URLResolver] = [
path("items", ItemView.as_view()),
path("items/<item_id>", ItemViewDetail.as_view()),
path("dev/error", ItemViewSpecial.as_view()),
]

# Override handler to return JSON response
handler404 = custom_handler404
2 changes: 2 additions & 0 deletions app/api/fastapi/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import FastAPI

from app.api.fastapi.dev.router import router as dev_router
from app.api.fastapi.exceptions import add_exception_handlers
from app.api.fastapi.items.router import router as items_router
from app.api.fastapi.lifespan import lifespan_factory
Expand All @@ -11,5 +12,6 @@ def create_fastapi_app(settings: Settings) -> FastAPI:

add_exception_handlers(app=app)
app.include_router(items_router)
app.include_router(dev_router)

return app
Empty file added app/api/fastapi/dev/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions app/api/fastapi/dev/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Annotated, Any, Literal

from fastapi import APIRouter, Depends, status

from app.api.fastapi.dependencies import get_context
from app.core.sqlalchemy.context import Context
from app.domain.dev.commands import ItemCreateError, create_item_error_command
from app.domain.items.entities import Item

router = APIRouter(prefix="/dev")


@router.post("/error", response_model=Item, status_code=status.HTTP_201_CREATED)
def item_error(
item_create: ItemCreateError,
context: Annotated[Context, Depends(get_context)],
error_type: Literal["domain", "unexpected"] | None = None,
) -> Any:
return create_item_error_command(
context,
item_create=item_create,
error_type=error_type,
)
27 changes: 17 additions & 10 deletions app/api/fastapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import FastAPI, status
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.responses import JSONResponse

from app.api.utils import ERROR_MAPPING
from app.domain.exceptions import (
Expand All @@ -9,16 +9,23 @@


def add_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal Server Error"},
)

@app.exception_handler(DomainError)
async def domain_exception_handler(request: Request, exc: DomainError) -> Response:
async def domain_exception_handler(
request: Request,
exc: DomainError,
) -> JSONResponse:
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR

for error_cls in type(exc).mro():
if issubclass(error_cls, DomainError) and error_cls in ERROR_MAPPING:
return JSONResponse(
status_code=ERROR_MAPPING[error_cls],
content={"detail": str(exc)},
)
status_code = ERROR_MAPPING[error_cls]
break

return PlainTextResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content="Internal Server Error",
)
return JSONResponse(status_code=status_code, content={"detail": str(exc)})
2 changes: 2 additions & 0 deletions app/api/flask/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from flask import Flask

from app.api.flask.dev.router import router as dev_router
from app.api.flask.exceptions import add_exception_handlers
from app.api.flask.items.router import router as items_router
from app.api.flask.utils import init_app
Expand All @@ -11,6 +12,7 @@ def create_flask_app(settings: Settings) -> Flask:

add_exception_handlers(app=app)
app.register_blueprint(items_router)
app.register_blueprint(dev_router)
init_app(settings=settings, app=app)

return app
20 changes: 15 additions & 5 deletions app/api/flask/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from flask import current_app, g

from app.core.sqlalchemy.context import Context
from app.infrastructure.sqlalchemy.logger import logger


def get_context() -> Context:
Expand All @@ -12,10 +13,19 @@ def get_context() -> Context:


def sql_session_teardown(error: BaseException | None) -> None:
# In Flask, `error` is only set for unhandled exceptions
# The global Exception handler catches everything, so `error` is always None.

sql_session = g.pop("sql_session", None)
if sql_session is not None:
if error is not None:
sql_session.rollback()
else:
sql_session.commit()
if not sql_session:
return

if g.pop("error", None):
logger.error(f"Rollback due to '{g.exception}'")
sql_session.rollback()
sql_session.close()
return

sql_session.commit()
logger.info("Commit ok")
sql_session.close()
Empty file added app/api/flask/dev/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions app/api/flask/dev/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from flask import Blueprint, Response, request

from app.api.flask.dependencies import get_context
from app.domain.dev.commands import ItemCreateError, create_item_error_command

router = Blueprint("dev", __name__, url_prefix="/dev")


@router.post("/error")
def item_error() -> Response:
error_type = request.args["error_type"]
context = get_context()
item_create = ItemCreateError.model_validate(request.get_json())
item = create_item_error_command(
context,
item_create=item_create,
error_type=error_type, # ty:ignore[invalid-argument-type]
)
return Response(
response=item.model_dump_json(),
status=201,
content_type="application/json",
)
47 changes: 39 additions & 8 deletions app/api/flask/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,55 @@
from flask import Flask, Response
import json

from flask import Flask, Response, g
from pydantic import ValidationError
from werkzeug.exceptions import HTTPException

from app.api.utils import ERROR_MAPPING
from app.domain.exceptions import DomainError


def add_exception_handlers(app: Flask) -> None:
@app.errorhandler(Exception)
def exception_handler(exc: Exception) -> Response:
# Ensure rollback in teardown
g.error = True
g.exception = exc

return Response(
response=json.dumps({"detail": "Internal Server Error"}),
status=500,
content_type="application/json",
)

@app.errorhandler(HTTPException)
def http_exception_handler(exc: HTTPException) -> Response:
# Ensure rollback in teardown
g.error = True
g.exception = exc

return Response(
response=json.dumps({"detail": exc.description}),
status=exc.code,
content_type="application/json",
)

@app.errorhandler(DomainError)
def domain_exception_handler(exc: DomainError) -> Response:
# Ensure rollback in teardown
g.error = True
g.exception = exc

status_code = 500

for error_cls in type(exc).mro():
if issubclass(error_cls, DomainError) and error_cls in ERROR_MAPPING:
return Response(
response="{'detail': str(exc)}",
status=ERROR_MAPPING[error_cls],
)
status_code = ERROR_MAPPING[error_cls]
break

return Response(
response="Internal Server Error",
status=500,
content_type="text/plain",
response=json.dumps({"detail": str(exc)}),
status=status_code,
content_type="application/json",
)

@app.errorhandler(ValidationError)
Expand Down
5 changes: 5 additions & 0 deletions app/core/fastapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from app.api.fastapi.app import create_fastapi_app
from app.core.settings import Settings

settings = Settings() # ty:ignore[missing-argument]
app = create_fastapi_app(settings=settings)
2 changes: 1 addition & 1 deletion app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@


class DjangoSettings(BaseModel):
debug: bool = True
debug: bool = False
secret_key: str
root_urlconf: str = "app.api.django.urls"
installed_apps: list[str] = DJANGO_APPS
Expand Down
Empty file added app/domain/dev/__init__.py
Empty file.
34 changes: 34 additions & 0 deletions app/domain/dev/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Literal

from pydantic import BaseModel

from app.domain.context import ContextProtocol
from app.domain.entities import EntityId
from app.domain.exceptions import BadRequestError
from app.domain.items.entities import Item


class UnexpectedError(Exception):
pass


class ItemCreateError(BaseModel):
id: EntityId


def create_item_error_command(
context: ContextProtocol,
/,
item_create: ItemCreateError,
error_type: Literal["domain", "unexpected"] | None = None,
) -> Item:
item = Item(id=item_create.id, name="Item", description="Wonderful item")
context.item_repository.save(item)

if error_type == "domain":
raise BadRequestError("Bad Request")

if error_type == "unexpected":
raise UnexpectedError()

return item
Empty file added app/domain/dev/entities.py
Empty file.
Loading