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.
Files changed (54) hide show
  1. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/PKG-INFO +1 -1
  2. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/fastapi_router_versioning/__init__.py +1 -1
  3. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/fastapi_router_versioning/versioner.py +38 -25
  4. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/release-notes.md +19 -0
  5. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_calver.py +10 -0
  6. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_semver.py +10 -0
  7. fastapi_router_versioning-1.0.3/tests/test_versioner.py +373 -0
  8. fastapi_router_versioning-1.0.3/tests/test_versioner_docs.py +559 -0
  9. fastapi_router_versioning-1.0.3/tests/test_versioner_errors.py +200 -0
  10. fastapi_router_versioning-1.0.3/tests/test_versioner_lifecycle.py +223 -0
  11. fastapi_router_versioning-1.0.3/tests/test_versioner_versions_route.py +110 -0
  12. fastapi_router_versioning-1.0.3/tests/test_versioner_webhooks.py +220 -0
  13. fastapi_router_versioning-1.0.2/tests/test_versioner.py +0 -1482
  14. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/dependabot.yml +0 -0
  15. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/labeler.yml +0 -0
  16. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/bump-pre-commit-hooks.yml +0 -0
  17. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/create-draft-release.yml +0 -0
  18. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/detect-conflicts.yml +0 -0
  19. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/guard-dependencies.yml +0 -0
  20. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/labeler.yml +0 -0
  21. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/pre-commit.yml +0 -0
  22. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/prepare-release.yml +0 -0
  23. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/publish.yml +0 -0
  24. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/test-redistribute.yml +0 -0
  25. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/test.yml +0 -0
  26. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.github/workflows/zizmor.yml +0 -0
  27. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.gitignore +0 -0
  28. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.pre-commit-config.yaml +0 -0
  29. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/.python-version +0 -0
  30. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/LICENSE +0 -0
  31. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/README.md +0 -0
  32. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/calver_app.py +0 -0
  33. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/download_static_assets.py +0 -0
  34. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/mounted_subapps_app.py +0 -0
  35. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/multi_router_app.py +0 -0
  36. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/openapi_hook_app.py +0 -0
  37. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/self_hosted_docs_app.py +0 -0
  38. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/semver_app.py +0 -0
  39. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/semver_major_only_app.py +0 -0
  40. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/validation_override_integration_app.py +0 -0
  41. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/examples/webhook_versioning_app.py +0 -0
  42. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/fastapi_router_versioning/py.typed +0 -0
  43. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/pyproject.toml +0 -0
  44. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/format.sh +0 -0
  45. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/lint.sh +0 -0
  46. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/prepare_release.py +0 -0
  47. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/test-cov-html.sh +0 -0
  48. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/test-cov.sh +0 -0
  49. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/scripts/test.sh +0 -0
  50. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/__init__.py +0 -0
  51. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_examples.py +0 -0
  52. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_fastapi_integration.py +0 -0
  53. {fastapi_router_versioning-1.0.2 → fastapi_router_versioning-1.0.3}/tests/test_prepare_release.py +0 -0
  54. {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.2
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
@@ -1,5 +1,5 @@
1
1
  from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.0.3"
4
4
 
5
5
  __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]
@@ -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
- claimed: set[str] | None = getattr(self._app.state, "_router_versioner_claimed_prefixes", None)
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
- claimed: set[str] | None = getattr(self._app.state, "_router_versioner_claimed_prefixes", None)
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 not webhooks_by_version:
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(source_route, k) for k in valid_params if hasattr(source_route, k)}
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
- # Override path/tags/deps with the merged values from RouteContext when present.
483
- for merged_attr in ("path", "tags", "dependencies"):
484
- if hasattr(route, merged_attr) and merged_attr in valid_params:
485
- filtered_kwargs[merged_attr] = getattr(route, merged_attr)
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 | Enum] = set()
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: list[Callable[[str], list[dict[str, Any]]]] | None = getattr(
678
- self._app.state, "_router_versioner_version_providers", None
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