Skip to content

Commit aa935d9

Browse files
olivermeyerclaude
andcommitted
feat(api): add versions param to init_api()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 52a1c27 commit aa935d9

3 files changed

Lines changed: 91 additions & 4 deletions

File tree

src/aignostics_foundry_core/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
125125
- `build_versioned_api_tags(version_name, repository_url)` — OpenAPI tags for a single versioned sub-app
126126
- `build_root_api_tags(base_url, versions)` — OpenAPI tags for the root app linking to each version's docs
127127
- `get_versioned_api_instances(versions, build_metadata=None, *, context=None)` — loads project modules (resolved via context), creates one `FastAPI` per version, routes registered `VersionedAPIRouter` instances to the matching version
128-
- `init_api(root_path, lifespan, exception_handler_registrations, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered
128+
- `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
129129
- **Location**: `aignostics_foundry_core/api/core.py`
130130
- **Dependencies**: `fastapi>=0.110,<1` (mandatory); `aignostics_foundry_core.di` (`load_modules`)
131131
- **Import**: `from aignostics_foundry_core.api.core import VersionedAPIRouter, init_api, build_api_metadata, …` or `from aignostics_foundry_core.api import …`

src/aignostics_foundry_core/api/core.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,20 +394,30 @@ def init_api(
394394
root_path: str = "",
395395
lifespan: Any | None = None, # noqa: ANN401
396396
exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None,
397+
versions: list[str] | None = None,
398+
version_exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None,
397399
**fastapi_kwargs: Any, # noqa: ANN401
398400
) -> FastAPI:
399401
"""Initialise a FastAPI application with standard exception handlers.
400402
401403
This is a generic factory that creates a ``FastAPI`` instance and registers
402-
the standard Foundry exception handlers. Versioned sub-application mounting
403-
(Bridge-specific) is left to the caller; use ``get_versioned_api_instances``
404-
and ``FastAPI.mount`` for that pattern.
404+
the standard Foundry exception handlers. When *versions* is supplied the
405+
function also creates versioned sub-applications via
406+
``get_versioned_api_instances``, optionally applies per-version exception
407+
handlers, and mounts each sub-app at ``/{version}`` on the root app.
405408
406409
Args:
407410
root_path: ASGI root path (useful for reverse-proxy setups).
408411
lifespan: Optional async context manager for application lifespan.
409412
exception_handler_registrations: Additional ``(exc_class, handler)`` pairs
410413
to register before the standard handlers.
414+
versions: Optional list of API version names (e.g. ``["v1", "v2"]``).
415+
When provided, ``get_versioned_api_instances`` is called internally
416+
and each resulting sub-app is mounted at ``/{version}`` on the root
417+
app.
418+
version_exception_handler_registrations: ``(exc_class, handler)`` pairs
419+
to register on *every* versioned sub-app before mounting. Only used
420+
when *versions* is also provided.
411421
**fastapi_kwargs: Extra keyword arguments forwarded to ``FastAPI()``.
412422
413423
Returns:
@@ -429,4 +439,11 @@ def init_api(
429439
api.add_exception_handler(exc_class_or_status_code=ValidationError, handler=validation_exception_handler)
430440
api.add_exception_handler(exc_class_or_status_code=Exception, handler=unhandled_exception_handler)
431441

442+
if versions:
443+
versioned_apps = get_versioned_api_instances(versions)
444+
for version_name, version_app in versioned_apps.items():
445+
for exc_class, handler in version_exception_handler_registrations or []:
446+
version_app.add_exception_handler(exc_class_or_status_code=exc_class, handler=handler)
447+
api.mount(f"/{version_name}", version_app)
448+
432449
return api

tests/aignostics_foundry_core/api/core_test.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,73 @@ def handler(request: object, exc: Exception) -> None:
244244

245245
assert isinstance(app, FastAPI)
246246
assert ValueError in app.exception_handlers
247+
248+
249+
VERSION_V1 = "v1"
250+
VERSION_V2 = "v2"
251+
MOUNT_PATH_V1 = "/v1"
252+
MOUNT_PATH_V2 = "/v2"
253+
254+
255+
@pytest.mark.unit
256+
def test_init_api_mounts_versioned_apps(monkeypatch: pytest.MonkeyPatch) -> None:
257+
"""init_api mounts each versioned sub-app at /{version} on the root app."""
258+
from typing import Any
259+
260+
from fastapi import FastAPI
261+
from starlette.routing import Mount
262+
263+
import aignostics_foundry_core.api.core as core_module
264+
from aignostics_foundry_core.api.core import init_api
265+
266+
stub_v1 = FastAPI()
267+
stub_v2 = FastAPI()
268+
269+
def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, FastAPI]: # noqa: ANN401
270+
return {VERSION_V1: stub_v1, VERSION_V2: stub_v2}
271+
272+
monkeypatch.setattr(core_module, "get_versioned_api_instances", fake_get_versioned)
273+
274+
app = init_api(versions=[VERSION_V1, VERSION_V2])
275+
276+
mount_paths = [r.path for r in app.routes if isinstance(r, Mount)]
277+
assert MOUNT_PATH_V1 in mount_paths
278+
assert MOUNT_PATH_V2 in mount_paths
279+
280+
281+
@pytest.mark.unit
282+
def test_init_api_applies_version_exception_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
283+
"""init_api applies version_exception_handler_registrations to each versioned sub-app."""
284+
from typing import Any
285+
from unittest.mock import MagicMock
286+
287+
import aignostics_foundry_core.api.core as core_module
288+
from aignostics_foundry_core.api.core import init_api
289+
290+
stub_v1 = MagicMock()
291+
stub_v2 = MagicMock()
292+
293+
def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, MagicMock]: # noqa: ANN401
294+
return {VERSION_V1: stub_v1, VERSION_V2: stub_v2}
295+
296+
monkeypatch.setattr(core_module, "get_versioned_api_instances", fake_get_versioned)
297+
298+
def my_handler(request: object, exc: Exception) -> None: ...
299+
300+
init_api(versions=[VERSION_V1, VERSION_V2], version_exception_handler_registrations=[(ValueError, my_handler)])
301+
302+
stub_v1.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler)
303+
stub_v2.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler)
304+
305+
306+
@pytest.mark.unit
307+
def test_init_api_without_versions_unchanged() -> None:
308+
"""init_api without versions adds no Mount routes (backward compatibility)."""
309+
from starlette.routing import Mount
310+
311+
from aignostics_foundry_core.api.core import init_api
312+
313+
app = init_api()
314+
315+
mount_routes = [r for r in app.routes if isinstance(r, Mount)]
316+
assert mount_routes == []

0 commit comments

Comments
 (0)