From bacf0101a66a552cdfc4b008f2bd01f09155b811 Mon Sep 17 00:00:00 2001 From: hideyukiMORI Date: Tue, 19 May 2026 20:14:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DomainExceptionHandler=20=E3=83=91?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=83=B3=E3=81=A8=20NoteNotFoundException=20?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85=E3=81=99=E3=82=8B=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DomainExceptionHandlerProtocol を新設し ErrorHandlerMiddleware に統合 - NoteNotFoundException + NoteNotFoundExceptionHandler を追加 - GetNoteUseCase が Note 未存在時に NoteNotFoundException を送出するよう変更 - ハンドラーのテストを拡充(domain handler / fallthrough / 422) Co-Authored-By: Claude Sonnet 4.6 --- src/example/app.py | 7 +++- src/example/note/exceptions.py | 19 +++++++++ src/example/note/handler.py | 4 +- src/example/note/use_case.py | 8 +++- src/nene2/middleware/__init__.py | 3 +- src/nene2/middleware/domain_exception.py | 18 +++++++++ src/nene2/middleware/error_handler.py | 14 ++++++- tests/nene2/middleware/test_error_handler.py | 41 ++++++++++++++++++-- 8 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 src/example/note/exceptions.py create mode 100644 src/nene2/middleware/domain_exception.py diff --git a/src/example/app.py b/src/example/app.py index 669e73f..201c4dd 100644 --- a/src/example/app.py +++ b/src/example/app.py @@ -7,6 +7,7 @@ from nene2.middleware import ErrorHandlerMiddleware from nene2.validation.exceptions import ValidationException +from .note.exceptions import NoteNotFoundExceptionHandler from .note.handler import make_note_router from .note.repository import InMemoryNoteRepository from .note.use_case import CreateNoteUseCase, GetNoteUseCase, ListNotesUseCase @@ -22,7 +23,11 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: openapi_url="/openapi.json", ) - app.add_middleware(ErrorHandlerMiddleware, debug=cfg.app_debug) + app.add_middleware( + ErrorHandlerMiddleware, + debug=cfg.app_debug, + domain_handlers=[NoteNotFoundExceptionHandler()], + ) app.add_exception_handler( ValidationException, ErrorHandlerMiddleware.handle_validation_exception, diff --git a/src/example/note/exceptions.py b/src/example/note/exceptions.py new file mode 100644 index 0000000..b8b23d5 --- /dev/null +++ b/src/example/note/exceptions.py @@ -0,0 +1,19 @@ +"""Note domain exceptions and their HTTP handlers.""" + +from starlette.responses import Response + +from nene2.http.problem_details import problem_details_response + + +class NoteNotFoundException(Exception): + def __init__(self, note_id: int) -> None: + self.note_id = note_id + super().__init__(f"Note {note_id} not found.") + + +class NoteNotFoundExceptionHandler: + def handles(self, exc: Exception) -> bool: + return isinstance(exc, NoteNotFoundException) + + def handle(self, exc: Exception) -> Response: + return problem_details_response("not-found", "Not Found", 404) diff --git a/src/example/note/handler.py b/src/example/note/handler.py index 05f0ad4..5e06bb9 100644 --- a/src/example/note/handler.py +++ b/src/example/note/handler.py @@ -4,7 +4,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel -from nene2.http import PaginationQueryParser, PaginationResponse, problem_details_response +from nene2.http import PaginationQueryParser, PaginationResponse from nene2.validation.exceptions import ValidationError, ValidationException from .use_case import ( @@ -44,8 +44,6 @@ async def list_notes(request: Request) -> JSONResponse: @router.get("/{note_id}") async def get_note(note_id: int) -> JSONResponse: note = get_use_case.execute(note_id) - if note is None: - return problem_details_response("not-found", "Not Found", 404) return JSONResponse({"id": note.id, "title": note.title, "body": note.body}) @router.post("", status_code=201) diff --git a/src/example/note/use_case.py b/src/example/note/use_case.py index 803affb..c6fa665 100644 --- a/src/example/note/use_case.py +++ b/src/example/note/use_case.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from .entity import Note +from .exceptions import NoteNotFoundException from .repository import NoteRepositoryInterface @@ -53,5 +54,8 @@ class GetNoteUseCase: def __init__(self, repository: NoteRepositoryInterface) -> None: self._repository = repository - def execute(self, note_id: int) -> Note | None: - return self._repository.find_by_id(note_id) + def execute(self, note_id: int) -> Note: + note = self._repository.find_by_id(note_id) + if note is None: + raise NoteNotFoundException(note_id) + return note diff --git a/src/nene2/middleware/__init__.py b/src/nene2/middleware/__init__.py index 221f69b..a3a1232 100644 --- a/src/nene2/middleware/__init__.py +++ b/src/nene2/middleware/__init__.py @@ -1,5 +1,6 @@ """NENE2 middleware pipeline.""" +from .domain_exception import DomainExceptionHandlerProtocol from .error_handler import ErrorHandlerMiddleware -__all__ = ["ErrorHandlerMiddleware"] +__all__ = ["DomainExceptionHandlerProtocol", "ErrorHandlerMiddleware"] diff --git a/src/nene2/middleware/domain_exception.py b/src/nene2/middleware/domain_exception.py new file mode 100644 index 0000000..83d762d --- /dev/null +++ b/src/nene2/middleware/domain_exception.py @@ -0,0 +1,18 @@ +"""DomainExceptionHandlerProtocol — delegate domain errors to typed handlers.""" + +from typing import Protocol, runtime_checkable + +from starlette.responses import Response + + +@runtime_checkable +class DomainExceptionHandlerProtocol(Protocol): + """Map a domain exception to an HTTP response.""" + + def handles(self, exc: Exception) -> bool: + """Return True if this handler is responsible for *exc*.""" + ... + + def handle(self, exc: Exception) -> Response: + """Convert *exc* to an HTTP response. Called only when handles() is True.""" + ... diff --git a/src/nene2/middleware/error_handler.py b/src/nene2/middleware/error_handler.py index e71af4e..cfabd57 100644 --- a/src/nene2/middleware/error_handler.py +++ b/src/nene2/middleware/error_handler.py @@ -16,6 +16,8 @@ from nene2.http.problem_details import problem_details_response from nene2.validation.exceptions import ValidationException +from .domain_exception import DomainExceptionHandlerProtocol + _ASGIApp = Callable[ [ MutableMapping[str, Any], @@ -31,9 +33,16 @@ class ErrorHandlerMiddleware(BaseHTTPMiddleware): """Catch-all error handler that maps exceptions to Problem Details responses.""" - def __init__(self, app: _ASGIApp, *, debug: bool = False) -> None: + def __init__( + self, + app: _ASGIApp, + *, + debug: bool = False, + domain_handlers: list[DomainExceptionHandlerProtocol] | None = None, + ) -> None: super().__init__(app) self.debug = debug + self._domain_handlers: list[DomainExceptionHandlerProtocol] = domain_handlers or [] async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: try: @@ -47,6 +56,9 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - extra={"errors": [e.to_dict() for e in exc.errors]}, ) except Exception as exc: + for handler in self._domain_handlers: + if handler.handles(exc): + return handler.handle(exc) logger.exception("Unhandled exception") detail = str(exc) if self.debug else "The server encountered an unexpected condition." return problem_details_response( diff --git a/tests/nene2/middleware/test_error_handler.py b/tests/nene2/middleware/test_error_handler.py index 01409d7..bf5cb69 100644 --- a/tests/nene2/middleware/test_error_handler.py +++ b/tests/nene2/middleware/test_error_handler.py @@ -3,14 +3,30 @@ from fastapi import FastAPI from fastapi.responses import JSONResponse from fastapi.testclient import TestClient +from starlette.responses import Response -from nene2.middleware import ErrorHandlerMiddleware +from nene2.http.problem_details import problem_details_response +from nene2.middleware import DomainExceptionHandlerProtocol, ErrorHandlerMiddleware from nene2.validation.exceptions import ValidationError, ValidationException -def _make_app(*, debug: bool = False) -> FastAPI: +class _DomainError(Exception): + pass + + +class _DomainErrorHandler: + def handles(self, exc: Exception) -> bool: + return isinstance(exc, _DomainError) + + def handle(self, exc: Exception) -> Response: + return problem_details_response("domain-error", "Domain Error", 409) + + +def _make_app( + *, debug: bool = False, domain_handlers: list[DomainExceptionHandlerProtocol] | None = None +) -> FastAPI: app = FastAPI() - app.add_middleware(ErrorHandlerMiddleware, debug=debug) + app.add_middleware(ErrorHandlerMiddleware, debug=debug, domain_handlers=domain_handlers) app.add_exception_handler( ValidationException, ErrorHandlerMiddleware.handle_validation_exception, # type: ignore[arg-type] @@ -20,6 +36,10 @@ def _make_app(*, debug: bool = False) -> FastAPI: async def boom() -> JSONResponse: raise RuntimeError("secret internal detail") + @app.get("/domain-error") + async def domain_error() -> JSONResponse: + raise _DomainError() + @app.get("/validation-error") async def validation_error() -> JSONResponse: raise ValidationException([ValidationError("field", "bad value", "invalid")]) @@ -51,3 +71,18 @@ def test_validation_exception_returns_422() -> None: r = client.get("/validation-error") assert r.status_code == 422 assert r.json()["errors"][0]["field"] == "field" + + +def test_domain_exception_handler_returns_mapped_status() -> None: + client = TestClient( + _make_app(domain_handlers=[_DomainErrorHandler()]), raise_server_exceptions=False + ) + r = client.get("/domain-error") + assert r.status_code == 409 + assert r.json()["type"].endswith("domain-error") + + +def test_unregistered_domain_exception_falls_through_to_500() -> None: + client = TestClient(_make_app(domain_handlers=[]), raise_server_exceptions=False) + r = client.get("/domain-error") + assert r.status_code == 500