fastapi-router-versioning 1.0.2__tar.gz → 1.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/PKG-INFO +1 -1
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/fastapi_router_versioning/__init__.py +1 -1
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/fastapi_router_versioning/versioner.py +38 -25
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/release-notes.md +19 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_calver.py +10 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_semver.py +10 -0
- fastapi_router_versioning-1.0.3/tests/test_versioner.py +373 -0
- fastapi_router_versioning-1.0.3/tests/test_versioner_docs.py +559 -0
- fastapi_router_versioning-1.0.3/tests/test_versioner_errors.py +200 -0
- fastapi_router_versioning-1.0.3/tests/test_versioner_lifecycle.py +223 -0
- fastapi_router_versioning-1.0.3/tests/test_versioner_versions_route.py +110 -0
- fastapi_router_versioning-1.0.3/tests/test_versioner_webhooks.py +220 -0
- fastapi_router_versioning-1.0.2/tests/test_versioner.py +0 -1482
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/dependabot.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/labeler.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/bump-pre-commit-hooks.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/create-draft-release.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/detect-conflicts.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/guard-dependencies.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/labeler.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/pre-commit.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/prepare-release.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/publish.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/test-redistribute.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/test.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/zizmor.yml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.gitignore +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.pre-commit-config.yaml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.python-version +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/LICENSE +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/README.md +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/calver_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/download_static_assets.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/mounted_subapps_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/multi_router_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/openapi_hook_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/self_hosted_docs_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/semver_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/semver_major_only_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/validation_override_integration_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/webhook_versioning_app.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/fastapi_router_versioning/py.typed +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/pyproject.toml +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/format.sh +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/lint.sh +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/prepare_release.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/test-cov-html.sh +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/test-cov.sh +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/test.sh +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/__init__.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_examples.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_fastapi_integration.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_prepare_release.py +0 -0
- {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-router-versioning
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: Router-based API versioning for FastAPI, with per-version docs and a declarative route lifecycle.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mat81black/fastapi-router-versioning
|
|
6
6
|
Project-URL: Repository, https://github.com/mat81black/fastapi-router-versioning
|
|
@@ -4,6 +4,7 @@ from collections import defaultdict
|
|
|
4
4
|
from collections.abc import Callable, Iterator
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from typing import Any, TypeAlias, TypeVar
|
|
7
|
+
from weakref import WeakKeyDictionary
|
|
7
8
|
|
|
8
9
|
import fastapi.openapi.utils
|
|
9
10
|
import fastapi.routing
|
|
@@ -32,6 +33,29 @@ _ATTR_DEPRECATE_IN = "_deprecate_in_version"
|
|
|
32
33
|
_ATTR_REMOVE_IN = "_remove_in_version"
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
class _AppRegistry:
|
|
37
|
+
"""Cross-instance bookkeeping for multiple RouterVersioner sharing one app: claimed
|
|
38
|
+
prefixes (collision detection) and /versions providers (aggregation). Keyed by app in
|
|
39
|
+
_app_registries below instead of living on app.state, so it's only ever reachable through
|
|
40
|
+
this module, not through any other code holding a reference to the app.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.claimed_prefixes: set[str] = set()
|
|
45
|
+
self.version_providers: list[Callable[[str], list[dict[str, Any]]]] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_app_registries: WeakKeyDictionary[FastAPI, _AppRegistry] = WeakKeyDictionary()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_app_registry(app: FastAPI) -> _AppRegistry:
|
|
52
|
+
registry = _app_registries.get(app)
|
|
53
|
+
if registry is None:
|
|
54
|
+
registry = _AppRegistry()
|
|
55
|
+
_app_registries[app] = registry
|
|
56
|
+
return registry
|
|
57
|
+
|
|
58
|
+
|
|
35
59
|
class VersionFormat(str, Enum):
|
|
36
60
|
"""
|
|
37
61
|
Defines the allowed versioning strategy for the RouterVersioner.
|
|
@@ -406,21 +430,16 @@ class RouterVersioner:
|
|
|
406
430
|
def _check_prefix_available(self, prefix: str, staged_prefixes: set[str]) -> None:
|
|
407
431
|
if prefix in staged_prefixes:
|
|
408
432
|
self._raise_prefix_claimed_by_self(prefix)
|
|
409
|
-
|
|
410
|
-
if claimed is not None and prefix in claimed:
|
|
433
|
+
if prefix in _get_app_registry(self._app).claimed_prefixes:
|
|
411
434
|
self._raise_prefix_claimed_by_other(prefix)
|
|
412
435
|
|
|
413
436
|
def _claim_prefix(self, prefix: str) -> None:
|
|
414
|
-
|
|
415
|
-
if claimed is None:
|
|
416
|
-
claimed = set()
|
|
417
|
-
self._app.state._router_versioner_claimed_prefixes = claimed
|
|
418
|
-
claimed.add(prefix)
|
|
437
|
+
_get_app_registry(self._app).claimed_prefixes.add(prefix)
|
|
419
438
|
|
|
420
439
|
def _resolve_webhooks_for_version(
|
|
421
440
|
self, version: VersionT, webhooks_by_version: dict[VersionT, list[Any]]
|
|
422
441
|
) -> list[Any]:
|
|
423
|
-
if
|
|
442
|
+
if self._webhook_routers is None:
|
|
424
443
|
# webhook_routers not provided: fall back to global app.webhooks
|
|
425
444
|
return list(self._app.webhooks.routes)
|
|
426
445
|
if isinstance(version, tuple):
|
|
@@ -463,9 +482,6 @@ class RouterVersioner:
|
|
|
463
482
|
def _add_route_to_router(
|
|
464
483
|
self, route: Any, router: APIRouter, version: VersionT, active_methods: set[str] | None = None
|
|
465
484
|
) -> None:
|
|
466
|
-
# Read attributes from the original route, not the RouteContext proxy. The proxy
|
|
467
|
-
# (FastAPI >= 0.137.2) only merges path/tags/deps; other fields such as
|
|
468
|
-
# response_model, status_code, and operation_id would be silently lost.
|
|
469
485
|
source_route = self._unwrap_route(route)
|
|
470
486
|
add_method: Callable[..., Any]
|
|
471
487
|
|
|
@@ -477,12 +493,14 @@ class RouterVersioner:
|
|
|
477
493
|
raise TypeError(f"Unsupported route type: {type(source_route).__name__}")
|
|
478
494
|
|
|
479
495
|
valid_params = inspect.signature(add_method).parameters.keys()
|
|
480
|
-
filtered_kwargs = {k: getattr(
|
|
496
|
+
filtered_kwargs = {k: getattr(route, k) for k in valid_params if hasattr(route, k)}
|
|
481
497
|
filtered_kwargs.setdefault("endpoint", source_route.endpoint)
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
498
|
+
# route_class_override isn't an attribute of the route instance (add_api_route consumes
|
|
499
|
+
# it once, at construction time), so the comprehension above never captures it: without
|
|
500
|
+
# this, a router built with APIRouter(route_class=CustomRoute) would silently remount
|
|
501
|
+
# every route as a plain APIRoute, dropping whatever get_route_handler() overrides.
|
|
502
|
+
if isinstance(source_route, APIRoute):
|
|
503
|
+
filtered_kwargs["route_class_override"] = type(source_route)
|
|
486
504
|
# A sibling route may have taken over some of this route's original methods at this
|
|
487
505
|
# version: mount only the methods still assigned to it, not its full original set.
|
|
488
506
|
if active_methods is not None and "methods" in valid_params:
|
|
@@ -528,10 +546,10 @@ class RouterVersioner:
|
|
|
528
546
|
def _collect_versioned_tags(self, router: APIRouter) -> list[dict[str, Any]]:
|
|
529
547
|
if self._app.openapi_tags is None:
|
|
530
548
|
return []
|
|
531
|
-
tags: set[str
|
|
549
|
+
tags: set[str] = set()
|
|
532
550
|
for route in router.routes:
|
|
533
551
|
if isinstance(route, APIRoute) and isinstance(route.tags, list):
|
|
534
|
-
tags.update(route.tags)
|
|
552
|
+
tags.update(tag.value if isinstance(tag, Enum) else tag for tag in route.tags)
|
|
535
553
|
if not tags:
|
|
536
554
|
return []
|
|
537
555
|
return [tag for tag in self._app.openapi_tags if tag["name"] in tags]
|
|
@@ -674,13 +692,8 @@ class RouterVersioner:
|
|
|
674
692
|
return version_models
|
|
675
693
|
|
|
676
694
|
def _add_versions_route(self, versions: list[VersionT]) -> None:
|
|
677
|
-
providers
|
|
678
|
-
|
|
679
|
-
)
|
|
680
|
-
is_first_provider = providers is None
|
|
681
|
-
if providers is None:
|
|
682
|
-
providers = []
|
|
683
|
-
self._app.state._router_versioner_version_providers = providers
|
|
695
|
+
providers = _get_app_registry(self._app).version_providers
|
|
696
|
+
is_first_provider = not providers
|
|
684
697
|
providers.append(lambda root_path: self._build_version_models(versions, root_path))
|
|
685
698
|
|
|
686
699
|
if not is_first_provider:
|
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Latest Changes
|
|
4
4
|
|
|
5
|
+
## 1.0.3 (2026-08-13)
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
* 🐛 Fix tag metadata missing from versioned schemas when tags use an Enum. PR [#81](https://github.com/mat81black/fastapi-router-versioning/pull/81) by [@mat81black](https://github.com/mat81black).
|
|
10
|
+
* 🐛 Distinguish an unset webhook_routers from one provided but empty. PR [#80](https://github.com/mat81black/fastapi-router-versioning/pull/80) by [@mat81black](https://github.com/mat81black).
|
|
11
|
+
* 🐛 Read route attributes from the merged RouteContext instead of the original route. PR [#79](https://github.com/mat81black/fastapi-router-versioning/pull/79) by [@mat81black](https://github.com/mat81black).
|
|
12
|
+
* 🐛 Preserve custom route_class when mounting versioned routes. PR [#78](https://github.com/mat81black/fastapi-router-versioning/pull/78) by [@mat81black](https://github.com/mat81black).
|
|
13
|
+
|
|
14
|
+
### Refactors
|
|
15
|
+
|
|
16
|
+
* ♻️ Move prefix and /versions bookkeeping off app.state into a private registry. PR [#84](https://github.com/mat81black/fastapi-router-versioning/pull/84) by [@mat81black](https://github.com/mat81black).
|
|
17
|
+
|
|
18
|
+
### Internal
|
|
19
|
+
|
|
20
|
+
* ♻️ Reorganize versioner tests into docs, lifecycle, webhooks, and errors files. PR [#85](https://github.com/mat81black/fastapi-router-versioning/pull/85) by [@mat81black](https://github.com/mat81black).
|
|
21
|
+
* ✅ Cover versionize() on a router with no routes. PR [#83](https://github.com/mat81black/fastapi-router-versioning/pull/83) by [@mat81black](https://github.com/mat81black).
|
|
22
|
+
* ✅ Cover default_version type validation on construction. PR [#82](https://github.com/mat81black/fastapi-router-versioning/pull/82) by [@mat81black](https://github.com/mat81black).
|
|
23
|
+
|
|
5
24
|
## 1.0.2 (2026-08-12)
|
|
6
25
|
|
|
7
26
|
### Internal
|
|
@@ -114,3 +114,13 @@ def test_calver_type_validation_raises_error() -> None:
|
|
|
114
114
|
|
|
115
115
|
with pytest.raises(ValueError, match="RouterVersioner expects CALVER"):
|
|
116
116
|
versioner.versionize()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_calver_default_version_type_validation_raises_error() -> None:
|
|
120
|
+
"""A tuple default_version on a CALVER-configured versioner raises ValueError at
|
|
121
|
+
construction time, the same way an invalid @api_version on a route does."""
|
|
122
|
+
app = FastAPI()
|
|
123
|
+
router = APIRouter()
|
|
124
|
+
|
|
125
|
+
with pytest.raises(ValueError, match="RouterVersioner expects CALVER"):
|
|
126
|
+
RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER, default_version=(1, 0))
|
|
@@ -117,3 +117,13 @@ def test_semver_type_validation_raises_error() -> None:
|
|
|
117
117
|
|
|
118
118
|
with pytest.raises(ValueError, match="RouterVersioner expects SEMVER"):
|
|
119
119
|
versioner.versionize()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_semver_default_version_type_validation_raises_error() -> None:
|
|
123
|
+
"""A string default_version on a SEMVER-configured versioner raises ValueError at
|
|
124
|
+
construction time, the same way an invalid @api_version on a route does."""
|
|
125
|
+
app = FastAPI()
|
|
126
|
+
router = APIRouter()
|
|
127
|
+
|
|
128
|
+
with pytest.raises(ValueError, match="RouterVersioner expects SEMVER"):
|
|
129
|
+
RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER, default_version="2025-01-01")
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
|
7
|
+
from fastapi.routing import APIRoute
|
|
8
|
+
from fastapi.testclient import TestClient
|
|
9
|
+
|
|
10
|
+
from fastapi_router_versioning import RouterVersioner, VersionFormat, VersionT, api_version
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_default_version_applied_to_undecorated_routes() -> None:
|
|
14
|
+
"""Routes without @api_version should fall back to the configured default_version."""
|
|
15
|
+
app = FastAPI()
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
@router.get("/default")
|
|
19
|
+
def default_route() -> dict[str, str]:
|
|
20
|
+
return {"msg": "ok"}
|
|
21
|
+
|
|
22
|
+
versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER, default_version=(4, 2))
|
|
23
|
+
versioner.versionize()
|
|
24
|
+
|
|
25
|
+
client = TestClient(app)
|
|
26
|
+
assert client.get("/v4_2/default").status_code == 200
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_sort_routes_and_empty_name() -> None:
|
|
30
|
+
"""Routes are sorted alphabetically when sort_routes=True; empty name does not crash FastAPI."""
|
|
31
|
+
app = FastAPI()
|
|
32
|
+
router = APIRouter()
|
|
33
|
+
|
|
34
|
+
@router.get("/b", name="")
|
|
35
|
+
@api_version((1, 0))
|
|
36
|
+
def route_b() -> dict[str, str]:
|
|
37
|
+
return {"msg": "b"}
|
|
38
|
+
|
|
39
|
+
@router.get("/a")
|
|
40
|
+
@api_version((1, 0))
|
|
41
|
+
def route_a() -> dict[str, str]:
|
|
42
|
+
return {"msg": "a"}
|
|
43
|
+
|
|
44
|
+
versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER, sort_routes=True)
|
|
45
|
+
versioner.versionize()
|
|
46
|
+
|
|
47
|
+
client = TestClient(app)
|
|
48
|
+
assert client.get("/v1_0/a").status_code == 200
|
|
49
|
+
assert client.get("/v1_0/b").status_code == 200
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_websockets_versioning() -> None:
|
|
53
|
+
"""WebSocket routes are versioned and accessible only in the declared version."""
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
router = APIRouter()
|
|
56
|
+
|
|
57
|
+
@router.websocket("/ws")
|
|
58
|
+
@api_version((2, 0))
|
|
59
|
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
60
|
+
await websocket.accept()
|
|
61
|
+
await websocket.send_text("Hello Versioned WS")
|
|
62
|
+
await websocket.close()
|
|
63
|
+
|
|
64
|
+
versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER)
|
|
65
|
+
versioner.versionize()
|
|
66
|
+
|
|
67
|
+
client = TestClient(app)
|
|
68
|
+
|
|
69
|
+
with pytest.raises(WebSocketDisconnect):
|
|
70
|
+
client.websocket_connect("/v1_0/ws").__enter__()
|
|
71
|
+
|
|
72
|
+
with client.websocket_connect("/v2_0/ws") as websocket:
|
|
73
|
+
data = websocket.receive_text()
|
|
74
|
+
assert data == "Hello Versioned WS"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_websocket_nested_router_prefix_is_preserved() -> None:
|
|
78
|
+
"""WebSocket inside a sub-router with a prefix must carry the full merged path when versionized."""
|
|
79
|
+
app = FastAPI()
|
|
80
|
+
ws_router = APIRouter()
|
|
81
|
+
|
|
82
|
+
@ws_router.websocket("/ws")
|
|
83
|
+
@api_version((1, 0))
|
|
84
|
+
async def ws_endpoint(websocket: WebSocket) -> None:
|
|
85
|
+
await websocket.accept()
|
|
86
|
+
await websocket.send_text("ok")
|
|
87
|
+
await websocket.close()
|
|
88
|
+
|
|
89
|
+
parent_router = APIRouter(prefix="/chat")
|
|
90
|
+
parent_router.include_router(ws_router)
|
|
91
|
+
|
|
92
|
+
RouterVersioner(app=app, routers=parent_router, version_format=VersionFormat.SEMVER).versionize()
|
|
93
|
+
|
|
94
|
+
client = TestClient(app)
|
|
95
|
+
with client.websocket_connect("/v1_0/chat/ws") as ws:
|
|
96
|
+
assert ws.receive_text() == "ok"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_unsupported_route_type_raises_error() -> None:
|
|
100
|
+
"""A route type RouterVersioner doesn't know how to mount (neither APIRoute nor
|
|
101
|
+
APIWebSocketRoute) raises TypeError."""
|
|
102
|
+
app = FastAPI()
|
|
103
|
+
router = APIRouter()
|
|
104
|
+
|
|
105
|
+
class UnsupportedRoute:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER)
|
|
109
|
+
|
|
110
|
+
with pytest.raises(TypeError, match="Unsupported route type: UnsupportedRoute"):
|
|
111
|
+
versioner._add_route_to_router(
|
|
112
|
+
route=UnsupportedRoute(), # type: ignore
|
|
113
|
+
router=router,
|
|
114
|
+
version=(1, 0),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_custom_route_class_is_preserved_on_versioned_routes() -> None:
|
|
119
|
+
"""A router built with APIRouter(route_class=CustomRoute) — FastAPI's documented pattern
|
|
120
|
+
for request/response interception via APIRoute.get_route_handler(), e.g. for auth checks
|
|
121
|
+
run before the endpoint — must keep using that class once versioned, not silently fall
|
|
122
|
+
back to plain APIRoute and drop whatever the custom class does.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
class RequireApiKeyRoute(APIRoute):
|
|
126
|
+
def get_route_handler(self) -> Callable[[Request], Any]:
|
|
127
|
+
original_handler = super().get_route_handler()
|
|
128
|
+
|
|
129
|
+
async def custom_handler(request: Request) -> Response:
|
|
130
|
+
if request.headers.get("x-api-key") != "secret-internal-key":
|
|
131
|
+
raise HTTPException(status_code=403, detail="Missing or invalid API key")
|
|
132
|
+
return await original_handler(request)
|
|
133
|
+
|
|
134
|
+
return custom_handler
|
|
135
|
+
|
|
136
|
+
app = FastAPI()
|
|
137
|
+
router = APIRouter(route_class=RequireApiKeyRoute)
|
|
138
|
+
|
|
139
|
+
@router.get("/admin/users")
|
|
140
|
+
@api_version((1, 0))
|
|
141
|
+
def list_admin_users() -> dict[str, list[str]]:
|
|
142
|
+
return {"users": ["alice", "bob"]}
|
|
143
|
+
|
|
144
|
+
RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).versionize()
|
|
145
|
+
|
|
146
|
+
client = TestClient(app)
|
|
147
|
+
assert client.get("/v1_0/admin/users").status_code == 403
|
|
148
|
+
assert client.get("/v1_0/admin/users", headers={"x-api-key": "wrong"}).status_code == 403
|
|
149
|
+
assert client.get("/v1_0/admin/users", headers={"x-api-key": "secret-internal-key"}).status_code == 200
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_include_router_schema_visibility_override_is_preserved_on_versioned_routes() -> None:
|
|
153
|
+
"""include_router(..., include_in_schema=False) hides every route of the included router
|
|
154
|
+
from the OpenAPI schema without affecting routing. That override must still hold once the
|
|
155
|
+
parent router is versioned, not silently revert to the route's own declared value."""
|
|
156
|
+
internal_router = APIRouter()
|
|
157
|
+
|
|
158
|
+
@internal_router.get("/debug/internal-state")
|
|
159
|
+
@api_version((1, 0))
|
|
160
|
+
def debug_state() -> dict[str, str]:
|
|
161
|
+
return {"secret": "internal debug info"}
|
|
162
|
+
|
|
163
|
+
parent_router = APIRouter()
|
|
164
|
+
parent_router.include_router(internal_router, include_in_schema=False)
|
|
165
|
+
|
|
166
|
+
app = FastAPI()
|
|
167
|
+
RouterVersioner(app=app, routers=parent_router, version_format=VersionFormat.SEMVER).versionize()
|
|
168
|
+
|
|
169
|
+
client = TestClient(app)
|
|
170
|
+
schema = client.get("/v1_0/openapi.json").json()
|
|
171
|
+
assert "/v1_0/debug/internal-state" not in schema["paths"]
|
|
172
|
+
assert client.get("/v1_0/debug/internal-state").status_code == 200
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_include_router_deprecated_and_responses_overrides_are_preserved_on_versioned_routes() -> None:
|
|
176
|
+
"""FastAPI's own "Bigger Applications" tutorial passes deprecated and responses to
|
|
177
|
+
include_router() to apply them to every route of the included router (e.g.
|
|
178
|
+
responses={418: ...}). Those merged values must survive versioning too."""
|
|
179
|
+
internal_router = APIRouter()
|
|
180
|
+
|
|
181
|
+
@internal_router.get("/admin")
|
|
182
|
+
@api_version((1, 0))
|
|
183
|
+
def admin() -> dict[str, bool]:
|
|
184
|
+
return {"ok": True}
|
|
185
|
+
|
|
186
|
+
parent_router = APIRouter()
|
|
187
|
+
parent_router.include_router(internal_router, deprecated=True, responses={418: {"description": "I'm a teapot"}})
|
|
188
|
+
|
|
189
|
+
app = FastAPI()
|
|
190
|
+
RouterVersioner(app=app, routers=parent_router, version_format=VersionFormat.SEMVER).versionize()
|
|
191
|
+
|
|
192
|
+
client = TestClient(app)
|
|
193
|
+
assert client.get("/v1_0/admin").json() == {"ok": True}
|
|
194
|
+
operation = client.get("/v1_0/openapi.json").json()["paths"]["/v1_0/admin"]["get"]
|
|
195
|
+
assert operation["deprecated"] is True
|
|
196
|
+
assert "418" in operation["responses"]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_versioner_callback() -> None:
|
|
200
|
+
"""The callback is invoked once per versioned router, including the latest_prefix alias."""
|
|
201
|
+
app = FastAPI()
|
|
202
|
+
router = APIRouter()
|
|
203
|
+
called_versions = []
|
|
204
|
+
|
|
205
|
+
def my_callback(rt: APIRouter, version: VersionT, prefix: str) -> None:
|
|
206
|
+
called_versions.append((version, prefix))
|
|
207
|
+
|
|
208
|
+
@router.get("/test")
|
|
209
|
+
@api_version((1, 0))
|
|
210
|
+
def test_route() -> dict[str, str]: ...
|
|
211
|
+
|
|
212
|
+
versioner = RouterVersioner(
|
|
213
|
+
app=app, routers=router, version_format=VersionFormat.SEMVER, callback=my_callback, latest_prefix="/vlatest"
|
|
214
|
+
)
|
|
215
|
+
versioner.versionize()
|
|
216
|
+
|
|
217
|
+
assert len(called_versions) == 2
|
|
218
|
+
assert called_versions[0] == ((1, 0), "/v1_0")
|
|
219
|
+
assert called_versions[1] == ((1, 0), "/vlatest")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_versioned_app_mounted_as_real_sub_application() -> None:
|
|
223
|
+
"""A RouterVersioner-managed app works correctly when actually mounted via app.mount(),
|
|
224
|
+
not just simulated with a root_path passed to TestClient. Covers the ASGI root_path
|
|
225
|
+
FastAPI injects for real sub-applications: versioned docs/openapi and runtime behavior
|
|
226
|
+
must all resolve under the mount prefix."""
|
|
227
|
+
main_app = FastAPI()
|
|
228
|
+
sub_app = FastAPI()
|
|
229
|
+
router = APIRouter()
|
|
230
|
+
|
|
231
|
+
@router.post("/items")
|
|
232
|
+
@api_version((1, 0))
|
|
233
|
+
def create_item(count: int) -> dict[str, str]: ...
|
|
234
|
+
|
|
235
|
+
RouterVersioner(app=sub_app, routers=router, version_format=VersionFormat.SEMVER).versionize()
|
|
236
|
+
|
|
237
|
+
main_app.mount("/sub", sub_app)
|
|
238
|
+
|
|
239
|
+
client = TestClient(main_app)
|
|
240
|
+
|
|
241
|
+
# Sub-app's own root schema (accessed through the mount) resolves the mount's root_path.
|
|
242
|
+
sub_root_schema = client.get("/sub/openapi.json").json()
|
|
243
|
+
assert "/v1_0/items" in sub_root_schema["paths"]
|
|
244
|
+
assert sub_root_schema["servers"][0]["url"] == "/sub"
|
|
245
|
+
|
|
246
|
+
# Versioned schema is consistent too.
|
|
247
|
+
versioned_schema = client.get("/sub/v1_0/openapi.json").json()
|
|
248
|
+
assert versioned_schema["servers"][0]["url"] == "/sub"
|
|
249
|
+
|
|
250
|
+
# Runtime resolves correctly through the mount.
|
|
251
|
+
assert client.post("/sub/v1_0/items?count=bad", json={}).status_code == 422
|
|
252
|
+
assert client.get("/sub/v1_0/docs").status_code == 200
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_custom_formats_coverage() -> None:
|
|
256
|
+
"""Custom prefix_format and semantic_version_format are applied correctly."""
|
|
257
|
+
app = FastAPI()
|
|
258
|
+
router = APIRouter()
|
|
259
|
+
|
|
260
|
+
@router.get("/custom")
|
|
261
|
+
@api_version((2, 1))
|
|
262
|
+
def custom_route() -> dict[str, str]:
|
|
263
|
+
return {"msg": "ok"}
|
|
264
|
+
|
|
265
|
+
versioner = RouterVersioner(
|
|
266
|
+
app=app,
|
|
267
|
+
routers=router,
|
|
268
|
+
version_format=VersionFormat.SEMVER,
|
|
269
|
+
prefix_format="/api/ver-{major}-{minor}",
|
|
270
|
+
semantic_version_format="v{major}.{minor}-custom",
|
|
271
|
+
)
|
|
272
|
+
versioner.versionize()
|
|
273
|
+
|
|
274
|
+
client = TestClient(app)
|
|
275
|
+
|
|
276
|
+
assert client.get("/api/ver-2-1/custom").status_code == 200
|
|
277
|
+
|
|
278
|
+
response = client.get("/api/ver-2-1/openapi.json")
|
|
279
|
+
assert response.status_code == 200
|
|
280
|
+
schema = response.json()
|
|
281
|
+
assert schema["info"]["version"] == "v2.1-custom"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_api_version_wrong_type_raises_error() -> None:
|
|
285
|
+
"""@api_version raises TypeError immediately at decoration time when given a wrong type."""
|
|
286
|
+
with pytest.raises(TypeError, match="api_version:.*'version'"):
|
|
287
|
+
|
|
288
|
+
@api_version(1) # type: ignore[arg-type]
|
|
289
|
+
def my_func() -> None: ...
|
|
290
|
+
|
|
291
|
+
with pytest.raises(TypeError, match="api_version:.*'deprecate_in'"):
|
|
292
|
+
|
|
293
|
+
@api_version((1, 0), deprecate_in=2) # type: ignore[arg-type]
|
|
294
|
+
def my_func2() -> None: ...
|
|
295
|
+
|
|
296
|
+
with pytest.raises(TypeError, match="api_version:.*'remove_in'"):
|
|
297
|
+
|
|
298
|
+
@api_version((1, 0), remove_in=3.5) # type: ignore[arg-type]
|
|
299
|
+
def my_func3() -> None: ...
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_versionize_on_router_with_no_routes_returns_empty_list() -> None:
|
|
303
|
+
"""A router with no @api_version-decorated routes at all (not even one version boundary)
|
|
304
|
+
must versionize() to an empty list without raising, and must not mount anything for
|
|
305
|
+
latest_prefix either — there is no "latest version" to alias."""
|
|
306
|
+
app = FastAPI()
|
|
307
|
+
router = APIRouter()
|
|
308
|
+
|
|
309
|
+
versioner = RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER, latest_prefix="/latest")
|
|
310
|
+
versions = versioner.versionize()
|
|
311
|
+
|
|
312
|
+
assert versions == []
|
|
313
|
+
|
|
314
|
+
client = TestClient(app)
|
|
315
|
+
assert client.get("/latest").status_code == 404
|
|
316
|
+
assert not any(getattr(r, "path", "").startswith("/latest") for r in app.routes)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_routers_as_list() -> None:
|
|
320
|
+
"""routers accepts a list of APIRouter, not just a single one (multi-router support)."""
|
|
321
|
+
app = FastAPI()
|
|
322
|
+
router1 = APIRouter()
|
|
323
|
+
router2 = APIRouter()
|
|
324
|
+
|
|
325
|
+
@router1.get("/a")
|
|
326
|
+
@api_version((1, 0))
|
|
327
|
+
def route_a() -> dict[str, str]:
|
|
328
|
+
return {"msg": "a"}
|
|
329
|
+
|
|
330
|
+
@router2.get("/b")
|
|
331
|
+
@api_version((1, 0))
|
|
332
|
+
def route_b() -> dict[str, str]:
|
|
333
|
+
return {"msg": "b"}
|
|
334
|
+
|
|
335
|
+
RouterVersioner(app=app, routers=[router1, router2], version_format=VersionFormat.SEMVER).versionize()
|
|
336
|
+
|
|
337
|
+
client = TestClient(app)
|
|
338
|
+
assert client.get("/v1_0/a").status_code == 200
|
|
339
|
+
assert client.get("/v1_0/b").status_code == 200
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_version_gte_mismatched_types_returns_false() -> None:
|
|
343
|
+
"""_version_gte returns False for values that aren't both tuples or both strings.
|
|
344
|
+
|
|
345
|
+
Defensive branch: normally unreachable via the public API, since _validate_version_type
|
|
346
|
+
enforces a single, consistent VersionT type (tuple for SEMVER, str for CALVER) per
|
|
347
|
+
RouterVersioner instance.
|
|
348
|
+
"""
|
|
349
|
+
assert RouterVersioner._version_gte((1, 0), "2025-01-01") is False
|
|
350
|
+
assert RouterVersioner._version_gte("2025-01-01", (1, 0)) is False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_iter_routes_flat_fallback_without_route_context_fn() -> None:
|
|
354
|
+
"""Covers the _route_contexts_fn=None fallback (legacy FastAPI < 0.137.2).
|
|
355
|
+
|
|
356
|
+
Patches the module-level variable to None to simulate an environment where
|
|
357
|
+
iter_route_contexts is not available, then verifies that _iter_routes_flat
|
|
358
|
+
yields the raw route list unchanged.
|
|
359
|
+
"""
|
|
360
|
+
import fastapi_router_versioning.versioner as versioner_module
|
|
361
|
+
|
|
362
|
+
router = APIRouter()
|
|
363
|
+
|
|
364
|
+
@router.get("/ping")
|
|
365
|
+
def ping() -> dict[str, str]: ...
|
|
366
|
+
|
|
367
|
+
original_fn = versioner_module._route_contexts_fn
|
|
368
|
+
try:
|
|
369
|
+
versioner_module._route_contexts_fn = None
|
|
370
|
+
result = list(versioner_module.RouterVersioner._iter_routes_flat(router.routes))
|
|
371
|
+
assert result == list(router.routes)
|
|
372
|
+
finally:
|
|
373
|
+
versioner_module._route_contexts_fn = original_fn
|