From facad19c7dc87954c79ac9a45b22f8ead2a53057 Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:32:29 +0300 Subject: [PATCH 1/7] feat: add HttpException hierarchy, ExceptionFilter ABC, and ArgumentsHost Co-Authored-By: Claude Sonnet 4.6 --- nest/common/exceptions.py | 82 ++++++++++++++++ tests/test_common/test_exceptions.py | 135 +++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/test_common/test_exceptions.py diff --git a/nest/common/exceptions.py b/nest/common/exceptions.py index 3d60fcc..c78e24d 100644 --- a/nest/common/exceptions.py +++ b/nest/common/exceptions.py @@ -1,3 +1,10 @@ +from abc import ABC, abstractmethod +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import Request + + class CircularDependencyException(Exception): def __init__(self, message="Circular dependency detected"): super().__init__(message) @@ -10,3 +17,78 @@ class UnknownModuleException(Exception): class NoneInjectableException(Exception): def __init__(self, message="None Injectable Classe Detected"): super().__init__(message) + + +class HttpException(Exception): + def __init__(self, message: str = "Internal Server Error", status_code: int = 500): + self.message = message + self.status_code = status_code + super().__init__(message) + + +class BadRequestException(HttpException): + def __init__(self, message: str = "Bad Request"): + super().__init__(message=message, status_code=400) + + +class UnauthorizedException(HttpException): + def __init__(self, message: str = "Unauthorized"): + super().__init__(message=message, status_code=401) + + +class ForbiddenException(HttpException): + def __init__(self, message: str = "Forbidden"): + super().__init__(message=message, status_code=403) + + +class NotFoundException(HttpException): + def __init__(self, message: str = "Not Found"): + super().__init__(message=message, status_code=404) + + +class MethodNotAllowedException(HttpException): + def __init__(self, message: str = "Method Not Allowed"): + super().__init__(message=message, status_code=405) + + +class ConflictException(HttpException): + def __init__(self, message: str = "Conflict"): + super().__init__(message=message, status_code=409) + + +class UnprocessableEntityException(HttpException): + def __init__(self, message: str = "Unprocessable Entity"): + super().__init__(message=message, status_code=422) + + +class InternalServerErrorException(HttpException): + def __init__(self, message: str = "Internal Server Error"): + super().__init__(message=message, status_code=500) + + +class HttpArgumentsHost: + def __init__(self, request: Optional["Request"]): + self._request = request + + def get_request(self) -> Optional["Request"]: + return self._request + + def get_response(self): + return None + + +class ArgumentsHost: + def __init__(self, request: Optional["Request"]): + self._request = request + + def switch_to_http(self) -> HttpArgumentsHost: + return HttpArgumentsHost(self._request) + + def get_type(self) -> str: + return "http" + + +class ExceptionFilter(ABC): + @abstractmethod + async def catch(self, exception: Exception, host: "ArgumentsHost"): + ... diff --git a/tests/test_common/test_exceptions.py b/tests/test_common/test_exceptions.py new file mode 100644 index 0000000..b5a0e12 --- /dev/null +++ b/tests/test_common/test_exceptions.py @@ -0,0 +1,135 @@ +import pytest +from nest.common.exceptions import ( + HttpException, + BadRequestException, + UnauthorizedException, + ForbiddenException, + NotFoundException, + MethodNotAllowedException, + ConflictException, + UnprocessableEntityException, + InternalServerErrorException, +) + + +def test_http_exception_attributes(): + exc = HttpException(message="oops", status_code=418) + assert exc.status_code == 418 + assert exc.message == "oops" + assert str(exc) == "oops" + + +def test_http_exception_is_exception(): + exc = HttpException(message="x", status_code=400) + assert isinstance(exc, Exception) + + +def test_bad_request_exception(): + exc = BadRequestException("bad input") + assert exc.status_code == 400 + assert exc.message == "bad input" + assert isinstance(exc, HttpException) + + +def test_unauthorized_exception(): + exc = UnauthorizedException("no auth") + assert exc.status_code == 401 + assert isinstance(exc, HttpException) + + +def test_forbidden_exception(): + exc = ForbiddenException("forbidden") + assert exc.status_code == 403 + assert isinstance(exc, HttpException) + + +def test_not_found_exception(): + exc = NotFoundException("not found") + assert exc.status_code == 404 + assert isinstance(exc, HttpException) + + +def test_method_not_allowed_exception(): + exc = MethodNotAllowedException("method not allowed") + assert exc.status_code == 405 + assert isinstance(exc, HttpException) + + +def test_conflict_exception(): + exc = ConflictException("conflict") + assert exc.status_code == 409 + assert isinstance(exc, HttpException) + + +def test_unprocessable_entity_exception(): + exc = UnprocessableEntityException("unprocessable") + assert exc.status_code == 422 + assert isinstance(exc, HttpException) + + +def test_internal_server_error_exception(): + exc = InternalServerErrorException("server error") + assert exc.status_code == 500 + assert isinstance(exc, HttpException) + + +def test_default_message(): + exc = NotFoundException() + assert exc.message == "Not Found" + assert exc.status_code == 404 + + +# --- ArgumentsHost / ExceptionFilter tests --- + +from fastapi import Request +from fastapi.responses import JSONResponse + +from nest.common.exceptions import ( + ArgumentsHost, + HttpArgumentsHost, + ExceptionFilter, +) + + +def test_arguments_host_switch_to_http(): + scope = {"type": "http", "method": "GET", "path": "/", "query_string": b"", + "headers": [], "http_version": "1.1"} + request = Request(scope=scope) + host = ArgumentsHost(request=request) + http_host = host.switch_to_http() + assert isinstance(http_host, HttpArgumentsHost) + + +def test_http_arguments_host_get_request(): + scope = {"type": "http", "method": "GET", "path": "/", "query_string": b"", + "headers": [], "http_version": "1.1"} + request = Request(scope=scope) + host = ArgumentsHost(request=request) + assert host.switch_to_http().get_request() is request + + +def test_arguments_host_get_type(): + host = ArgumentsHost(request=None) + assert host.get_type() == "http" + + +def test_exception_filter_is_abstract(): + with pytest.raises(TypeError): + ExceptionFilter() + + +def test_exception_filter_subclass_must_implement_catch(): + class BadFilter(ExceptionFilter): + pass + + with pytest.raises(TypeError): + BadFilter() + + +def test_exception_filter_concrete_subclass(): + class GoodFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=400, content={"error": str(exception)}) + + f = GoodFilter() + assert f is not None From d1cb46d726003a072a0c0b19b8e8d07ef6c94604 Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:32:57 +0300 Subject: [PATCH 2/7] feat: add @Catch and @UseFilters decorators Co-Authored-By: Claude Sonnet 4.6 --- nest/core/decorators/filters.py | 44 +++++++ .../test_core/test_decorators/test_filters.py | 124 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 nest/core/decorators/filters.py create mode 100644 tests/test_core/test_decorators/test_filters.py diff --git a/nest/core/decorators/filters.py b/nest/core/decorators/filters.py new file mode 100644 index 0000000..5c09b22 --- /dev/null +++ b/nest/core/decorators/filters.py @@ -0,0 +1,44 @@ +from typing import Type + + +def Catch(*exception_types: Type[Exception]): + """Bind an ExceptionFilter class to one or more exception types. + + Usage:: + + @Catch(HttpException) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + ... + + An empty ``@Catch()`` means the filter catches every exception. + """ + def decorator(cls): + cls.__caught_exceptions__ = tuple(exception_types) + return cls + + return decorator + + +def UseFilters(*filters): + """Apply exception filters to a controller class or a route method. + + Filters are tried in declaration order. Pass filter classes *or* + pre-instantiated filter objects. + + Usage:: + + @Controller('/users') + @UseFilters(HttpExceptionFilter) # class-level + class UserController: + @Get('/:id') + @UseFilters(NotFoundFilter()) # method-level instance + def get_user(self, id: int): ... + """ + def decorator(obj): + existing = list(getattr(obj, '__filters__', [])) + existing.extend(filters) + obj.__filters__ = existing + return obj + + return decorator diff --git a/tests/test_core/test_decorators/test_filters.py b/tests/test_core/test_decorators/test_filters.py new file mode 100644 index 0000000..a0ce66f --- /dev/null +++ b/tests/test_core/test_decorators/test_filters.py @@ -0,0 +1,124 @@ +import pytest +from fastapi.responses import JSONResponse + +from nest.common.exceptions import ExceptionFilter, ArgumentsHost, HttpException, NotFoundException +from nest.core.decorators.filters import Catch, UseFilters + + +# --- @Catch tests --- + +def test_catch_stores_exception_types(): + @Catch(HttpException) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + assert MyFilter.__caught_exceptions__ == (HttpException,) + + +def test_catch_multiple_types(): + @Catch(ValueError, TypeError) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + assert MyFilter.__caught_exceptions__ == (ValueError, TypeError) + + +def test_catch_no_args_catches_all(): + @Catch() + class CatchAllFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + assert CatchAllFilter.__caught_exceptions__ == () + + +def test_catch_returns_the_class(): + @Catch(HttpException) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + assert issubclass(MyFilter, ExceptionFilter) + + +# --- @UseFilters on methods --- + +def test_use_filters_on_method(): + @Catch(HttpException) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + def my_handler(): + pass + + decorated = UseFilters(MyFilter)(my_handler) + assert MyFilter in decorated.__filters__ + + +def test_use_filters_with_instance(): + @Catch(HttpException) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + def my_handler(): + pass + + instance = MyFilter() + decorated = UseFilters(instance)(my_handler) + assert instance in decorated.__filters__ + + +def test_use_filters_multiple_on_method(): + @Catch(HttpException) + class FilterA(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + @Catch(ValueError) + class FilterB(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + def my_handler(): + pass + + decorated = UseFilters(FilterA, FilterB)(my_handler) + assert FilterA in decorated.__filters__ + assert FilterB in decorated.__filters__ + + +def test_use_filters_on_class(): + @Catch(HttpException) + class MyFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + @UseFilters(MyFilter) + class MyController: + pass + + assert MyFilter in MyController.__filters__ + + +def test_use_filters_preserves_existing_filters(): + @Catch(HttpException) + class FilterA(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + @Catch(ValueError) + class FilterB(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse(status_code=500, content={}) + + def my_handler(): + pass + + UseFilters(FilterA)(my_handler) + UseFilters(FilterB)(my_handler) + assert FilterA in my_handler.__filters__ + assert FilterB in my_handler.__filters__ From 892a944f499c9355d5454e22153085668d42b1d0 Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:33:29 +0300 Subject: [PATCH 3/7] feat: wrap CBV routes with route/controller-level exception filters Co-Authored-By: Claude Sonnet 4.6 --- nest/core/decorators/class_based_view.py | 61 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/nest/core/decorators/class_based_view.py b/nest/core/decorators/class_based_view.py index 18cf0b8..725fa90 100644 --- a/nest/core/decorators/class_based_view.py +++ b/nest/core/decorators/class_based_view.py @@ -16,7 +16,7 @@ get_type_hints, ) -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from starlette.routing import Route, WebSocketRoute T = TypeVar("T") @@ -43,6 +43,7 @@ def class_based_view(router: APIRouter, cls: Type[T]) -> Type[T]: for route in cbv_routes: router.routes.remove(route) _update_cbv_route_endpoint_signature(cls, route) + _wrap_route_with_filters(cls, route) cbv_router.routes.append(route) router.include_router(cbv_router) return cls @@ -110,4 +111,60 @@ def _update_cbv_route_endpoint_signature( for parameter in old_parameters[1:] ] new_signature = old_signature.replace(parameters=new_parameters) - setattr(route.endpoint, "__signature__", new_signature) \ No newline at end of file + setattr(route.endpoint, "__signature__", new_signature) + + +def _wrap_route_with_filters(cls: Type[Any], route: Union[Route, WebSocketRoute]) -> None: + """Wrap a route endpoint with controller/route-level exception filter logic. + + Called after _update_cbv_route_endpoint_signature so the wrapper inherits + the correct CBV __signature__ (with self as Depends(cls)). + """ + from nest.common.exceptions import ArgumentsHost + + route_filters = list(getattr(route.endpoint, "__filters__", [])) + controller_filters = list(getattr(cls, "__filters__", [])) + if not route_filters and not controller_filters: + return + + original_endpoint = route.endpoint + cbv_signature = getattr(original_endpoint, "__signature__", inspect.signature(original_endpoint)) + + # Inject `request: Request` into the wrapper signature so FastAPI provides it. + existing_params = list(cbv_signature.parameters.values()) + has_request = any(p.name == "request" for p in existing_params) + if not has_request: + request_param = inspect.Parameter( + "request", + inspect.Parameter.KEYWORD_ONLY, + annotation=Request, + ) + wrapper_signature = cbv_signature.replace(parameters=existing_params + [request_param]) + else: + wrapper_signature = cbv_signature + + orig_param_names = {p.name for p in existing_params} + + async def filter_wrapper(*args, **kwargs): + request = kwargs.get("request") + call_kwargs = {k: v for k, v in kwargs.items() if k in orig_param_names} + try: + result = original_endpoint(*args, **call_kwargs) + if inspect.isawaitable(result): + result = await result + return result + except Exception as exc: + host = ArgumentsHost(request=request) + for raw_filter in route_filters + controller_filters: + f = raw_filter() if isinstance(raw_filter, type) else raw_filter + caught = getattr(f, "__caught_exceptions__", ()) + if not caught or isinstance(exc, caught): + result = f.catch(exc, host) + if inspect.isawaitable(result): + return await result + return result + raise + + filter_wrapper.__name__ = getattr(original_endpoint, "__name__", "filter_wrapper") + filter_wrapper.__signature__ = wrapper_signature + route.endpoint = filter_wrapper From fa928fedf656ca828ed23e333f6367d75dc70b3f Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:33:48 +0300 Subject: [PATCH 4/7] feat: add use_global_filters() to PyNestApp Co-Authored-By: Claude Sonnet 4.6 --- nest/core/pynest_application.py | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/nest/core/pynest_application.py b/nest/core/pynest_application.py index 22e2669..1533ae6 100644 --- a/nest/core/pynest_application.py +++ b/nest/core/pynest_application.py @@ -1,6 +1,8 @@ +import inspect from typing import Any -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from nest.common.route_resolver import RoutesResolver from nest.core.pynest_app_context import PyNestApplicationContext @@ -62,3 +64,44 @@ def register_routes(self): Register the routes using the RoutesResolver. """ self.routes_resolver.register_routes() + + def use_global_filters(self, *filters) -> "PyNestApp": + """Register one or more exception filters that apply to every route. + + Filters are tried in the order provided. Each filter must be an + *instance* of an ExceptionFilter subclass decorated with @Catch. + + Args: + *filters: ExceptionFilter instances to register globally. + + Returns: + PyNestApp: The current instance (allows method chaining). + + Example:: + + app = PyNestFactory.create(AppModule) + app.use_global_filters(AllExceptionsFilter()) + """ + for f in filters: + caught = getattr(f, "__caught_exceptions__", None) + if caught is None: + raise ValueError( + f"{type(f).__name__} must be decorated with @Catch to use as a global filter" + ) + exc_types = caught if caught else (Exception,) + for exc_type in exc_types: + self._register_global_handler(exc_type, f) + return self + + def _register_global_handler(self, exc_type: type, filter_instance) -> None: + async def handler(request: Request, exc: Exception): + result = filter_instance.catch(exc, None) + if inspect.isawaitable(result): + result = await result + if result is None: + return JSONResponse( + status_code=500, content={"message": "Internal server error"} + ) + return result + + self.http_server.add_exception_handler(exc_type, handler) From e77b6dd83a7782acc3adf325020e5f31cc258c98 Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:34:09 +0300 Subject: [PATCH 5/7] feat: export exception filter symbols from nest.common and nest.core Co-Authored-By: Claude Sonnet 4.6 --- nest/common/__init__.py | 14 ++++++++++++++ nest/core/__init__.py | 2 ++ nest/core/decorators/__init__.py | 1 + 3 files changed, 17 insertions(+) diff --git a/nest/common/__init__.py b/nest/common/__init__.py index e69de29..6f1c1a7 100644 --- a/nest/common/__init__.py +++ b/nest/common/__init__.py @@ -0,0 +1,14 @@ +from nest.common.exceptions import ( + HttpException, + BadRequestException, + UnauthorizedException, + ForbiddenException, + NotFoundException, + MethodNotAllowedException, + ConflictException, + UnprocessableEntityException, + InternalServerErrorException, + ExceptionFilter, + ArgumentsHost, + HttpArgumentsHost, +) diff --git a/nest/core/__init__.py b/nest/core/__init__.py index e2dce6e..8e1b620 100644 --- a/nest/core/__init__.py +++ b/nest/core/__init__.py @@ -1,6 +1,7 @@ from fastapi import Depends from nest.core.decorators import ( + Catch, Controller, Delete, Get, @@ -10,6 +11,7 @@ Patch, Post, Put, + UseFilters, ) from nest.core.decorators.guards import BaseGuard, UseGuards from nest.core.pynest_application import PyNestApp diff --git a/nest/core/decorators/__init__.py b/nest/core/decorators/__init__.py index 1ed7489..c152132 100644 --- a/nest/core/decorators/__init__.py +++ b/nest/core/decorators/__init__.py @@ -1,4 +1,5 @@ from nest.core.decorators.controller import Controller +from nest.core.decorators.filters import Catch, UseFilters from nest.core.decorators.http_code import HttpCode from nest.core.decorators.http_method import Delete, Get, Patch, Post, Put from nest.core.decorators.injectable import Injectable From 247b86fb2a48e3f1e69e4c05fc9322dc9bc8023a Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:35:05 +0300 Subject: [PATCH 6/7] test: add integration tests for exception filters (11 scenarios) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 + .../test_exception_filters_integration.py | 379 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 tests/test_core/test_exception_filters_integration.py diff --git a/pyproject.toml b/pyproject.toml index 83c86b8..a59927d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ test = [ [dependency-groups] test = [ "pytest>=7.0.1,<8.0.0", + "httpx>=0.27.0,<1.0.0", "sqlalchemy>=2.0.36,<3.0.0", "motor>=3.2.0,<4.0.0", "beanie>=1.27.0,<2.0.0", diff --git a/tests/test_core/test_exception_filters_integration.py b/tests/test_core/test_exception_filters_integration.py new file mode 100644 index 0000000..9a172ec --- /dev/null +++ b/tests/test_core/test_exception_filters_integration.py @@ -0,0 +1,379 @@ +"""Integration tests for exception filters using TestClient.""" +import pytest +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from nest.common.exceptions import ( + ArgumentsHost, + ExceptionFilter, + HttpException, + NotFoundException, + BadRequestException, + InternalServerErrorException, +) +from nest.core import ( + Controller, + Get, + Module, + Injectable, + PyNestFactory, +) +from nest.core.decorators.filters import Catch, UseFilters + + +# --------------------------------------------------------------------------- +# Shared filter definitions +# --------------------------------------------------------------------------- + +@Catch(HttpException) +class HttpExceptionFilter(ExceptionFilter): + async def catch(self, exception: HttpException, host: ArgumentsHost): + return JSONResponse( + status_code=exception.status_code, + content={"statusCode": exception.status_code, "message": exception.message, "source": "HttpExceptionFilter"}, + ) + + +@Catch(ValueError) +class ValueErrorFilter(ExceptionFilter): + async def catch(self, exception: ValueError, host: ArgumentsHost): + return JSONResponse( + status_code=400, + content={"statusCode": 400, "message": str(exception), "source": "ValueErrorFilter"}, + ) + + +@Catch() +class AllExceptionsFilter(ExceptionFilter): + async def catch(self, exception: Exception, host: ArgumentsHost): + return JSONResponse( + status_code=500, + content={"statusCode": 500, "message": "all exceptions caught", "source": "AllExceptionsFilter"}, + ) + + +# --------------------------------------------------------------------------- +# Test 1: Global filter catches HttpException +# --------------------------------------------------------------------------- + +@Injectable +class NoopService1: + pass + + +@Controller("/t1") +class T1Controller: + def __init__(self, svc: NoopService1): + self.svc = svc + + @Get("/not-found") + def raise_not_found(self): + raise NotFoundException("resource missing") + + @Get("/ok") + def ok(self): + return {"status": "ok"} + + +@Module(controllers=[T1Controller], providers=[NoopService1]) +class T1Module: + pass + + +def test_global_http_exception_filter(): + app = PyNestFactory.create(T1Module) + app.use_global_filters(HttpExceptionFilter()) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t1/not-found") + assert resp.status_code == 404 + body = resp.json() + assert body["message"] == "resource missing" + assert body["source"] == "HttpExceptionFilter" + + +def test_global_filter_does_not_affect_ok_routes(): + app = PyNestFactory.create(T1Module) + app.use_global_filters(HttpExceptionFilter()) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t1/ok") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Test 2: Controller-level @UseFilters +# --------------------------------------------------------------------------- + +@Injectable +class NoopService2: + pass + + +@Controller("/t2") +@UseFilters(HttpExceptionFilter) +class T2Controller: + def __init__(self, svc: NoopService2): + self.svc = svc + + @Get("/not-found") + def raise_not_found(self): + raise NotFoundException("t2 not found") + + @Get("/ok") + def ok(self): + return {"status": "ok"} + + +@Module(controllers=[T2Controller], providers=[NoopService2]) +class T2Module: + pass + + +def test_controller_level_filter_catches_http_exception(): + app = PyNestFactory.create(T2Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t2/not-found") + assert resp.status_code == 404 + body = resp.json() + assert body["source"] == "HttpExceptionFilter" + + +def test_controller_level_filter_ok_route_unaffected(): + app = PyNestFactory.create(T2Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t2/ok") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Test 3: Route-level @UseFilters takes priority over controller-level +# --------------------------------------------------------------------------- + +@Injectable +class NoopService3: + pass + + +@Catch(NotFoundException) +class NotFoundFilter(ExceptionFilter): + async def catch(self, exception, host): + return JSONResponse( + status_code=404, + content={"statusCode": 404, "message": exception.message, "source": "NotFoundFilter"}, + ) + + +@Controller("/t3") +@UseFilters(HttpExceptionFilter) # controller-level: handles all HttpException +class T3Controller: + def __init__(self, svc: NoopService3): + self.svc = svc + + @Get("/not-found") + @UseFilters(NotFoundFilter) # route-level: more specific, takes priority + def raise_not_found(self): + raise NotFoundException("t3 not found") + + @Get("/bad-request") + def raise_bad_request(self): + raise BadRequestException("t3 bad request") + + +@Module(controllers=[T3Controller], providers=[NoopService3]) +class T3Module: + pass + + +def test_route_level_filter_takes_priority_over_controller(): + app = PyNestFactory.create(T3Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t3/not-found") + assert resp.status_code == 404 + assert resp.json()["source"] == "NotFoundFilter" + + +def test_controller_level_filter_handles_unmatched_by_route(): + app = PyNestFactory.create(T3Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t3/bad-request") + assert resp.status_code == 400 + assert resp.json()["source"] == "HttpExceptionFilter" + + +# --------------------------------------------------------------------------- +# Test 4: Catch-all global filter (@Catch()) +# --------------------------------------------------------------------------- + +@Injectable +class NoopService4: + pass + + +@Controller("/t4") +class T4Controller: + def __init__(self, svc: NoopService4): + self.svc = svc + + @Get("/runtime-error") + def raise_runtime(self): + raise RuntimeError("unexpected failure") + + +@Module(controllers=[T4Controller], providers=[NoopService4]) +class T4Module: + pass + + +def test_global_catch_all_filter(): + app = PyNestFactory.create(T4Module) + app.use_global_filters(AllExceptionsFilter()) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t4/runtime-error") + assert resp.status_code == 500 + assert resp.json()["source"] == "AllExceptionsFilter" + + +# --------------------------------------------------------------------------- +# Test 5: Filter does not catch non-matching exception type +# --------------------------------------------------------------------------- + +@Injectable +class NoopService5: + pass + + +@Controller("/t5") +@UseFilters(ValueErrorFilter) # only catches ValueError +class T5Controller: + def __init__(self, svc: NoopService5): + self.svc = svc + + @Get("/value-error") + def raise_value_error(self): + raise ValueError("bad value") + + @Get("/runtime-error") + def raise_runtime_error(self): + raise RuntimeError("unexpected") + + +@Module(controllers=[T5Controller], providers=[NoopService5]) +class T5Module: + pass + + +def test_filter_catches_matching_exception(): + app = PyNestFactory.create(T5Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t5/value-error") + assert resp.status_code == 400 + assert resp.json()["source"] == "ValueErrorFilter" + + +def test_filter_ignores_non_matching_exception(): + app = PyNestFactory.create(T5Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + # RuntimeError is not caught by ValueErrorFilter → propagates → 500 + resp = client.get("/t5/runtime-error") + assert resp.status_code == 500 + + +# --------------------------------------------------------------------------- +# Test 6: ArgumentsHost provides request access in filter +# --------------------------------------------------------------------------- + +@Injectable +class NoopService6: + pass + + +@Catch(NotFoundException) +class RequestAwareFilter(ExceptionFilter): + async def catch(self, exception: NotFoundException, host: ArgumentsHost): + http = host.switch_to_http() + request = http.get_request() + path = request.url.path if request else "unknown" + return JSONResponse( + status_code=404, + content={"path": path, "message": exception.message}, + ) + + +@Controller("/t6") +@UseFilters(RequestAwareFilter) +class T6Controller: + def __init__(self, svc: NoopService6): + self.svc = svc + + @Get("/item") + def get_item(self): + raise NotFoundException("item not found") + + +@Module(controllers=[T6Controller], providers=[NoopService6]) +class T6Module: + pass + + +def test_filter_receives_request_via_arguments_host(): + app = PyNestFactory.create(T6Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t6/item") + assert resp.status_code == 404 + body = resp.json() + assert body["path"] == "/t6/item" + assert body["message"] == "item not found" + + +# --------------------------------------------------------------------------- +# Test 7: Async filter works +# --------------------------------------------------------------------------- + +@Injectable +class NoopService7: + pass + + +@Catch(HttpException) +class AsyncHttpFilter(ExceptionFilter): + async def catch(self, exception: HttpException, host: ArgumentsHost): + import asyncio + await asyncio.sleep(0) # prove it's awaited + return JSONResponse( + status_code=exception.status_code, + content={"message": exception.message, "source": "AsyncHttpFilter"}, + ) + + +@Controller("/t7") +@UseFilters(AsyncHttpFilter) +class T7Controller: + def __init__(self, svc: NoopService7): + self.svc = svc + + @Get("/error") + def raise_error(self): + raise InternalServerErrorException("server boom") + + +@Module(controllers=[T7Controller], providers=[NoopService7]) +class T7Module: + pass + + +def test_async_filter_is_awaited(): + app = PyNestFactory.create(T7Module) + client = TestClient(app.get_server(), raise_server_exceptions=False) + + resp = client.get("/t7/error") + assert resp.status_code == 500 + assert resp.json()["source"] == "AsyncHttpFilter" From 488b60bb4b737cadc822a46da194b689486723fd Mon Sep 17 00:00:00 2001 From: Itay Dar <118370953+ItayTheDar@users.noreply.github.com> Date: Thu, 7 May 2026 21:35:42 +0300 Subject: [PATCH 7/7] docs: add exception filters documentation page Co-Authored-By: Claude Sonnet 4.6 --- docs/exception_filters.md | 304 ++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 305 insertions(+) create mode 100644 docs/exception_filters.md diff --git a/docs/exception_filters.md b/docs/exception_filters.md new file mode 100644 index 0000000..928e4d7 --- /dev/null +++ b/docs/exception_filters.md @@ -0,0 +1,304 @@ +# Exception Filters + +Exception Filters give you a centralized, composable way to catch and transform +errors into consistent HTTP responses. Without filters, every route handler needs +its own `try/except`; filters let you declare error handling once and apply it +at route, controller, or global scope. + +## Quick Start + +```python +from fastapi.responses import JSONResponse +from nest.common.exceptions import ExceptionFilter, ArgumentsHost, HttpException +from nest.core.decorators.filters import Catch, UseFilters +from nest.core import Controller, Get + +@Catch(HttpException) +class HttpExceptionFilter(ExceptionFilter): + async def catch(self, exception: HttpException, host: ArgumentsHost): + return JSONResponse( + status_code=exception.status_code, + content={"statusCode": exception.status_code, "message": exception.message}, + ) + +@Controller("/users") +@UseFilters(HttpExceptionFilter) +class UserController: + @Get("/{user_id}") + def get_user(self, user_id: int): + raise NotFoundException(f"User {user_id} not found") +``` + +Visiting `/users/42` returns: + +```json +{"statusCode": 404, "message": "User 42 not found"} +``` + +--- + +## Built-in HTTP Exceptions + +All exceptions are importable from `nest.common.exceptions` (or `nest.common`): + +| Class | Status Code | Default Message | +|-------|-------------|-----------------| +| `HttpException` | (any) | `"Internal Server Error"` | +| `BadRequestException` | 400 | `"Bad Request"` | +| `UnauthorizedException` | 401 | `"Unauthorized"` | +| `ForbiddenException` | 403 | `"Forbidden"` | +| `NotFoundException` | 404 | `"Not Found"` | +| `MethodNotAllowedException` | 405 | `"Method Not Allowed"` | +| `ConflictException` | 409 | `"Conflict"` | +| `UnprocessableEntityException` | 422 | `"Unprocessable Entity"` | +| `InternalServerErrorException` | 500 | `"Internal Server Error"` | + +All accept an optional `message` argument: + +```python +raise NotFoundException("User 42 not found") +raise HttpException(message="Custom error", status_code=418) +``` + +--- + +## ExceptionFilter Base Class + +Subclass `ExceptionFilter` and decorate your class with `@Catch`: + +```python +from nest.common.exceptions import ExceptionFilter, ArgumentsHost + +@Catch(HttpException) +class MyFilter(ExceptionFilter): + async def catch(self, exception: HttpException, host: ArgumentsHost): + return JSONResponse( + status_code=exception.status_code, + content={"error": exception.message}, + ) +``` + +- `@Catch(*exception_types)` — binds the filter to one or more exception types. + Pass no arguments (`@Catch()`) to match **every** exception. +- `catch(exception, host)` — can be `async def` or a regular `def`; PyNest awaits it automatically. + +--- + +## ArgumentsHost + +The `host` parameter passed to `catch()` gives access to request context: + +```python +async def catch(self, exception, host: ArgumentsHost): + http = host.switch_to_http() + request = http.get_request() # starlette Request object (or None) + print(request.url.path) +``` + +| Method | Returns | +|--------|---------| +| `host.switch_to_http()` | `HttpArgumentsHost` | +| `host.get_type()` | `"http"` | +| `http_host.get_request()` | `Request` \| `None` | + +--- + +## @UseFilters Decorator + +Apply filters at **route method** or **controller class** scope: + +```python +from nest.core.decorators.filters import UseFilters + +@Controller("/items") +@UseFilters(HttpExceptionFilter) # ① controller scope — all routes +class ItemController: + + @Get("/") + def list_items(self): + raise NotFoundException("empty") + + @Delete("/{id}") + @UseFilters(ConflictFilter) # ② route scope — this route only + def delete_item(self, id: int): + raise ConflictException("already deleted") +``` + +Pass filter classes **or** pre-created instances: + +```python +@UseFilters(HttpExceptionFilter) # class — instantiated per request +@UseFilters(HttpExceptionFilter()) # instance — shared across requests +``` + +--- + +## Global Filters + +Register filters that apply to every route in the application: + +```python +app = PyNestFactory.create(AppModule) +app.use_global_filters(AllExceptionsFilter()) +``` + +Multiple global filters are tried in the order they are registered: + +```python +app.use_global_filters(HttpExceptionFilter(), AllExceptionsFilter()) +``` + +`use_global_filters()` returns the app instance for chaining: + +```python +app = PyNestFactory.create(AppModule) +app.use(CORSMiddleware, allow_origins=["*"]).use_global_filters(AllExceptionsFilter()) +``` + +--- + +## Filter Resolution Order + +When an exception is raised, PyNest checks filters in this priority: + +1. **Route-level** `@UseFilters` — most specific, checked first +2. **Controller-level** `@UseFilters` +3. **Global filters** via `app.use_global_filters()` +4. **Framework default** — FastAPI's built-in 500 response + +The first filter whose `@Catch` types match the exception handles it; the rest are skipped. + +--- + +## Catch-All Filter + +`@Catch()` with no arguments catches every exception: + +```python +@Catch() +class AllExceptionsFilter(ExceptionFilter): + async def catch(self, exception: Exception, host: ArgumentsHost): + return JSONResponse( + status_code=500, + content={"message": "Internal server error"}, + ) + +app.use_global_filters(AllExceptionsFilter()) +``` + +--- + +## Async Filters + +`catch()` can be an `async def`; PyNest awaits it automatically: + +```python +@Catch(HttpException) +class LoggingFilter(ExceptionFilter): + async def catch(self, exception: HttpException, host: ArgumentsHost): + await log_to_database(exception) # async I/O is fine + return JSONResponse( + status_code=exception.status_code, + content={"message": exception.message}, + ) +``` + +--- + +## Combining Filters + +Use multiple filters at the same scope to handle different exception families: + +```python +@Controller("/orders") +@UseFilters(HttpExceptionFilter, ValidationFilter) +class OrderController: + ... +``` + +They are tried in order; the first matching filter wins. + +--- + +## Testing Filters in Isolation + +Test a filter directly without spinning up the full app: + +```python +import pytest +from fastapi import Request +from nest.common.exceptions import ArgumentsHost, NotFoundException + +@pytest.mark.asyncio +async def test_http_exception_filter_returns_correct_shape(): + scope = {"type": "http", "method": "GET", "path": "/test", + "query_string": b"", "headers": [], "http_version": "1.1"} + request = Request(scope=scope) + host = ArgumentsHost(request=request) + + f = HttpExceptionFilter() + exc = NotFoundException("item missing") + response = await f.catch(exc, host) + + assert response.status_code == 404 + assert response.body == b'{"statusCode":404,"message":"item missing"}' +``` + +--- + +## Full Example + +```python +from fastapi.responses import JSONResponse +from nest.common.exceptions import ( + ExceptionFilter, ArgumentsHost, + HttpException, NotFoundException, +) +from nest.core import Controller, Get, Injectable, Module, PyNestFactory +from nest.core.decorators.filters import Catch, UseFilters + + +@Catch(HttpException) +class HttpExceptionFilter(ExceptionFilter): + async def catch(self, exception: HttpException, host: ArgumentsHost): + return JSONResponse( + status_code=exception.status_code, + content={"statusCode": exception.status_code, "message": exception.message}, + ) + + +@Catch() +class FallbackFilter(ExceptionFilter): + async def catch(self, exception: Exception, host: ArgumentsHost): + return JSONResponse(status_code=500, content={"message": "Unexpected error"}) + + +@Injectable +class UserService: + def get_user(self, user_id: int): + if user_id != 1: + raise NotFoundException(f"User {user_id} not found") + return {"id": 1, "name": "Alice"} + + +@Controller("/users") +@UseFilters(HttpExceptionFilter) +class UserController: + def __init__(self, user_service: UserService): + self.user_service = user_service + + @Get("/{user_id}") + def get_user(self, user_id: int): + return self.user_service.get_user(user_id) + + +@Module(controllers=[UserController], providers=[UserService]) +class AppModule: + pass + + +app = PyNestFactory.create(AppModule) +app.use_global_filters(FallbackFilter()) + +http_server = app.get_server() +``` diff --git a/mkdocs.yml b/mkdocs.yml index b12e051..72f0903 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,7 @@ nav: - Controllers: controllers.md - Providers: providers.md - Guards: guards.md + - Exception Filters: exception_filters.md - Dependency Injection: dependency_injection.md - Deployment: - Docker: docker.md