fastapi-router-versioning 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- fastapi_router_versioning/py.typed +0 -0
- fastapi_router_versioning/versioner.py +564 -0
- fastapi_router_versioning-0.1.0.dist-info/METADATA +410 -0
- fastapi_router_versioning-0.1.0.dist-info/RECORD +7 -0
- fastapi_router_versioning-0.1.0.dist-info/WHEEL +4 -0
- fastapi_router_versioning-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Callable, Iterator
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, TypeAlias, TypeVar
|
|
7
|
+
|
|
8
|
+
import fastapi.openapi.utils
|
|
9
|
+
import fastapi.routing
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, FastAPI
|
|
12
|
+
from fastapi.openapi.docs import (
|
|
13
|
+
get_redoc_html,
|
|
14
|
+
get_swagger_ui_html,
|
|
15
|
+
get_swagger_ui_oauth2_redirect_html,
|
|
16
|
+
)
|
|
17
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
18
|
+
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
|
|
21
|
+
# iter_route_contexts was introduced in FastAPI 0.137.2. On older versions the
|
|
22
|
+
# attribute does not exist and getattr returns None, activating the fallback path.
|
|
23
|
+
_route_contexts_fn: Callable[..., Any] | None = getattr(fastapi.routing, "iter_route_contexts", None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _iter_routes_flat(routes: list[Any]) -> Iterator[Any]:
|
|
27
|
+
"""
|
|
28
|
+
Flattens the route tree using iter_route_contexts (FastAPI >= 0.137.2),
|
|
29
|
+
or yields the original flat list for older versions.
|
|
30
|
+
"""
|
|
31
|
+
if _route_contexts_fn is None:
|
|
32
|
+
yield from routes
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
for route_ctx in _route_contexts_fn(routes):
|
|
36
|
+
original = route_ctx.original_route
|
|
37
|
+
if isinstance(original, APIRoute):
|
|
38
|
+
# RouteContext merges path/tags/deps via __getattr__; use the context directly.
|
|
39
|
+
yield route_ctx
|
|
40
|
+
else:
|
|
41
|
+
yield original
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unwrap_route(route: Any) -> Any:
|
|
45
|
+
# RouteContext (FastAPI >= 0.137.2) wraps the original route.
|
|
46
|
+
# Attributes like response_model, status_code, operation_id live on the original.
|
|
47
|
+
return getattr(route, "original_route", route)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
|
51
|
+
|
|
52
|
+
VersionT: TypeAlias = tuple[int, int] | str
|
|
53
|
+
|
|
54
|
+
_ATTR_API_VERSION = "_api_version"
|
|
55
|
+
_ATTR_DEPRECATE_IN = "_deprecate_in_version"
|
|
56
|
+
_ATTR_REMOVE_IN = "_remove_in_version"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class VersionFormat(str, Enum):
|
|
60
|
+
"""
|
|
61
|
+
Defines the allowed versioning strategy for the RouterVersioner.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
SEMVER = "semver" # Accepts tuple[int, int] (e.g., (1, 0))
|
|
65
|
+
CALVER = "calver" # Accepts str (e.g., "2025-01-01", "v1")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _validate_api_version_arg(value: Any, param_name: str) -> None:
|
|
69
|
+
if not isinstance(value, (tuple, str)):
|
|
70
|
+
raise TypeError(
|
|
71
|
+
f"api_version: '{param_name}' must be a tuple[int, int] (SemVer) or str (CalVer), "
|
|
72
|
+
f"got {type(value).__name__!r} instead. "
|
|
73
|
+
"Example: @api_version((1, 0)) or @api_version('2025-01-01')."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def api_version(
|
|
78
|
+
version: VersionT,
|
|
79
|
+
deprecate_in: VersionT | None = None,
|
|
80
|
+
remove_in: VersionT | None = None,
|
|
81
|
+
) -> Callable[[CallableT], CallableT]:
|
|
82
|
+
"""
|
|
83
|
+
Decorator to annotate API routes with their specific version.
|
|
84
|
+
|
|
85
|
+
Accepts both Semantic Versioning (e.g., tuple (1, 0)) and Calendar Versioning
|
|
86
|
+
or strings (e.g., "2025-01-01", "v1").
|
|
87
|
+
Metadata is injected directly into the wrapper function, allowing the
|
|
88
|
+
RouterVersioner to organize the routes dynamically.
|
|
89
|
+
"""
|
|
90
|
+
_validate_api_version_arg(version, "version")
|
|
91
|
+
if deprecate_in is not None:
|
|
92
|
+
_validate_api_version_arg(deprecate_in, "deprecate_in")
|
|
93
|
+
if remove_in is not None:
|
|
94
|
+
_validate_api_version_arg(remove_in, "remove_in")
|
|
95
|
+
|
|
96
|
+
def decorator(func: CallableT) -> CallableT:
|
|
97
|
+
setattr(func, _ATTR_API_VERSION, version) # noqa: B010
|
|
98
|
+
|
|
99
|
+
if deprecate_in is not None:
|
|
100
|
+
setattr(func, _ATTR_DEPRECATE_IN, deprecate_in) # noqa: B010
|
|
101
|
+
|
|
102
|
+
if remove_in is not None:
|
|
103
|
+
setattr(func, _ATTR_REMOVE_IN, remove_in) # noqa: B010
|
|
104
|
+
|
|
105
|
+
return func
|
|
106
|
+
|
|
107
|
+
return decorator
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RouterVersioner:
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
app: FastAPI,
|
|
114
|
+
routers: list[APIRouter] | APIRouter,
|
|
115
|
+
version_format: VersionFormat = VersionFormat.SEMVER,
|
|
116
|
+
prefix_format: str | None = None,
|
|
117
|
+
semantic_version_format: str | None = None,
|
|
118
|
+
default_version: VersionT | None = None,
|
|
119
|
+
latest_prefix: str | None = None,
|
|
120
|
+
include_version_docs: bool = True,
|
|
121
|
+
include_version_openapi_route: bool = True,
|
|
122
|
+
include_versions_route: bool = False,
|
|
123
|
+
sort_routes: bool = False,
|
|
124
|
+
callback: Callable[[APIRouter, VersionT, str], None] | None = None,
|
|
125
|
+
webhook_routers: list[APIRouter] | APIRouter | None = None,
|
|
126
|
+
openapi_hook: Callable[[dict[str, Any], VersionT], dict[str, Any]] | None = None,
|
|
127
|
+
swagger_js_url: str | None = None,
|
|
128
|
+
swagger_css_url: str | None = None,
|
|
129
|
+
swagger_favicon_url: str | None = None,
|
|
130
|
+
redoc_js_url: str | None = None,
|
|
131
|
+
redoc_favicon_url: str | None = None,
|
|
132
|
+
redoc_with_google_fonts: bool = True,
|
|
133
|
+
):
|
|
134
|
+
"""
|
|
135
|
+
Versionize your FastAPI application in-place, organizing routes based on their API version.
|
|
136
|
+
|
|
137
|
+
:param app: The main FastAPI application instance.
|
|
138
|
+
:param routers: A single APIRouter or a list of APIRouters containing the routes to version.
|
|
139
|
+
:param version_format: Enforces the versioning strategy (SEMVER or CALVER).
|
|
140
|
+
For CALVER, version strings must be lexicographically sortable in the intended
|
|
141
|
+
order (e.g. ISO dates "2025-01-01", zero-padded numbers "v01", "v02").
|
|
142
|
+
Strings like "v1", "v10", "v2" will NOT sort correctly and cause routes to
|
|
143
|
+
appear in the wrong versions.
|
|
144
|
+
:param prefix_format: Format used to build the route prefix.
|
|
145
|
+
:param semantic_version_format: Format used to build the version in Swagger/ReDoc.
|
|
146
|
+
:param default_version: Default version used if a route is not explicitly decorated.
|
|
147
|
+
:param latest_prefix: If specified, creates an alias prefix for the latest active version.
|
|
148
|
+
:param include_version_docs: If True, creates isolated Swagger/ReDoc pages for each version.
|
|
149
|
+
:param include_version_openapi_route: If True, creates an independent openapi.json route for each version.
|
|
150
|
+
:param include_versions_route: If True, adds a 'GET /versions' endpoint returning info on all active API versions.
|
|
151
|
+
:param sort_routes: If True, sorts all routes alphabetically by path.
|
|
152
|
+
:param callback: Optional hook invoked every time a versioned APIRouter is created.
|
|
153
|
+
:param webhook_routers: A single APIRouter or a list of APIRouters containing webhook definitions
|
|
154
|
+
annotated with @api_version. When provided, each version's OpenAPI schema shows only the
|
|
155
|
+
webhooks active in that version (using the same introduce/remove lifecycle as regular routes).
|
|
156
|
+
When None, every version inherits app.webhooks unchanged.
|
|
157
|
+
:param openapi_hook: Optional hook applied to the generated OpenAPI schema for each version.
|
|
158
|
+
Receives the schema dict and the current version; must return the (modified) schema dict.
|
|
159
|
+
Use this to add custom extensions, logos, or version-specific metadata that would
|
|
160
|
+
otherwise be bypassed by the per-version schema generation.
|
|
161
|
+
:param swagger_js_url: Custom URL for the Swagger UI JS bundle. Defaults to FastAPI's CDN URL.
|
|
162
|
+
:param swagger_css_url: Custom URL for the Swagger UI CSS. Defaults to FastAPI's CDN URL.
|
|
163
|
+
:param swagger_favicon_url: Custom URL for the Swagger UI favicon. Defaults to FastAPI's favicon.
|
|
164
|
+
:param redoc_js_url: Custom URL for the ReDoc JS bundle. Defaults to FastAPI's CDN URL.
|
|
165
|
+
:param redoc_favicon_url: Custom URL for the ReDoc favicon. Defaults to FastAPI's favicon.
|
|
166
|
+
:param redoc_with_google_fonts: If False, ReDoc will not load Google Fonts. Defaults to True.
|
|
167
|
+
"""
|
|
168
|
+
self._app = app
|
|
169
|
+
self._routers = [routers] if isinstance(routers, APIRouter) else routers
|
|
170
|
+
self._version_format = version_format
|
|
171
|
+
|
|
172
|
+
if prefix_format is None:
|
|
173
|
+
self._prefix_format = "/v{major}_{minor}" if version_format == VersionFormat.SEMVER else "/{version}"
|
|
174
|
+
else:
|
|
175
|
+
self._prefix_format = prefix_format
|
|
176
|
+
|
|
177
|
+
if semantic_version_format is None:
|
|
178
|
+
self._semantic_version_format = "{major}.{minor}" if version_format == VersionFormat.SEMVER else "{version}"
|
|
179
|
+
else:
|
|
180
|
+
self._semantic_version_format = semantic_version_format
|
|
181
|
+
|
|
182
|
+
self._latest_prefix = latest_prefix
|
|
183
|
+
self._include_version_docs = include_version_docs
|
|
184
|
+
self._include_version_openapi_route = include_version_openapi_route
|
|
185
|
+
self._include_versions_route = include_versions_route
|
|
186
|
+
self._sort_routes = sort_routes
|
|
187
|
+
self._callback = callback
|
|
188
|
+
self._webhook_routers: list[APIRouter] | None = (
|
|
189
|
+
[webhook_routers] if isinstance(webhook_routers, APIRouter) else webhook_routers
|
|
190
|
+
)
|
|
191
|
+
self._openapi_hook = openapi_hook
|
|
192
|
+
self._swagger_js_url = swagger_js_url
|
|
193
|
+
self._swagger_css_url = swagger_css_url
|
|
194
|
+
self._swagger_favicon_url = swagger_favicon_url
|
|
195
|
+
self._redoc_js_url = redoc_js_url
|
|
196
|
+
self._redoc_favicon_url = redoc_favicon_url
|
|
197
|
+
self._redoc_with_google_fonts = redoc_with_google_fonts
|
|
198
|
+
|
|
199
|
+
if default_version is None:
|
|
200
|
+
self._default_version: VersionT = (1, 0) if version_format == VersionFormat.SEMVER else "1"
|
|
201
|
+
else:
|
|
202
|
+
self._validate_version_type(default_version, "default_version fallback")
|
|
203
|
+
self._default_version = default_version
|
|
204
|
+
|
|
205
|
+
self._docs_url = getattr(app, "docs_url", "/docs")
|
|
206
|
+
self._redoc_url = getattr(app, "redoc_url", "/redoc")
|
|
207
|
+
|
|
208
|
+
def _validate_version_type(self, version: Any, route_path: str) -> None:
|
|
209
|
+
if self._version_format == VersionFormat.SEMVER:
|
|
210
|
+
if not isinstance(version, tuple) or len(version) != 2 or not all(isinstance(i, int) for i in version):
|
|
211
|
+
error_msg = f"RouterVersioner expects SEMVER, but found an invalid version '{version}' on {route_path}. Use a tuple of exactly two integers: (major, minor). e.g., (1, 0)."
|
|
212
|
+
raise ValueError(error_msg)
|
|
213
|
+
elif self._version_format == VersionFormat.CALVER:
|
|
214
|
+
if not isinstance(version, str):
|
|
215
|
+
error_msg = f"RouterVersioner expects CALVER, but found a non-string version '{version}' on {route_path}. Use a string like '2025-01-01'."
|
|
216
|
+
raise ValueError(error_msg)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _format_string(format_str: str, version: VersionT) -> str:
|
|
220
|
+
if isinstance(version, tuple):
|
|
221
|
+
return format_str.format(major=version[0], minor=version[1], version=f"{version[0]}_{version[1]}")
|
|
222
|
+
return format_str.format(version=version, major=version, minor=version)
|
|
223
|
+
|
|
224
|
+
def _extract_version_attribute(self, endpoint: Any, attribute: str, route_path: str) -> VersionT | None:
|
|
225
|
+
val = getattr(endpoint, attribute, None)
|
|
226
|
+
if isinstance(val, (tuple, str)):
|
|
227
|
+
self._validate_version_type(val, route_path)
|
|
228
|
+
return val
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def versionize(self) -> list[VersionT]:
|
|
232
|
+
latest_version: VersionT | None = None
|
|
233
|
+
latest_routes: dict[tuple[str, str], Any] = {}
|
|
234
|
+
latest_webhooks: list[Any] = []
|
|
235
|
+
|
|
236
|
+
routes_by_version = self._get_routes_by_version()
|
|
237
|
+
versions = list(routes_by_version.keys())
|
|
238
|
+
webhooks_by_version = self._get_webhooks_by_version()
|
|
239
|
+
|
|
240
|
+
for version, routes_by_key in routes_by_version.items():
|
|
241
|
+
version_prefix = self._format_string(self._prefix_format, version)
|
|
242
|
+
active_webhooks = self._resolve_webhooks_for_version(version, webhooks_by_version)
|
|
243
|
+
|
|
244
|
+
version_router = self._build_version_router(
|
|
245
|
+
version=version, version_prefix=version_prefix, routes_by_key=routes_by_key, webhooks=active_webhooks
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if self._callback:
|
|
249
|
+
self._callback(version_router, version, version_prefix)
|
|
250
|
+
|
|
251
|
+
self._app.include_router(router=version_router)
|
|
252
|
+
|
|
253
|
+
latest_version = version
|
|
254
|
+
latest_routes = routes_by_key
|
|
255
|
+
latest_webhooks = active_webhooks
|
|
256
|
+
|
|
257
|
+
if self._latest_prefix is not None and latest_version is not None:
|
|
258
|
+
latest_router = self._build_version_router(
|
|
259
|
+
version=latest_version,
|
|
260
|
+
version_prefix=self._latest_prefix,
|
|
261
|
+
routes_by_key=latest_routes,
|
|
262
|
+
webhooks=latest_webhooks,
|
|
263
|
+
)
|
|
264
|
+
if self._callback:
|
|
265
|
+
self._callback(latest_router, latest_version, self._latest_prefix)
|
|
266
|
+
self._app.include_router(router=latest_router)
|
|
267
|
+
|
|
268
|
+
if self._include_versions_route:
|
|
269
|
+
self._add_versions_route(versions=versions)
|
|
270
|
+
|
|
271
|
+
return versions
|
|
272
|
+
|
|
273
|
+
def _build_version_router(
|
|
274
|
+
self,
|
|
275
|
+
version: VersionT,
|
|
276
|
+
version_prefix: str,
|
|
277
|
+
routes_by_key: dict[tuple[str, str], Any],
|
|
278
|
+
webhooks: list[Any],
|
|
279
|
+
) -> APIRouter:
|
|
280
|
+
router = APIRouter(prefix=version_prefix, responses=self._app.router.responses)
|
|
281
|
+
|
|
282
|
+
if self._sort_routes:
|
|
283
|
+
routes_by_key = dict(sorted(routes_by_key.items()))
|
|
284
|
+
|
|
285
|
+
for route in routes_by_key.values():
|
|
286
|
+
self._add_route_to_router(route=route, router=router, version=version)
|
|
287
|
+
|
|
288
|
+
self._add_version_docs(router=router, version=version, version_prefix=version_prefix, webhooks=webhooks)
|
|
289
|
+
|
|
290
|
+
return router
|
|
291
|
+
|
|
292
|
+
def _get_routes_by_version(self) -> dict[VersionT, dict[tuple[str, str], Any]]:
|
|
293
|
+
all_routes: list[Any] = []
|
|
294
|
+
for router in self._routers:
|
|
295
|
+
all_routes.extend(_iter_routes_flat(router.routes))
|
|
296
|
+
|
|
297
|
+
routes_introduced: dict[VersionT, list[Any]] = defaultdict(list)
|
|
298
|
+
for route in all_routes:
|
|
299
|
+
start_version = self._extract_version_attribute(route.endpoint, _ATTR_API_VERSION, route.path)
|
|
300
|
+
if start_version is None:
|
|
301
|
+
start_version = self._default_version
|
|
302
|
+
routes_introduced[start_version].append(route)
|
|
303
|
+
|
|
304
|
+
routes_removed: dict[VersionT, list[Any]] = defaultdict(list)
|
|
305
|
+
for route in all_routes:
|
|
306
|
+
end_version = self._extract_version_attribute(route.endpoint, _ATTR_REMOVE_IN, route.path)
|
|
307
|
+
if end_version is not None:
|
|
308
|
+
routes_removed[end_version].append(route)
|
|
309
|
+
|
|
310
|
+
all_version_keys = set(routes_introduced.keys()) | set(routes_removed.keys())
|
|
311
|
+
versions = sorted(all_version_keys)
|
|
312
|
+
routes_by_version: dict[VersionT, dict[tuple[str, str], Any]] = {}
|
|
313
|
+
active_routes: dict[tuple[str, str], Any] = {}
|
|
314
|
+
|
|
315
|
+
for version in versions:
|
|
316
|
+
for route in routes_introduced[version]:
|
|
317
|
+
active_routes.update(self._get_route_keys(route=route))
|
|
318
|
+
|
|
319
|
+
for route in routes_removed.get(version, []):
|
|
320
|
+
for route_key in self._get_route_keys(route=route):
|
|
321
|
+
active_routes.pop(route_key, None)
|
|
322
|
+
|
|
323
|
+
routes_by_version[version] = dict(active_routes)
|
|
324
|
+
|
|
325
|
+
return routes_by_version
|
|
326
|
+
|
|
327
|
+
def _get_webhooks_by_version(self) -> dict[VersionT, list[Any]]:
|
|
328
|
+
if not self._webhook_routers:
|
|
329
|
+
return {}
|
|
330
|
+
|
|
331
|
+
all_webhooks: list[Any] = []
|
|
332
|
+
for router in self._webhook_routers:
|
|
333
|
+
all_webhooks.extend(_iter_routes_flat(router.routes))
|
|
334
|
+
|
|
335
|
+
webhooks_introduced: dict[VersionT, list[Any]] = defaultdict(list)
|
|
336
|
+
for route in all_webhooks:
|
|
337
|
+
v = self._extract_version_attribute(route.endpoint, _ATTR_API_VERSION, route.path)
|
|
338
|
+
webhooks_introduced[v if v is not None else self._default_version].append(route)
|
|
339
|
+
|
|
340
|
+
webhooks_removed: dict[VersionT, list[Any]] = defaultdict(list)
|
|
341
|
+
for route in all_webhooks:
|
|
342
|
+
v = self._extract_version_attribute(route.endpoint, _ATTR_REMOVE_IN, route.path)
|
|
343
|
+
if v is not None:
|
|
344
|
+
webhooks_removed[v].append(route)
|
|
345
|
+
|
|
346
|
+
combined_keys = set(webhooks_introduced.keys()) | set(webhooks_removed.keys())
|
|
347
|
+
result: dict[VersionT, list[Any]] = {}
|
|
348
|
+
active: dict[tuple[str, str], Any] = {}
|
|
349
|
+
|
|
350
|
+
for version in sorted(combined_keys):
|
|
351
|
+
for route in webhooks_introduced[version]:
|
|
352
|
+
active.update(self._get_route_keys(route=route))
|
|
353
|
+
for route in webhooks_removed.get(version, []):
|
|
354
|
+
for key in self._get_route_keys(route=route):
|
|
355
|
+
active.pop(key, None)
|
|
356
|
+
result[version] = list(active.values())
|
|
357
|
+
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
def _resolve_webhooks_for_version(
|
|
361
|
+
self, version: VersionT, webhooks_by_version: dict[VersionT, list[Any]]
|
|
362
|
+
) -> list[Any]:
|
|
363
|
+
if not webhooks_by_version:
|
|
364
|
+
# webhook_routers not provided: fall back to global app.webhooks
|
|
365
|
+
return list(self._app.webhooks.routes)
|
|
366
|
+
candidates: list[VersionT] = []
|
|
367
|
+
if isinstance(version, tuple):
|
|
368
|
+
candidates = [v for v in webhooks_by_version if isinstance(v, tuple) and v <= version]
|
|
369
|
+
else:
|
|
370
|
+
candidates = [v for v in webhooks_by_version if isinstance(v, str) and v <= version]
|
|
371
|
+
if not candidates:
|
|
372
|
+
return []
|
|
373
|
+
return webhooks_by_version[max(candidates)]
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def _get_route_keys(cls, route: Any) -> dict[tuple[str, str], Any]:
|
|
377
|
+
path = route.path
|
|
378
|
+
routes_by_key: dict[tuple[str, str], Any] = {}
|
|
379
|
+
route_type = _unwrap_route(route)
|
|
380
|
+
|
|
381
|
+
if isinstance(route_type, APIRoute):
|
|
382
|
+
for method in route.methods:
|
|
383
|
+
routes_by_key[(path, method)] = route
|
|
384
|
+
elif isinstance(route_type, APIWebSocketRoute):
|
|
385
|
+
routes_by_key[(path, "")] = route
|
|
386
|
+
|
|
387
|
+
return routes_by_key
|
|
388
|
+
|
|
389
|
+
def _add_version_docs(self, router: APIRouter, version: VersionT, version_prefix: str, webhooks: list[Any]) -> None:
|
|
390
|
+
doc_version_str = self._format_string(self._semantic_version_format, version)
|
|
391
|
+
title = f"{self._app.title} - v{doc_version_str}"
|
|
392
|
+
versioned_tags = self._collect_versioned_tags(router)
|
|
393
|
+
openapi_url = self._app.openapi_url
|
|
394
|
+
|
|
395
|
+
if self._include_version_openapi_route and openapi_url is not None:
|
|
396
|
+
self._add_openapi_route(router, title, doc_version_str, versioned_tags, openapi_url, version, webhooks)
|
|
397
|
+
|
|
398
|
+
if self._include_version_docs and self._docs_url is not None and openapi_url is not None:
|
|
399
|
+
self._add_swagger_ui_routes(router, title, version_prefix, self._docs_url, openapi_url)
|
|
400
|
+
|
|
401
|
+
if self._include_version_docs and self._redoc_url is not None and openapi_url is not None:
|
|
402
|
+
self._add_redoc_route(router, title, version_prefix, self._redoc_url, openapi_url)
|
|
403
|
+
|
|
404
|
+
def _collect_versioned_tags(self, router: APIRouter) -> list[dict[str, Any]]:
|
|
405
|
+
if self._app.openapi_tags is None:
|
|
406
|
+
return []
|
|
407
|
+
tags: set[str | Enum] = set()
|
|
408
|
+
for route in router.routes:
|
|
409
|
+
if isinstance(route, APIRoute) and isinstance(route.tags, list):
|
|
410
|
+
tags.update(route.tags)
|
|
411
|
+
if not tags:
|
|
412
|
+
return []
|
|
413
|
+
return [tag for tag in self._app.openapi_tags if tag["name"] in tags]
|
|
414
|
+
|
|
415
|
+
def _add_openapi_route(
|
|
416
|
+
self,
|
|
417
|
+
router: APIRouter,
|
|
418
|
+
title: str,
|
|
419
|
+
doc_version_str: str,
|
|
420
|
+
versioned_tags: list[dict[str, Any]],
|
|
421
|
+
openapi_url: str,
|
|
422
|
+
version: VersionT,
|
|
423
|
+
webhooks: list[Any],
|
|
424
|
+
) -> None:
|
|
425
|
+
@router.get(openapi_url, include_in_schema=False)
|
|
426
|
+
async def get_openapi(req: Request) -> Any:
|
|
427
|
+
schema = fastapi.openapi.utils.get_openapi(
|
|
428
|
+
title=title,
|
|
429
|
+
version=doc_version_str,
|
|
430
|
+
openapi_version=self._app.openapi_version,
|
|
431
|
+
summary=self._app.summary,
|
|
432
|
+
description=self._app.description,
|
|
433
|
+
routes=router.routes,
|
|
434
|
+
webhooks=webhooks,
|
|
435
|
+
tags=versioned_tags,
|
|
436
|
+
servers=self._app.servers,
|
|
437
|
+
terms_of_service=self._app.terms_of_service,
|
|
438
|
+
contact=self._app.contact,
|
|
439
|
+
license_info=self._app.license_info,
|
|
440
|
+
separate_input_output_schemas=self._app.separate_input_output_schemas,
|
|
441
|
+
external_docs=self._app.openapi_external_docs,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
root_path = req.scope.get("root_path", "").rstrip("/")
|
|
445
|
+
if root_path and getattr(self._app, "root_path_in_servers", True):
|
|
446
|
+
server_urls = {s.get("url") for s in schema.get("servers", [])}
|
|
447
|
+
if root_path not in server_urls:
|
|
448
|
+
schema = dict(schema)
|
|
449
|
+
schema["servers"] = [{"url": root_path}] + schema.get("servers", [])
|
|
450
|
+
|
|
451
|
+
if self._openapi_hook is not None:
|
|
452
|
+
schema = self._openapi_hook(schema, version)
|
|
453
|
+
|
|
454
|
+
return schema
|
|
455
|
+
|
|
456
|
+
def _add_swagger_ui_routes(
|
|
457
|
+
self, router: APIRouter, title: str, version_prefix: str, docs_url: str, openapi_url: str
|
|
458
|
+
) -> None:
|
|
459
|
+
swagger_asset_kwargs: dict[str, Any] = {}
|
|
460
|
+
if self._swagger_js_url is not None:
|
|
461
|
+
swagger_asset_kwargs["swagger_js_url"] = self._swagger_js_url
|
|
462
|
+
if self._swagger_css_url is not None:
|
|
463
|
+
swagger_asset_kwargs["swagger_css_url"] = self._swagger_css_url
|
|
464
|
+
if self._swagger_favicon_url is not None:
|
|
465
|
+
swagger_asset_kwargs["swagger_favicon_url"] = self._swagger_favicon_url
|
|
466
|
+
|
|
467
|
+
# root_path is resolved at request time (mirrors FastAPI's own /docs handler).
|
|
468
|
+
@router.get(docs_url, include_in_schema=False)
|
|
469
|
+
async def get_docs(request: Request) -> HTMLResponse:
|
|
470
|
+
root_path = request.scope.get("root_path", "").rstrip("/")
|
|
471
|
+
versioned_openapi_url = f"{root_path}{version_prefix}{openapi_url}"
|
|
472
|
+
oauth2_redirect_url = (
|
|
473
|
+
f"{root_path}{version_prefix}{self._app.swagger_ui_oauth2_redirect_url}"
|
|
474
|
+
if self._app.swagger_ui_oauth2_redirect_url
|
|
475
|
+
else None
|
|
476
|
+
)
|
|
477
|
+
return get_swagger_ui_html(
|
|
478
|
+
openapi_url=versioned_openapi_url,
|
|
479
|
+
title=title,
|
|
480
|
+
oauth2_redirect_url=oauth2_redirect_url,
|
|
481
|
+
init_oauth=self._app.swagger_ui_init_oauth,
|
|
482
|
+
swagger_ui_parameters=self._app.swagger_ui_parameters,
|
|
483
|
+
**swagger_asset_kwargs,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if self._app.swagger_ui_oauth2_redirect_url:
|
|
487
|
+
|
|
488
|
+
@router.get(self._app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
|
|
489
|
+
async def get_oauth2_redirect(_request: Request) -> HTMLResponse:
|
|
490
|
+
return get_swagger_ui_oauth2_redirect_html()
|
|
491
|
+
|
|
492
|
+
def _add_redoc_route(
|
|
493
|
+
self, router: APIRouter, title: str, version_prefix: str, redoc_url: str, openapi_url: str
|
|
494
|
+
) -> None:
|
|
495
|
+
redoc_asset_kwargs: dict[str, Any] = {"with_google_fonts": self._redoc_with_google_fonts}
|
|
496
|
+
if self._redoc_js_url is not None:
|
|
497
|
+
redoc_asset_kwargs["redoc_js_url"] = self._redoc_js_url
|
|
498
|
+
if self._redoc_favicon_url is not None:
|
|
499
|
+
redoc_asset_kwargs["redoc_favicon_url"] = self._redoc_favicon_url
|
|
500
|
+
|
|
501
|
+
@router.get(redoc_url, include_in_schema=False)
|
|
502
|
+
async def get_redoc(request: Request) -> HTMLResponse:
|
|
503
|
+
root_path = request.scope.get("root_path", "").rstrip("/")
|
|
504
|
+
versioned_openapi_url = f"{root_path}{version_prefix}{openapi_url}"
|
|
505
|
+
return get_redoc_html(openapi_url=versioned_openapi_url, title=title, **redoc_asset_kwargs)
|
|
506
|
+
|
|
507
|
+
def _add_versions_route(self, versions: list[VersionT]) -> None:
|
|
508
|
+
@self._app.get("/versions", tags=["Versions"], response_class=JSONResponse)
|
|
509
|
+
def get_versions(request: Request) -> dict[str, Any]:
|
|
510
|
+
root_path = request.scope.get("root_path", "").rstrip("/")
|
|
511
|
+
version_models: list[dict[str, Any]] = []
|
|
512
|
+
for version in versions:
|
|
513
|
+
version_prefix = self._format_string(self._prefix_format, version)
|
|
514
|
+
doc_version_str = self._format_string(self._semantic_version_format, version)
|
|
515
|
+
|
|
516
|
+
version_model = {"version": doc_version_str}
|
|
517
|
+
|
|
518
|
+
if self._include_version_openapi_route and self._app.openapi_url is not None:
|
|
519
|
+
version_model["openapi_url"] = f"{root_path}{version_prefix}{self._app.openapi_url}"
|
|
520
|
+
if self._include_version_docs and self._docs_url is not None:
|
|
521
|
+
version_model["swagger_url"] = f"{root_path}{version_prefix}{self._docs_url}"
|
|
522
|
+
if self._include_version_docs and self._redoc_url is not None:
|
|
523
|
+
version_model["redoc_url"] = f"{root_path}{version_prefix}{self._redoc_url}"
|
|
524
|
+
|
|
525
|
+
version_models.append(version_model)
|
|
526
|
+
|
|
527
|
+
return {"versions": version_models}
|
|
528
|
+
|
|
529
|
+
def _add_route_to_router(self, route: Any, router: APIRouter, version: VersionT) -> None:
|
|
530
|
+
# Read attributes from the original route, not the RouteContext proxy. The proxy
|
|
531
|
+
# (FastAPI >= 0.137.2) only merges path/tags/deps; other fields such as
|
|
532
|
+
# response_model, status_code, and operation_id would be silently lost.
|
|
533
|
+
source_route = _unwrap_route(route)
|
|
534
|
+
add_method: Callable[..., Any]
|
|
535
|
+
|
|
536
|
+
if isinstance(source_route, APIRoute):
|
|
537
|
+
add_method = router.add_api_route
|
|
538
|
+
elif isinstance(source_route, APIWebSocketRoute):
|
|
539
|
+
add_method = router.add_api_websocket_route
|
|
540
|
+
else:
|
|
541
|
+
raise TypeError(f"Unsupported route type: {type(source_route).__name__}")
|
|
542
|
+
|
|
543
|
+
valid_params = inspect.signature(add_method).parameters.keys()
|
|
544
|
+
filtered_kwargs = {k: getattr(source_route, k) for k in valid_params if hasattr(source_route, k)}
|
|
545
|
+
filtered_kwargs.setdefault("endpoint", source_route.endpoint)
|
|
546
|
+
# Override path/tags/deps with the merged values from RouteContext when present.
|
|
547
|
+
for merged_attr in ("path", "tags", "dependencies"):
|
|
548
|
+
if hasattr(route, merged_attr) and merged_attr in valid_params:
|
|
549
|
+
filtered_kwargs[merged_attr] = getattr(route, merged_attr)
|
|
550
|
+
|
|
551
|
+
deprecated_in_version = self._extract_version_attribute(route.endpoint, _ATTR_DEPRECATE_IN, route.path)
|
|
552
|
+
if deprecated_in_version is not None:
|
|
553
|
+
if isinstance(version, tuple) and isinstance(deprecated_in_version, tuple):
|
|
554
|
+
if version >= deprecated_in_version:
|
|
555
|
+
filtered_kwargs["deprecated"] = True
|
|
556
|
+
elif isinstance(version, str) and isinstance(deprecated_in_version, str):
|
|
557
|
+
if version >= deprecated_in_version:
|
|
558
|
+
filtered_kwargs["deprecated"] = True
|
|
559
|
+
|
|
560
|
+
# An empty string name causes an internal FastAPI error; drop it to use the default.
|
|
561
|
+
if "name" in filtered_kwargs and not filtered_kwargs["name"]:
|
|
562
|
+
filtered_kwargs.pop("name")
|
|
563
|
+
|
|
564
|
+
add_method(**filtered_kwargs)
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-router-versioning
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native, router-based API versioning for FastAPI, featuring per-version docs and a declarative route lifecycle.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mat81black/fastapi-router-versioning
|
|
6
|
+
Project-URL: Repository, https://github.com/mat81black/fastapi-router-versioning
|
|
7
|
+
Project-URL: Issues, https://github.com/mat81black/fastapi-router-versioning/issues
|
|
8
|
+
Author-email: Matteo Nieddu <mat81black@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: fastapi!=0.137.0,!=0.137.1,>=0.120.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# FastAPI Router Versioning (Native API Versioning)
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/fastapi-router-versioning/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](https://pypi.org/project/fastapi-router-versioning/)
|
|
34
|
+
|
|
35
|
+
The native, router-based solution for **API versioning in FastAPI**.
|
|
36
|
+
|
|
37
|
+
If you want to implement **FastAPI API versioning**, you can simply annotate your routes with `@api_version`, call `.versionize()`, and get isolated URL prefixes, per-version Swagger UI, and a full route lifecycle — all without touching your existing application structure.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **SemVer and CalVer** — version routes with `(major, minor)` tuples or arbitrary strings
|
|
44
|
+
- **Per-version docs** — isolated Swagger UI, ReDoc, and `openapi.json` for every version
|
|
45
|
+
- **Declarative lifecycle** — introduce, deprecate, and remove routes with a single decorator
|
|
46
|
+
- **Latest alias** — serve the newest version under a stable `/latest` prefix
|
|
47
|
+
- **Self-hosted docs** — point Swagger UI and ReDoc at your own assets for air-gapped environments
|
|
48
|
+
- **Reverse proxy aware** — doc URLs include the ASGI `root_path` at request time, so sub-app mounting works out of the box
|
|
49
|
+
- **Broad compatibility** — works with nested routers, WebSockets, `Depends`, and OpenAPI Callbacks
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python ≥ 3.10
|
|
56
|
+
- FastAPI ≥ 0.120.0
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install fastapi-router-versioning
|
|
64
|
+
# or
|
|
65
|
+
uv add fastapi-router-versioning
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Quick start — SemVer
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from fastapi import APIRouter, FastAPI
|
|
74
|
+
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
75
|
+
|
|
76
|
+
app = FastAPI()
|
|
77
|
+
router = APIRouter()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("/items")
|
|
81
|
+
@api_version((1, 0))
|
|
82
|
+
def get_items_v1():
|
|
83
|
+
return {"version": "1.0", "items": ["a", "b"]}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/items")
|
|
87
|
+
@api_version((2, 0))
|
|
88
|
+
def get_items_v2():
|
|
89
|
+
return {"version": "2.0", "items": ["a", "b", "c"]}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).versionize()
|
|
93
|
+
# Mounts: GET /v1_0/items GET /v2_0/items
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Each version also gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Quick start — CalVer
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from fastapi import APIRouter, FastAPI
|
|
104
|
+
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
105
|
+
|
|
106
|
+
app = FastAPI()
|
|
107
|
+
router = APIRouter()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("/items")
|
|
111
|
+
@api_version("2025-01-01")
|
|
112
|
+
def get_items():
|
|
113
|
+
return {"release": "2025-01-01"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).versionize()
|
|
117
|
+
# Mounts: GET /2025-01-01/items
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Any string is a valid CalVer token: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
|
|
121
|
+
|
|
122
|
+
> **CalVer sorting:** versions are sorted lexicographically, so strings must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Strings like `"v1"`, `"v10"`, `"v2"` will **not** sort correctly and will cause routes to appear in the wrong versions.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Route lifecycle
|
|
127
|
+
|
|
128
|
+
Use `deprecate_in` and `remove_in` to manage the full lifecycle of a route across versions.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
@router.get("/legacy")
|
|
132
|
+
@api_version((1, 0), deprecate_in=(2, 0), remove_in=(3, 0))
|
|
133
|
+
def legacy_route():
|
|
134
|
+
return {"msg": "I am stable in v1, deprecated in v2, gone in v3."}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Version | `/legacy` present? | Marked deprecated? |
|
|
138
|
+
|---------|-------------------|--------------------|
|
|
139
|
+
| v1.0 | yes | no |
|
|
140
|
+
| v2.0 | yes | **yes** |
|
|
141
|
+
| v3.0 | **no** | — |
|
|
142
|
+
|
|
143
|
+
Routes without `@api_version` fall back to `default_version` (default: `(1, 0)` for SemVer, `"1"` for CalVer).
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## `RouterVersioner` reference
|
|
148
|
+
|
|
149
|
+
| Parameter | Type | Default | Description |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `app` | `FastAPI` | required | The FastAPI application instance |
|
|
152
|
+
| `routers` | `APIRouter \| list[APIRouter]` | required | Router(s) whose routes will be versioned |
|
|
153
|
+
| `version_format` | `VersionFormat` | `SEMVER` | Versioning strategy (`SEMVER` or `CALVER`) |
|
|
154
|
+
| `prefix_format` | `str \| None` | `/v{major}_{minor}` / `/{version}` | URL prefix template; supports `{major}`, `{minor}`, `{version}` |
|
|
155
|
+
| `semantic_version_format` | `str \| None` | `{major}.{minor}` / `{version}` | Version label used in Swagger/ReDoc titles |
|
|
156
|
+
| `default_version` | `VersionT \| None` | `(1, 0)` / `"1"` | Fallback version for routes without `@api_version` |
|
|
157
|
+
| `latest_prefix` | `str \| None` | `None` | If set, adds an alias prefix (e.g. `"/latest"`) pointing to the newest version |
|
|
158
|
+
| `include_version_docs` | `bool` | `True` | Create per-version Swagger UI and ReDoc pages |
|
|
159
|
+
| `include_version_openapi_route` | `bool` | `True` | Create a per-version `openapi.json` route |
|
|
160
|
+
| `include_versions_route` | `bool` | `False` | Add a `GET /versions` endpoint listing all active versions |
|
|
161
|
+
| `sort_routes` | `bool` | `False` | Sort routes alphabetically by path within each version |
|
|
162
|
+
| `callback` | `Callable[[APIRouter, VersionT, str], None] \| None` | `None` | Hook called once per versioned router, before it is included in the app |
|
|
163
|
+
| `webhook_routers` | `APIRouter \| list[APIRouter] \| None` | `None` | Router(s) containing webhook definitions annotated with `@api_version`; each version's schema shows only the webhooks active in that version |
|
|
164
|
+
| `openapi_hook` | `Callable[[dict, VersionT], dict] \| None` | `None` | Hook applied to the generated OpenAPI schema for each version; receives `(schema, version)` and must return the modified schema |
|
|
165
|
+
| `swagger_js_url` | `str \| None` | FastAPI CDN | Custom URL for the Swagger UI JS bundle |
|
|
166
|
+
| `swagger_css_url` | `str \| None` | FastAPI CDN | Custom URL for the Swagger UI CSS |
|
|
167
|
+
| `swagger_favicon_url` | `str \| None` | FastAPI favicon | Custom URL for the Swagger UI favicon |
|
|
168
|
+
| `redoc_js_url` | `str \| None` | FastAPI CDN | Custom URL for the ReDoc JS bundle |
|
|
169
|
+
| `redoc_favicon_url` | `str \| None` | FastAPI favicon | Custom URL for the ReDoc favicon |
|
|
170
|
+
| `redoc_with_google_fonts` | `bool` | `True` | If `False`, ReDoc will not load Google Fonts |
|
|
171
|
+
|
|
172
|
+
Call `.versionize()` after constructing the object. It returns the list of active versions.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## `@api_version` reference
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@api_version(version, *, deprecate_in=None, remove_in=None)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
| Parameter | Type | Required | Description |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| `version` | `tuple[int, int] \| str` | yes | Version when this route is introduced |
|
|
185
|
+
| `deprecate_in` | same \| `None` | no | Version when this route is marked deprecated in the docs |
|
|
186
|
+
| `remove_in` | same \| `None` | no | Version from which this route is removed entirely |
|
|
187
|
+
|
|
188
|
+
All three parameters must match the `version_format` configured on `RouterVersioner`
|
|
189
|
+
(`tuple[int, int]` for SemVer, `str` for CalVer).
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Advanced options
|
|
194
|
+
|
|
195
|
+
### Latest alias
|
|
196
|
+
|
|
197
|
+
Serve the newest version under a stable prefix that clients can pin to:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
RouterVersioner(
|
|
201
|
+
app=app,
|
|
202
|
+
routers=router,
|
|
203
|
+
version_format=VersionFormat.SEMVER,
|
|
204
|
+
latest_prefix="/latest",
|
|
205
|
+
).versionize()
|
|
206
|
+
# Also mounts /latest/... pointing to the highest version
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Version discovery endpoint
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
RouterVersioner(
|
|
213
|
+
app=app,
|
|
214
|
+
routers=router,
|
|
215
|
+
version_format=VersionFormat.SEMVER,
|
|
216
|
+
include_versions_route=True,
|
|
217
|
+
).versionize()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
GET /versions
|
|
222
|
+
{
|
|
223
|
+
"versions": [
|
|
224
|
+
{
|
|
225
|
+
"version": "1.0",
|
|
226
|
+
"openapi_url": "/v1_0/openapi.json",
|
|
227
|
+
"swagger_url": "/v1_0/docs",
|
|
228
|
+
"redoc_url": "/v1_0/redoc"
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Custom URL format
|
|
235
|
+
|
|
236
|
+
Use `prefix_format` and `semantic_version_format` to control how versions appear in URLs and docs.
|
|
237
|
+
|
|
238
|
+
**Major-only versioning** (`/v1`, `/v2`, `/v3`):
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
RouterVersioner(
|
|
242
|
+
app=app,
|
|
243
|
+
routers=router,
|
|
244
|
+
version_format=VersionFormat.SEMVER,
|
|
245
|
+
prefix_format="/v{major}",
|
|
246
|
+
semantic_version_format="{major}",
|
|
247
|
+
latest_prefix="/latest",
|
|
248
|
+
).versionize()
|
|
249
|
+
# Mounts: GET /v1/items GET /v2/items GET /latest/items
|
|
250
|
+
# Swagger at /v1/docs, /v2/docs — titles show "v1", "v2"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The route decorator still uses `(major, minor)` tuples — only the URL and doc label change.
|
|
254
|
+
|
|
255
|
+
### OpenAPI schema hook
|
|
256
|
+
|
|
257
|
+
`openapi_hook` lets you modify the generated OpenAPI JSON for each version — useful for
|
|
258
|
+
custom extensions, logos, version-specific metadata, or AWS API Gateway integration.
|
|
259
|
+
Unlike overriding `app.openapi`, this hook is called inside the per-version generation
|
|
260
|
+
pipeline, so it receives the correct filtered schema.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
def my_openapi_hook(schema: dict, version: tuple[int, int]) -> dict:
|
|
264
|
+
# Applied to every version
|
|
265
|
+
schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}
|
|
266
|
+
|
|
267
|
+
# Applied only to v1
|
|
268
|
+
if version == (1, 0):
|
|
269
|
+
schema["info"]["description"] += "\n\n**DEPRECATED:** Use v2."
|
|
270
|
+
|
|
271
|
+
return schema
|
|
272
|
+
|
|
273
|
+
RouterVersioner(
|
|
274
|
+
app=app,
|
|
275
|
+
routers=router,
|
|
276
|
+
version_format=VersionFormat.SEMVER,
|
|
277
|
+
openapi_hook=my_openapi_hook,
|
|
278
|
+
).versionize()
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The hook receives `(schema: dict, version: VersionT)` and must return the (modified) dict.
|
|
282
|
+
|
|
283
|
+
### OpenAPI Callbacks and Webhooks
|
|
284
|
+
|
|
285
|
+
**Callbacks** (per-route) are propagated automatically — any `callbacks=[...]` parameter
|
|
286
|
+
on a route is copied to every versioned copy of that route:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
callback_router = APIRouter()
|
|
290
|
+
|
|
291
|
+
@callback_router.post("{$url}")
|
|
292
|
+
def on_event(body: dict) -> None: ...
|
|
293
|
+
|
|
294
|
+
@router.post("/items", callbacks=callback_router.routes)
|
|
295
|
+
@api_version((1, 0))
|
|
296
|
+
def create_item() -> dict: ...
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Webhooks** (`app.webhooks`) appear in the OpenAPI schema of every version by default.
|
|
300
|
+
To version webhooks independently, use `webhook_routers` with the same `@api_version`
|
|
301
|
+
decorator used on regular routes:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
webhook_router = APIRouter()
|
|
305
|
+
|
|
306
|
+
@webhook_router.post("/order-created")
|
|
307
|
+
@api_version((1, 0))
|
|
308
|
+
def webhook_order_v1(body: OrderV1) -> None: ...
|
|
309
|
+
|
|
310
|
+
@webhook_router.post("/order-created")
|
|
311
|
+
@api_version((2, 0)) # replaces v1 definition (same path + method)
|
|
312
|
+
def webhook_order_v2(body: OrderV2) -> None: ...
|
|
313
|
+
|
|
314
|
+
@webhook_router.post("/payment-failed")
|
|
315
|
+
@api_version((1, 0), remove_in=(2, 0))
|
|
316
|
+
def webhook_payment_v1(body: dict) -> None: ...
|
|
317
|
+
|
|
318
|
+
RouterVersioner(
|
|
319
|
+
app=app,
|
|
320
|
+
routers=router,
|
|
321
|
+
webhook_routers=webhook_router,
|
|
322
|
+
version_format=VersionFormat.SEMVER,
|
|
323
|
+
).versionize()
|
|
324
|
+
# /v1_0/openapi.json → webhooks: /order-created (V1), /payment-failed
|
|
325
|
+
# /v2_0/openapi.json → webhooks: /order-created (V2) ← /payment-failed removed
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The same introduce / `remove_in` lifecycle applies. Webhook versions follow route versions:
|
|
329
|
+
a new webhook version only appears once a route version creates that API prefix.
|
|
330
|
+
|
|
331
|
+
### Multiple routers
|
|
332
|
+
|
|
333
|
+
Pass a list of routers to version routes that are split across modules:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
RouterVersioner(
|
|
337
|
+
app=app,
|
|
338
|
+
routers=[users_router, products_router],
|
|
339
|
+
version_format=VersionFormat.SEMVER,
|
|
340
|
+
).versionize()
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
All routers are versioned together under the same prefix tree.
|
|
344
|
+
|
|
345
|
+
### Self-hosted docs (air-gapped environments)
|
|
346
|
+
|
|
347
|
+
By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at assets you host yourself:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
RouterVersioner(
|
|
351
|
+
app=app,
|
|
352
|
+
routers=router,
|
|
353
|
+
version_format=VersionFormat.SEMVER,
|
|
354
|
+
swagger_js_url="/static/swagger-ui-bundle.js",
|
|
355
|
+
swagger_css_url="/static/swagger-ui.css",
|
|
356
|
+
swagger_favicon_url="/static/favicon.png",
|
|
357
|
+
redoc_js_url="/static/redoc.standalone.js",
|
|
358
|
+
redoc_favicon_url="/static/favicon.png",
|
|
359
|
+
redoc_with_google_fonts=False,
|
|
360
|
+
).versionize()
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a ready-made script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
|
|
364
|
+
|
|
365
|
+
### Reverse proxy / sub-app mounting
|
|
366
|
+
|
|
367
|
+
When the app runs behind a reverse proxy or is mounted as a sub-application, the ASGI `root_path` is included in all per-version doc URLs automatically — no extra configuration needed:
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
parent = FastAPI()
|
|
371
|
+
parent.mount("/api", app) # root_path="/api" is injected at request time
|
|
372
|
+
# /api/v1_0/docs correctly references /api/v1_0/openapi.json
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Callback hook
|
|
376
|
+
|
|
377
|
+
Run custom logic each time a versioned router is created — useful for logging or metrics:
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
def on_version_created(router: APIRouter, version, prefix: str) -> None:
|
|
381
|
+
print(f"Registered version {version} at {prefix}")
|
|
382
|
+
|
|
383
|
+
RouterVersioner(
|
|
384
|
+
app=app,
|
|
385
|
+
routers=router,
|
|
386
|
+
version_format=VersionFormat.SEMVER,
|
|
387
|
+
callback=on_version_created,
|
|
388
|
+
).versionize()
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Examples
|
|
394
|
+
|
|
395
|
+
Runnable examples are available in the [`examples/`](examples/) directory:
|
|
396
|
+
|
|
397
|
+
| File | What it shows |
|
|
398
|
+
|---|---|
|
|
399
|
+
| [`semver_app.py`](examples/semver_app.py) | Full SemVer lifecycle (introduce, deprecate, remove) |
|
|
400
|
+
| [`calver_app.py`](examples/calver_app.py) | Same lifecycle with CalVer date strings |
|
|
401
|
+
| [`semver_major_only_app.py`](examples/semver_major_only_app.py) | Custom prefix `/v1`, `/v2` via `prefix_format` |
|
|
402
|
+
| [`webhook_versioning_app.py`](examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
|
|
403
|
+
| [`multi_router_app.py`](examples/multi_router_app.py) | Multiple routers versioned together |
|
|
404
|
+
| [`self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) | Swagger UI and ReDoc from local static assets |
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## License
|
|
409
|
+
|
|
410
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
fastapi_router_versioning/__init__.py,sha256=HEfhSE-ggX7QJX8vOQiUGNRhnKg2VcBUY5eds9ReD3c,175
|
|
2
|
+
fastapi_router_versioning/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
fastapi_router_versioning/versioner.py,sha256=9xBQCB_snF9pDXaCAh95IDKQ2Js_JAFVzU24zjZ7RO4,26437
|
|
4
|
+
fastapi_router_versioning-0.1.0.dist-info/METADATA,sha256=gBtOBWdNZrVaR_oDlg0w0-wMeRVoBynBnGRKD_Z7Rvo,14710
|
|
5
|
+
fastapi_router_versioning-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
fastapi_router_versioning-0.1.0.dist-info/licenses/LICENSE,sha256=Tsif_IFIW5f-xYSy1KlhAy7v_oNEU4lP2cEnSQbMdE4,1086
|
|
7
|
+
fastapi_router_versioning-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Sebastián Ramírez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|