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
2 changes: 1 addition & 1 deletion ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion src/aignostics_foundry_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 …`
Expand Down
30 changes: 22 additions & 8 deletions src/aignostics_foundry_core/api/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,29 +390,32 @@ 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.

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:
Expand All @@ -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
52 changes: 46 additions & 6 deletions tests/aignostics_foundry_core/api/core_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading