diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 3d3cf30..714901b 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -360,7 +360,7 @@ SOFTWARE. ``` -## aignostics-foundry-core (0.7.1) - MIT License +## aignostics-foundry-core (0.8.0) - MIT License 🏭 Foundational infrastructure for Foundry components. diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 39cfe73..0a3d186 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -139,7 +139,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - `build_versioned_api_tags(version_name, *, context=None)` — OpenAPI tags for a single versioned sub-app; reads `repository_url` from *context* - `build_root_api_tags(base_url, versions)` — OpenAPI tags for the root app linking to each version's docs - `get_versioned_api_instances(versions, *, context=None)` — loads project modules (resolved via context), calls `build_api_metadata(context=ctx)` to configure each `FastAPI` instance, routes registered `VersionedAPIRouter` instances to the matching version - - `init_api(root_path, lifespan, exception_handler_registrations, versions=None, version_exception_handler_registrations=None, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered; when *versions* is supplied, calls `get_versioned_api_instances` internally, optionally applies *version_exception_handler_registrations* to each sub-app, and mounts them at `/{version}` on the root app + - `init_api(root_path, lifespan, exception_handler_registrations, versions=None, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered; when *versions* is supplied, calls `get_versioned_api_instances` internally and mounts each sub-app at `/{version}` on the root app; all handlers (custom + standard) are registered on **every** app instance (root and each versioned sub-app) so that mounted sub-apps handle exceptions correctly - **Location**: `aignostics_foundry_core/api/core.py` - **Dependencies**: `fastapi>=0.110,<1` (mandatory); `aignostics_foundry_core.di` (`load_modules`) - **Import**: `from aignostics_foundry_core.api.core import VersionedAPIRouter, init_api, build_api_metadata, …` or `from aignostics_foundry_core.api import …` diff --git a/src/aignostics_foundry_core/api/core.py b/src/aignostics_foundry_core/api/core.py index 291326e..ff5e4ee 100644 --- a/src/aignostics_foundry_core/api/core.py +++ b/src/aignostics_foundry_core/api/core.py @@ -390,7 +390,6 @@ def init_api( lifespan: Any | None = None, # noqa: ANN401 exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None, versions: list[str] | None = None, - version_exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None, **fastapi_kwargs: Any, # noqa: ANN401 ) -> FastAPI: """Initialise a FastAPI application with standard exception handlers. @@ -398,21 +397,25 @@ def init_api( This is a generic factory that creates a ``FastAPI`` instance and registers the standard Foundry exception handlers. When *versions* is supplied the function also creates versioned sub-applications via - ``get_versioned_api_instances``, optionally applies per-version exception - handlers, and mounts each sub-app at ``/{version}`` on the root app. + ``get_versioned_api_instances`` and mounts each sub-app at ``/{version}`` + on the root app. + + All exception handlers — both the custom ``exception_handler_registrations`` + entries and the 4 standard handlers (``ApiException``, + ``RequestValidationError``, ``ValidationError``, ``Exception``) — are + registered on the root app **and** on every versioned sub-app. This is + necessary because FastAPI mounted sub-apps handle exceptions independently; + the root app's handlers never fire for requests matched inside a sub-app. Args: root_path: ASGI root path (useful for reverse-proxy setups). lifespan: Optional async context manager for application lifespan. exception_handler_registrations: Additional ``(exc_class, handler)`` pairs - to register before the standard handlers. + to register on all app instances before the standard handlers. versions: Optional list of API version names (e.g. ``["v1", "v2"]``). When provided, ``get_versioned_api_instances`` is called internally and each resulting sub-app is mounted at ``/{version}`` on the root app. - version_exception_handler_registrations: ``(exc_class, handler)`` pairs - to register on *every* versioned sub-app before mounting. Only used - when *versions* is also provided. **fastapi_kwargs: Extra keyword arguments forwarded to ``FastAPI()``. Returns: @@ -437,8 +440,19 @@ def init_api( if versions: versioned_apps = get_versioned_api_instances(versions) for version_name, version_app in versioned_apps.items(): - for exc_class, handler in version_exception_handler_registrations or []: + for exc_class, handler in exception_handler_registrations or []: version_app.add_exception_handler(exc_class_or_status_code=exc_class, handler=handler) + version_app.add_exception_handler( # type: ignore[arg-type] + exc_class_or_status_code=ApiException, + handler=api_exception_handler, # pyright: ignore[reportArgumentType] + ) + version_app.add_exception_handler( + exc_class_or_status_code=RequestValidationError, handler=validation_exception_handler + ) + version_app.add_exception_handler( + exc_class_or_status_code=ValidationError, handler=validation_exception_handler + ) + version_app.add_exception_handler(exc_class_or_status_code=Exception, handler=unhandled_exception_handler) api.mount(f"/{version_name}", version_app) return api diff --git a/tests/aignostics_foundry_core/api/core_test.py b/tests/aignostics_foundry_core/api/core_test.py index a90955f..5b187ca 100644 --- a/tests/aignostics_foundry_core/api/core_test.py +++ b/tests/aignostics_foundry_core/api/core_test.py @@ -293,10 +293,10 @@ def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, FastAPI]: # @pytest.mark.unit -def test_init_api_applies_version_exception_handlers(monkeypatch: pytest.MonkeyPatch) -> None: - """init_api applies version_exception_handler_registrations to each versioned sub-app.""" +def test_init_api_propagates_custom_exception_handlers_to_versioned_apps(monkeypatch: pytest.MonkeyPatch) -> None: + """init_api registers exception_handler_registrations on each versioned sub-app.""" from typing import Any - from unittest.mock import MagicMock + from unittest.mock import MagicMock, call import aignostics_foundry_core.api.core as core_module from aignostics_foundry_core.api.core import init_api @@ -311,10 +311,50 @@ def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, MagicMock]: def my_handler(request: object, exc: Exception) -> None: ... - init_api(versions=[VERSION_V1, VERSION_V2], version_exception_handler_registrations=[(ValueError, my_handler)]) + init_api(versions=[VERSION_V1, VERSION_V2], exception_handler_registrations=[(ValueError, my_handler)]) - stub_v1.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler) - stub_v2.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler) + custom_call = call(exc_class_or_status_code=ValueError, handler=my_handler) + assert custom_call in stub_v1.add_exception_handler.call_args_list + assert custom_call in stub_v2.add_exception_handler.call_args_list + + +@pytest.mark.unit +def test_init_api_registers_standard_handlers_on_versioned_apps(monkeypatch: pytest.MonkeyPatch) -> None: + """init_api registers the 4 standard handlers on each versioned sub-app.""" + from typing import Any + from unittest.mock import MagicMock, call + + from fastapi.exceptions import RequestValidationError + from pydantic import ValidationError + + import aignostics_foundry_core.api.core as core_module + from aignostics_foundry_core.api.core import ( + ApiException, + api_exception_handler, + init_api, + unhandled_exception_handler, + validation_exception_handler, + ) + + stub_v1 = MagicMock() + stub_v2 = MagicMock() + + def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, MagicMock]: # noqa: ANN401 + return {VERSION_V1: stub_v1, VERSION_V2: stub_v2} + + monkeypatch.setattr(core_module, "get_versioned_api_instances", fake_get_versioned) + + init_api(versions=[VERSION_V1, VERSION_V2]) + + expected_calls = [ + call(exc_class_or_status_code=ApiException, handler=api_exception_handler), + call(exc_class_or_status_code=RequestValidationError, handler=validation_exception_handler), + call(exc_class_or_status_code=ValidationError, handler=validation_exception_handler), + call(exc_class_or_status_code=Exception, handler=unhandled_exception_handler), + ] + for expected in expected_calls: + assert expected in stub_v1.add_exception_handler.call_args_list + assert expected in stub_v2.add_exception_handler.call_args_list @pytest.mark.unit