fastapi-router-versioning 0.2.1__tar.gz → 0.2.2__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-0.2.1 → fastapi_router_versioning-0.2.2}/PKG-INFO +23 -1
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/README.md +22 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/RELEASE_NOTES.md +15 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/calver_app.py +14 -0
- fastapi_router_versioning-0.2.2/examples/mounted_subapps_app.py +76 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/multi_router_app.py +14 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/openapi_hook_app.py +14 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/self_hosted_docs_app.py +14 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/semver_app.py +14 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/semver_major_only_app.py +14 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/webhook_versioning_app.py +14 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/fastapi_router_versioning/__init__.py +1 -1
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/fastapi_router_versioning/versioner.py +228 -199
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_versioner.py +207 -2
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/dependabot.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/detect-conflicts.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/pre-commit.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/prepare-release.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/publish.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/test-redistribute.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/test.yml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.gitignore +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.pre-commit-config.yaml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.python-version +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/LICENSE +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/download_static_assets.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/validation_error_code_app.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/validation_error_code_custom_handler_app.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/fastapi_router_versioning/py.typed +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/pyproject.toml +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/format.sh +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/lint.sh +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/prepare_release.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test-cov-html.sh +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test-cov.sh +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test.sh +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/__init__.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_calver.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_fastapi_integration.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_prepare_release.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_semver.py +0 -0
- {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-router-versioning
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -400,6 +400,23 @@ RouterVersioner(
|
|
|
400
400
|
).versionize()
|
|
401
401
|
```
|
|
402
402
|
|
|
403
|
+
The app's own root `/docs`, `/redoc`, and `/openapi.json` (not versioned) also reflect
|
|
404
|
+
`validation_error_code` — RouterVersioner patches `app.openapi()` itself, so nothing needs to
|
|
405
|
+
be disabled to get consistent schemas everywhere.
|
|
406
|
+
|
|
407
|
+
**If you do share one app across multiple `RouterVersioner` instances** — the only case this is
|
|
408
|
+
actually needed for is mixing `version_format` (e.g. SemVer for one group of routes, CalVer for
|
|
409
|
+
another) on the same app, since a single instance already handles multiple routers and prefixes
|
|
410
|
+
via `routers=[...]` (see [`multi_router_app.py`](examples/multi_router_app.py)). All instances
|
|
411
|
+
must use the same `validation_error_code` when `handle_validation_exceptions=True` — since
|
|
412
|
+
FastAPI's exception handler is app-wide, not per-router, a mismatch raises `RuntimeError` at
|
|
413
|
+
construction time instead of silently picking whichever was registered first. Each instance
|
|
414
|
+
must also use a distinct `prefix_format`/`latest_prefix`, otherwise their docs/openapi routes
|
|
415
|
+
would collide at the same path — this also raises `RuntimeError`.
|
|
416
|
+
|
|
417
|
+
For genuine module independence (separate `validation_error_code`, no risk of prefix collisions,
|
|
418
|
+
isolated docs), mount each module as its own FastAPI sub-application instead — see below.
|
|
419
|
+
|
|
403
420
|
### Reverse proxy / sub-app mounting
|
|
404
421
|
|
|
405
422
|
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:
|
|
@@ -410,6 +427,10 @@ parent.mount("/api", app) # root_path="/api" is injected at request time
|
|
|
410
427
|
# /api/v1_0/docs correctly references /api/v1_0/openapi.json
|
|
411
428
|
```
|
|
412
429
|
|
|
430
|
+
Each sub-application has its own independent `RouterVersioner`, `app.state`, and OpenAPI docs —
|
|
431
|
+
no coordination needed between them, including different `validation_error_code` per
|
|
432
|
+
sub-application. See [`examples/mounted_subapps_app.py`](examples/mounted_subapps_app.py).
|
|
433
|
+
|
|
413
434
|
### Callback hook
|
|
414
435
|
|
|
415
436
|
Run custom logic each time a versioned router is created — useful for logging or metrics:
|
|
@@ -442,6 +463,7 @@ Runnable examples are available in the [`examples/`](examples/) directory:
|
|
|
442
463
|
| [`self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) | Swagger UI and ReDoc from local static assets |
|
|
443
464
|
| [`validation_error_code_app.py`](examples/validation_error_code_app.py) | Return `400` instead of `422` for validation errors (automatic handler via `RouterVersioner`) |
|
|
444
465
|
| [`validation_error_code_custom_handler_app.py`](examples/validation_error_code_custom_handler_app.py) | Same, but with `handle_validation_exceptions=False` and a user-defined exception handler |
|
|
466
|
+
| [`mounted_subapps_app.py`](examples/mounted_subapps_app.py) | Independently versioned modules as separate FastAPI sub-applications via `app.mount()` |
|
|
445
467
|
|
|
446
468
|
---
|
|
447
469
|
|
|
@@ -371,6 +371,23 @@ RouterVersioner(
|
|
|
371
371
|
).versionize()
|
|
372
372
|
```
|
|
373
373
|
|
|
374
|
+
The app's own root `/docs`, `/redoc`, and `/openapi.json` (not versioned) also reflect
|
|
375
|
+
`validation_error_code` — RouterVersioner patches `app.openapi()` itself, so nothing needs to
|
|
376
|
+
be disabled to get consistent schemas everywhere.
|
|
377
|
+
|
|
378
|
+
**If you do share one app across multiple `RouterVersioner` instances** — the only case this is
|
|
379
|
+
actually needed for is mixing `version_format` (e.g. SemVer for one group of routes, CalVer for
|
|
380
|
+
another) on the same app, since a single instance already handles multiple routers and prefixes
|
|
381
|
+
via `routers=[...]` (see [`multi_router_app.py`](examples/multi_router_app.py)). All instances
|
|
382
|
+
must use the same `validation_error_code` when `handle_validation_exceptions=True` — since
|
|
383
|
+
FastAPI's exception handler is app-wide, not per-router, a mismatch raises `RuntimeError` at
|
|
384
|
+
construction time instead of silently picking whichever was registered first. Each instance
|
|
385
|
+
must also use a distinct `prefix_format`/`latest_prefix`, otherwise their docs/openapi routes
|
|
386
|
+
would collide at the same path — this also raises `RuntimeError`.
|
|
387
|
+
|
|
388
|
+
For genuine module independence (separate `validation_error_code`, no risk of prefix collisions,
|
|
389
|
+
isolated docs), mount each module as its own FastAPI sub-application instead — see below.
|
|
390
|
+
|
|
374
391
|
### Reverse proxy / sub-app mounting
|
|
375
392
|
|
|
376
393
|
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:
|
|
@@ -381,6 +398,10 @@ parent.mount("/api", app) # root_path="/api" is injected at request time
|
|
|
381
398
|
# /api/v1_0/docs correctly references /api/v1_0/openapi.json
|
|
382
399
|
```
|
|
383
400
|
|
|
401
|
+
Each sub-application has its own independent `RouterVersioner`, `app.state`, and OpenAPI docs —
|
|
402
|
+
no coordination needed between them, including different `validation_error_code` per
|
|
403
|
+
sub-application. See [`examples/mounted_subapps_app.py`](examples/mounted_subapps_app.py).
|
|
404
|
+
|
|
384
405
|
### Callback hook
|
|
385
406
|
|
|
386
407
|
Run custom logic each time a versioned router is created — useful for logging or metrics:
|
|
@@ -413,6 +434,7 @@ Runnable examples are available in the [`examples/`](examples/) directory:
|
|
|
413
434
|
| [`self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) | Swagger UI and ReDoc from local static assets |
|
|
414
435
|
| [`validation_error_code_app.py`](examples/validation_error_code_app.py) | Return `400` instead of `422` for validation errors (automatic handler via `RouterVersioner`) |
|
|
415
436
|
| [`validation_error_code_custom_handler_app.py`](examples/validation_error_code_custom_handler_app.py) | Same, but with `handle_validation_exceptions=False` and a user-defined exception handler |
|
|
437
|
+
| [`mounted_subapps_app.py`](examples/mounted_subapps_app.py) | Independently versioned modules as separate FastAPI sub-applications via `app.mount()` |
|
|
416
438
|
|
|
417
439
|
---
|
|
418
440
|
|
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Latest Changes
|
|
4
4
|
|
|
5
|
+
## 0.2.2 (2026-07-01)
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
* 🐛 Fix root OpenAPI schema not reflecting validation_error_code. PR [#21](https://github.com/mat81black/fastapi-router-versioning/pull/21) by [@mat81black](https://github.com/mat81black).
|
|
10
|
+
* 🐛 Fix silent conflicts from misconfigured RouterVersioner instances sharing an app. PR [#20](https://github.com/mat81black/fastapi-router-versioning/pull/20) by [@mat81black](https://github.com/mat81black).
|
|
11
|
+
|
|
12
|
+
### Internal
|
|
13
|
+
|
|
14
|
+
* 👷 Add a regression test for `RouterVersioner` under a real `app.mount()` sub-application, and `examples/mounted_subapps_app.py` demonstrating the same scenario. PR [#22](https://github.com/mat81black/fastapi-router-versioning/pull/22) by [@mat81black](https://github.com/mat81black).
|
|
15
|
+
|
|
16
|
+
### Refactors
|
|
17
|
+
|
|
18
|
+
* ♻️ Reorder RouterVersioner methods to match their call graph. PR [#23](https://github.com/mat81black/fastapi-router-versioning/pull/23) by [@mat81black](https://github.com/mat81black).
|
|
19
|
+
|
|
5
20
|
## 0.2.1 (2026-06-26)
|
|
6
21
|
|
|
7
22
|
### Fixes
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from fastapi import APIRouter, FastAPI
|
|
2
|
+
from pydantic import BaseModel
|
|
2
3
|
|
|
3
4
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
4
5
|
|
|
@@ -10,6 +11,11 @@ app = FastAPI(
|
|
|
10
11
|
router = APIRouter()
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class CreateItemRequest(BaseModel):
|
|
15
|
+
name: str
|
|
16
|
+
quantity: int
|
|
17
|
+
|
|
18
|
+
|
|
13
19
|
# 1. Introduced in the January release, persists across all versions
|
|
14
20
|
@router.get("/persistent")
|
|
15
21
|
@api_version("2025-01-01")
|
|
@@ -41,6 +47,14 @@ def future_route() -> dict[str, str]:
|
|
|
41
47
|
return {"status": "active", "message": "Welcome to the December release!"}
|
|
42
48
|
|
|
43
49
|
|
|
50
|
+
# POST /items with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
51
|
+
# default 422 validation error.
|
|
52
|
+
@router.post("/items")
|
|
53
|
+
@api_version("2025-01-01")
|
|
54
|
+
def create_item(body: CreateItemRequest) -> dict[str, str]:
|
|
55
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
versioner = RouterVersioner(
|
|
45
59
|
app=app,
|
|
46
60
|
routers=router,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sub-applications example.
|
|
3
|
+
|
|
4
|
+
Shows how to mount independently versioned modules as separate FastAPI
|
|
5
|
+
sub-applications via app.mount() — each with its own RouterVersioner, docs,
|
|
6
|
+
and OpenAPI schema. RouterVersioner already threads the ASGI root_path through
|
|
7
|
+
docs/openapi URLs automatically, so no extra configuration is needed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, FastAPI
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CreateItemRequest(BaseModel):
|
|
17
|
+
name: str
|
|
18
|
+
quantity: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
main_app = FastAPI(title="Main App")
|
|
22
|
+
|
|
23
|
+
# ── Admin sub-app: SemVer, validation errors return 400 ──────────────────────
|
|
24
|
+
|
|
25
|
+
admin_app = FastAPI(title="Admin API")
|
|
26
|
+
admin_router = APIRouter()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@admin_router.get("/users")
|
|
30
|
+
@api_version((1, 0))
|
|
31
|
+
def list_admin_users() -> dict[str, list[str]]:
|
|
32
|
+
return {"users": []}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@admin_router.post("/items")
|
|
36
|
+
@api_version((1, 0))
|
|
37
|
+
def create_admin_item(body: CreateItemRequest) -> dict[str, str]:
|
|
38
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
RouterVersioner(
|
|
42
|
+
app=admin_app, routers=admin_router, version_format=VersionFormat.SEMVER, validation_error_code=400
|
|
43
|
+
).versionize()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Orders sub-app: CalVer ────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
orders_app = FastAPI(title="Orders API")
|
|
49
|
+
orders_router = APIRouter()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@orders_router.get("/")
|
|
53
|
+
@api_version("2025-01-01")
|
|
54
|
+
def list_orders() -> dict[str, list[str]]:
|
|
55
|
+
return {"orders": []}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@orders_router.post("/items")
|
|
59
|
+
@api_version("2025-01-01")
|
|
60
|
+
def create_order_item(body: CreateItemRequest) -> dict[str, str]:
|
|
61
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
RouterVersioner(app=orders_app, routers=orders_router, version_format=VersionFormat.CALVER).versionize()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Mount both sub-apps on the main app ───────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
main_app.mount("/admin", admin_app)
|
|
70
|
+
main_app.mount("/orders", orders_app)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
import uvicorn
|
|
75
|
+
|
|
76
|
+
uvicorn.run(main_app, host="127.0.0.1", port=8009)
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/multi_router_app.py
RENAMED
|
@@ -8,6 +8,7 @@ versioned together under a single prefix tree.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from fastapi import APIRouter, FastAPI
|
|
11
|
+
from pydantic import BaseModel
|
|
11
12
|
|
|
12
13
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
13
14
|
|
|
@@ -20,6 +21,11 @@ users_router = APIRouter(prefix="/users", tags=["Users"])
|
|
|
20
21
|
products_router = APIRouter(prefix="/products", tags=["Products"])
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
class CreateProductRequest(BaseModel):
|
|
25
|
+
name: str
|
|
26
|
+
quantity: int
|
|
27
|
+
|
|
28
|
+
|
|
23
29
|
@users_router.get("/")
|
|
24
30
|
@api_version((1, 0))
|
|
25
31
|
def list_users() -> dict[str, list[str]]:
|
|
@@ -44,6 +50,14 @@ def search_products() -> dict[str, list[str]]:
|
|
|
44
50
|
return {"results": []}
|
|
45
51
|
|
|
46
52
|
|
|
53
|
+
# POST /products with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
54
|
+
# default 422 validation error.
|
|
55
|
+
@products_router.post("/")
|
|
56
|
+
@api_version((1, 0))
|
|
57
|
+
def create_product(body: CreateProductRequest) -> dict[str, str]:
|
|
58
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
59
|
+
|
|
60
|
+
|
|
47
61
|
versioner = RouterVersioner(
|
|
48
62
|
app=app,
|
|
49
63
|
routers=[users_router, products_router], # list of routers versioned together
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/openapi_hook_app.py
RENAMED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
3
|
from fastapi import APIRouter, FastAPI
|
|
4
|
+
from pydantic import BaseModel
|
|
4
5
|
|
|
5
6
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, VersionT, api_version
|
|
6
7
|
|
|
@@ -12,6 +13,11 @@ app = FastAPI(
|
|
|
12
13
|
router = APIRouter()
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
class CreateItemRequest(BaseModel):
|
|
17
|
+
name: str
|
|
18
|
+
quantity: int
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
@router.get("/items")
|
|
16
22
|
@api_version((1, 0))
|
|
17
23
|
def get_items_v1() -> dict[str, object]:
|
|
@@ -30,6 +36,14 @@ def get_users() -> dict[str, object]:
|
|
|
30
36
|
return {"users": ["alice", "bob"]}
|
|
31
37
|
|
|
32
38
|
|
|
39
|
+
# POST /items with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
40
|
+
# default 422 validation error.
|
|
41
|
+
@router.post("/items")
|
|
42
|
+
@api_version((1, 0))
|
|
43
|
+
def create_item(body: CreateItemRequest) -> dict[str, str]:
|
|
44
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
def my_openapi_hook(schema: dict[str, Any], version: VersionT) -> dict[str, Any]:
|
|
34
48
|
# Applied to every version: add a company logo to the docs
|
|
35
49
|
schema["info"]["x-logo"] = {"url": "https://fastapi.tiangolo.com/img/logo-margin/logo-teal.png"}
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/self_hosted_docs_app.py
RENAMED
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
|
|
19
19
|
from fastapi import APIRouter, FastAPI
|
|
20
20
|
from fastapi.staticfiles import StaticFiles
|
|
21
|
+
from pydantic import BaseModel
|
|
21
22
|
|
|
22
23
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
23
24
|
|
|
@@ -32,12 +33,25 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
|
32
33
|
router = APIRouter()
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
class CreateItemRequest(BaseModel):
|
|
37
|
+
name: str
|
|
38
|
+
quantity: int
|
|
39
|
+
|
|
40
|
+
|
|
35
41
|
@router.get("/items")
|
|
36
42
|
@api_version((1, 0))
|
|
37
43
|
def list_items() -> dict[str, list[str]]:
|
|
38
44
|
return {"items": []}
|
|
39
45
|
|
|
40
46
|
|
|
47
|
+
# POST /items with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
48
|
+
# default 422 validation error.
|
|
49
|
+
@router.post("/items")
|
|
50
|
+
@api_version((1, 0))
|
|
51
|
+
def create_item(body: CreateItemRequest) -> dict[str, str]:
|
|
52
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
53
|
+
|
|
54
|
+
|
|
41
55
|
versioner = RouterVersioner(
|
|
42
56
|
app=app,
|
|
43
57
|
routers=router,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from fastapi import APIRouter, FastAPI
|
|
2
|
+
from pydantic import BaseModel
|
|
2
3
|
|
|
3
4
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
4
5
|
|
|
@@ -10,6 +11,11 @@ app = FastAPI(
|
|
|
10
11
|
router = APIRouter()
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class CreateItemRequest(BaseModel):
|
|
15
|
+
name: str
|
|
16
|
+
quantity: int
|
|
17
|
+
|
|
18
|
+
|
|
13
19
|
# 1. Introduced in v1.0, persists across all versions
|
|
14
20
|
@router.get("/persistent")
|
|
15
21
|
@api_version((1, 0))
|
|
@@ -41,6 +47,14 @@ def future_route() -> dict[str, str]:
|
|
|
41
47
|
return {"status": "active", "message": "Welcome to v3.0!"}
|
|
42
48
|
|
|
43
49
|
|
|
50
|
+
# POST /items with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
51
|
+
# default 422 validation error.
|
|
52
|
+
@router.post("/items")
|
|
53
|
+
@api_version((1, 0))
|
|
54
|
+
def create_item(body: CreateItemRequest) -> dict[str, str]:
|
|
55
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
versioner = RouterVersioner(
|
|
45
59
|
app=app,
|
|
46
60
|
routers=router,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from fastapi import APIRouter, FastAPI
|
|
2
|
+
from pydantic import BaseModel
|
|
2
3
|
|
|
3
4
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
4
5
|
|
|
@@ -10,6 +11,11 @@ app = FastAPI(
|
|
|
10
11
|
router = APIRouter()
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class CreateItemRequest(BaseModel):
|
|
15
|
+
name: str
|
|
16
|
+
quantity: int
|
|
17
|
+
|
|
18
|
+
|
|
13
19
|
# 1. Introduced in v1, persists across all versions
|
|
14
20
|
@router.get("/persistent")
|
|
15
21
|
@api_version((1, 0))
|
|
@@ -41,6 +47,14 @@ def future_route() -> dict[str, str]:
|
|
|
41
47
|
return {"status": "active", "message": "Welcome to v3!"}
|
|
42
48
|
|
|
43
49
|
|
|
50
|
+
# POST /items with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
51
|
+
# default 422 validation error.
|
|
52
|
+
@router.post("/items")
|
|
53
|
+
@api_version((1, 0))
|
|
54
|
+
def create_item(body: CreateItemRequest) -> dict[str, str]:
|
|
55
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
versioner = RouterVersioner(
|
|
45
59
|
app=app,
|
|
46
60
|
routers=router,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from fastapi import APIRouter, FastAPI
|
|
2
|
+
from pydantic import BaseModel
|
|
2
3
|
|
|
3
4
|
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
|
|
4
5
|
|
|
@@ -12,6 +13,11 @@ app = FastAPI(
|
|
|
12
13
|
router = APIRouter()
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
class CreateItemRequest(BaseModel):
|
|
17
|
+
name: str
|
|
18
|
+
quantity: int
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
@router.post("/orders")
|
|
16
22
|
@api_version((1, 0))
|
|
17
23
|
def create_order_v1() -> dict[str, str]:
|
|
@@ -30,6 +36,14 @@ def get_items() -> dict[str, object]:
|
|
|
30
36
|
return {"items": ["a", "b"]}
|
|
31
37
|
|
|
32
38
|
|
|
39
|
+
# POST /items with an invalid "quantity" (e.g. "not-a-number") returns FastAPI's
|
|
40
|
+
# default 422 validation error.
|
|
41
|
+
@router.post("/items")
|
|
42
|
+
@api_version((1, 0))
|
|
43
|
+
def create_item(body: CreateItemRequest) -> dict[str, str]:
|
|
44
|
+
return {"name": body.name, "quantity": str(body.quantity)}
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
# --- Webhook definitions ---
|
|
34
48
|
# Webhooks document outbound calls your API makes to subscriber URLs.
|
|
35
49
|
# Annotate them with @api_version just like regular routes.
|
|
@@ -25,37 +25,6 @@ from starlette.requests import Request
|
|
|
25
25
|
_route_contexts_fn: Callable[..., Any] | None = getattr(fastapi.routing, "iter_route_contexts", None)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def _iter_routes_flat(routes: list[Any]) -> Iterator[Any]:
|
|
29
|
-
"""
|
|
30
|
-
Flattens the route tree using iter_route_contexts (FastAPI >= 0.137.2),
|
|
31
|
-
or yields the original flat list for older versions.
|
|
32
|
-
"""
|
|
33
|
-
if _route_contexts_fn is None:
|
|
34
|
-
yield from routes
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
for route_ctx in _route_contexts_fn(routes):
|
|
38
|
-
original = route_ctx.original_route
|
|
39
|
-
if isinstance(original, APIRoute):
|
|
40
|
-
# RouteContext merges path/tags/deps via __getattr__; use the context directly.
|
|
41
|
-
yield route_ctx
|
|
42
|
-
elif isinstance(original, APIWebSocketRoute):
|
|
43
|
-
# For WebSockets, RouteContext.__getattr__ does NOT merge parent prefixes into path.
|
|
44
|
-
# _route_context._EffectiveRouteContext holds the fully resolved starlette_route
|
|
45
|
-
# (with all include_router prefixes applied). Fall back to original for direct routes.
|
|
46
|
-
rc = getattr(route_ctx, "_route_context", None)
|
|
47
|
-
starlette_route = getattr(rc, "starlette_route", None) if rc is not None else None
|
|
48
|
-
yield starlette_route if starlette_route is not None else original
|
|
49
|
-
else:
|
|
50
|
-
yield original # pragma: no cover
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _unwrap_route(route: Any) -> Any:
|
|
54
|
-
# RouteContext (FastAPI >= 0.137.2) wraps the original route.
|
|
55
|
-
# Attributes like response_model, status_code, operation_id live on the original.
|
|
56
|
-
return getattr(route, "original_route", route)
|
|
57
|
-
|
|
58
|
-
|
|
59
28
|
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
|
60
29
|
|
|
61
30
|
VersionT: TypeAlias = tuple[int, int] | str
|
|
@@ -228,6 +197,7 @@ class RouterVersioner:
|
|
|
228
197
|
|
|
229
198
|
if self._validation_error_code != 422 and self._handle_validation_exceptions:
|
|
230
199
|
self._register_validation_exception_handler()
|
|
200
|
+
self._patch_root_openapi()
|
|
231
201
|
|
|
232
202
|
def _validate_version_type(self, version: Any, route_path: str) -> None:
|
|
233
203
|
if self._version_format == VersionFormat.SEMVER:
|
|
@@ -239,12 +209,134 @@ class RouterVersioner:
|
|
|
239
209
|
error_msg = f"RouterVersioner expects CALVER, but found a non-string version '{version}' on {route_path}. Use a string like '2025-01-01'."
|
|
240
210
|
raise ValueError(error_msg)
|
|
241
211
|
|
|
212
|
+
def _register_validation_exception_handler(self) -> None:
|
|
213
|
+
existing_code = getattr(self._app.state, "_validation_effective_code", None)
|
|
214
|
+
if existing_code is not None:
|
|
215
|
+
if existing_code != self._validation_error_code:
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
f"A validation exception handler is already registered on this app with status "
|
|
218
|
+
f"code {existing_code}, which conflicts with the requested {self._validation_error_code}. "
|
|
219
|
+
"All RouterVersioner instances with handle_validation_exceptions=True sharing the "
|
|
220
|
+
"same app must use the same validation_error_code."
|
|
221
|
+
)
|
|
222
|
+
return # same code already registered: idempotent
|
|
223
|
+
|
|
224
|
+
@self._app.exception_handler(RequestValidationError)
|
|
225
|
+
async def custom_validation_exception_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
226
|
+
return JSONResponse(
|
|
227
|
+
status_code=self._validation_error_code,
|
|
228
|
+
content={"detail": jsonable_encoder(exc.errors())},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
self._app.state._validation_effective_code = self._validation_error_code
|
|
232
|
+
|
|
233
|
+
def _patch_root_openapi(self) -> None:
|
|
234
|
+
if getattr(self._app.state, "_root_openapi_patched", False):
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
original_openapi = self._app.openapi
|
|
238
|
+
|
|
239
|
+
def custom_openapi() -> dict[str, Any]:
|
|
240
|
+
schema = original_openapi()
|
|
241
|
+
self._patch_validation_error_openapi(schema)
|
|
242
|
+
self._app.openapi_schema = schema
|
|
243
|
+
return schema
|
|
244
|
+
|
|
245
|
+
self._app.openapi = custom_openapi # type: ignore[method-assign] # ty:ignore[invalid-assignment]
|
|
246
|
+
self._app.state._root_openapi_patched = True
|
|
247
|
+
|
|
248
|
+
def _get_schema_validation_code(self) -> int:
|
|
249
|
+
if not self._handle_validation_exceptions:
|
|
250
|
+
return self._validation_error_code
|
|
251
|
+
return getattr(self._app.state, "_validation_effective_code", self._validation_error_code)
|
|
252
|
+
|
|
253
|
+
def _patch_validation_error_openapi(self, schema: dict[str, Any]) -> None:
|
|
254
|
+
target = self._get_schema_validation_code()
|
|
255
|
+
if target == 422:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
target_code = str(target)
|
|
259
|
+
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
260
|
+
|
|
261
|
+
for _path, path_item in schema.get("paths", {}).items():
|
|
262
|
+
for _method, operation in path_item.items():
|
|
263
|
+
if _method not in http_methods:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
responses = operation.get("responses", {})
|
|
267
|
+
if "422" in responses:
|
|
268
|
+
response_422 = responses["422"]
|
|
269
|
+
content_422 = response_422.get("content", {}).get("application/json", {})
|
|
270
|
+
schema_422 = content_422.get("schema", {})
|
|
271
|
+
ref = schema_422.get("$ref", "")
|
|
272
|
+
|
|
273
|
+
if ref.endswith("HTTPValidationError"):
|
|
274
|
+
if target_code in responses:
|
|
275
|
+
existing_response = responses[target_code]
|
|
276
|
+
existing_content = existing_response.setdefault("content", {}).setdefault(
|
|
277
|
+
"application/json", {}
|
|
278
|
+
)
|
|
279
|
+
existing_schema = existing_content.setdefault("schema", {})
|
|
280
|
+
|
|
281
|
+
if "anyOf" in existing_schema:
|
|
282
|
+
existing_schema["anyOf"].append(schema_422)
|
|
283
|
+
elif existing_schema:
|
|
284
|
+
existing_content["schema"] = {"anyOf": [existing_schema, schema_422]}
|
|
285
|
+
else:
|
|
286
|
+
existing_content["schema"] = schema_422
|
|
287
|
+
|
|
288
|
+
old_desc = existing_response.get("description", "Error")
|
|
289
|
+
existing_response["description"] = f"{old_desc} / Validation Error"
|
|
290
|
+
|
|
291
|
+
del responses["422"]
|
|
292
|
+
else:
|
|
293
|
+
responses[target_code] = responses.pop("422")
|
|
294
|
+
|
|
242
295
|
@staticmethod
|
|
243
296
|
def _format_string(format_str: str, version: VersionT) -> str:
|
|
244
297
|
if isinstance(version, tuple):
|
|
245
298
|
return format_str.format(major=version[0], minor=version[1], version=f"{version[0]}_{version[1]}")
|
|
246
299
|
return format_str.format(version=version, major=version, minor=version)
|
|
247
300
|
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _version_gte(a: VersionT, b: VersionT) -> bool:
|
|
303
|
+
if isinstance(a, tuple) and isinstance(b, tuple):
|
|
304
|
+
return a >= b
|
|
305
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
306
|
+
return a >= b
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _iter_routes_flat(routes: list[Any]) -> Iterator[Any]:
|
|
311
|
+
"""
|
|
312
|
+
Flattens the route tree using iter_route_contexts (FastAPI >= 0.137.2),
|
|
313
|
+
or yields the original flat list for older versions.
|
|
314
|
+
"""
|
|
315
|
+
if _route_contexts_fn is None:
|
|
316
|
+
yield from routes
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
for route_ctx in _route_contexts_fn(routes):
|
|
320
|
+
original = route_ctx.original_route
|
|
321
|
+
if isinstance(original, APIRoute):
|
|
322
|
+
# RouteContext merges path/tags/deps via __getattr__; use the context directly.
|
|
323
|
+
yield route_ctx
|
|
324
|
+
elif isinstance(original, APIWebSocketRoute):
|
|
325
|
+
# For WebSockets, RouteContext.__getattr__ does NOT merge parent prefixes into path.
|
|
326
|
+
# _route_context._EffectiveRouteContext holds the fully resolved starlette_route
|
|
327
|
+
# (with all include_router prefixes applied). Fall back to original for direct routes.
|
|
328
|
+
rc = getattr(route_ctx, "_route_context", None)
|
|
329
|
+
starlette_route = getattr(rc, "starlette_route", None) if rc is not None else None
|
|
330
|
+
yield starlette_route if starlette_route is not None else original
|
|
331
|
+
else:
|
|
332
|
+
yield original # pragma: no cover
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _unwrap_route(route: Any) -> Any:
|
|
336
|
+
# RouteContext (FastAPI >= 0.137.2) wraps the original route.
|
|
337
|
+
# Attributes like response_model, status_code, operation_id live on the original.
|
|
338
|
+
return getattr(route, "original_route", route)
|
|
339
|
+
|
|
248
340
|
def _extract_version_attribute(self, endpoint: Any, attribute: str, route_path: str) -> VersionT | None:
|
|
249
341
|
val = getattr(endpoint, attribute, None)
|
|
250
342
|
if isinstance(val, (tuple, str)):
|
|
@@ -263,6 +355,7 @@ class RouterVersioner:
|
|
|
263
355
|
|
|
264
356
|
for version, routes_by_key in routes_by_version.items():
|
|
265
357
|
version_prefix = self._format_string(self._prefix_format, version)
|
|
358
|
+
self._claim_prefix(version_prefix)
|
|
266
359
|
active_webhooks = self._resolve_webhooks_for_version(version, webhooks_by_version)
|
|
267
360
|
|
|
268
361
|
version_router = self._build_version_router(
|
|
@@ -279,6 +372,7 @@ class RouterVersioner:
|
|
|
279
372
|
latest_webhooks = active_webhooks
|
|
280
373
|
|
|
281
374
|
if self._latest_prefix is not None and latest_version is not None:
|
|
375
|
+
self._claim_prefix(self._latest_prefix)
|
|
282
376
|
latest_router = self._build_version_router(
|
|
283
377
|
version=latest_version,
|
|
284
378
|
version_prefix=self._latest_prefix,
|
|
@@ -294,59 +388,56 @@ class RouterVersioner:
|
|
|
294
388
|
|
|
295
389
|
return versions
|
|
296
390
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
webhooks: list[Any],
|
|
303
|
-
) -> APIRouter:
|
|
304
|
-
router = APIRouter(prefix=version_prefix, responses=self._app.router.responses)
|
|
305
|
-
|
|
306
|
-
if self._sort_routes:
|
|
307
|
-
routes_by_key = dict(sorted(routes_by_key.items()))
|
|
308
|
-
|
|
309
|
-
for route in routes_by_key.values():
|
|
310
|
-
self._add_route_to_router(route=route, router=router, version=version)
|
|
391
|
+
@classmethod
|
|
392
|
+
def _get_route_keys(cls, route: Any) -> dict[tuple[str, str], Any]:
|
|
393
|
+
path = route.path
|
|
394
|
+
routes_by_key: dict[tuple[str, str], Any] = {}
|
|
395
|
+
route_type = cls._unwrap_route(route)
|
|
311
396
|
|
|
312
|
-
|
|
397
|
+
if isinstance(route_type, APIRoute):
|
|
398
|
+
for method in route.methods:
|
|
399
|
+
routes_by_key[(path, method)] = route
|
|
400
|
+
elif isinstance(route_type, APIWebSocketRoute):
|
|
401
|
+
routes_by_key[(path, "")] = route
|
|
313
402
|
|
|
314
|
-
return
|
|
403
|
+
return routes_by_key
|
|
315
404
|
|
|
316
|
-
def
|
|
317
|
-
|
|
318
|
-
for router in self._routers:
|
|
319
|
-
all_routes.extend(_iter_routes_flat(router.routes))
|
|
405
|
+
def _resolve_lifecycle(self, routes: list[Any]) -> dict[VersionT, dict[tuple[str, str], Any]]:
|
|
406
|
+
"""Accumulates active routes per version, honoring @api_version introduce/remove.
|
|
320
407
|
|
|
321
|
-
|
|
322
|
-
|
|
408
|
+
Shared by _get_routes_by_version and _get_webhooks_by_version: both need the same
|
|
409
|
+
introduce/remove bookkeeping, only the input route list and output shape differ.
|
|
410
|
+
"""
|
|
411
|
+
introduced: dict[VersionT, list[Any]] = defaultdict(list)
|
|
412
|
+
for route in routes:
|
|
323
413
|
start_version = self._extract_version_attribute(route.endpoint, _ATTR_API_VERSION, route.path)
|
|
324
|
-
if start_version is None
|
|
325
|
-
start_version = self._default_version
|
|
326
|
-
routes_introduced[start_version].append(route)
|
|
414
|
+
introduced[start_version if start_version is not None else self._default_version].append(route)
|
|
327
415
|
|
|
328
|
-
|
|
329
|
-
for route in
|
|
416
|
+
removed: dict[VersionT, list[Any]] = defaultdict(list)
|
|
417
|
+
for route in routes:
|
|
330
418
|
end_version = self._extract_version_attribute(route.endpoint, _ATTR_REMOVE_IN, route.path)
|
|
331
419
|
if end_version is not None:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
all_version_keys = set(routes_introduced.keys()) | set(routes_removed.keys())
|
|
335
|
-
versions = sorted(all_version_keys)
|
|
336
|
-
routes_by_version: dict[VersionT, dict[tuple[str, str], Any]] = {}
|
|
337
|
-
active_routes: dict[tuple[str, str], Any] = {}
|
|
420
|
+
removed[end_version].append(route)
|
|
338
421
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
active_routes.update(self._get_route_keys(route=route))
|
|
422
|
+
active: dict[tuple[str, str], Any] = {}
|
|
423
|
+
result: dict[VersionT, dict[tuple[str, str], Any]] = {}
|
|
342
424
|
|
|
343
|
-
|
|
425
|
+
for version in sorted(set(introduced.keys()) | set(removed.keys())):
|
|
426
|
+
for route in introduced[version]:
|
|
427
|
+
active.update(self._get_route_keys(route=route))
|
|
428
|
+
for route in removed.get(version, []):
|
|
344
429
|
for route_key in self._get_route_keys(route=route):
|
|
345
|
-
|
|
430
|
+
active.pop(route_key, None)
|
|
431
|
+
result[version] = dict(active)
|
|
346
432
|
|
|
347
|
-
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
def _get_routes_by_version(self) -> dict[VersionT, dict[tuple[str, str], Any]]:
|
|
436
|
+
all_routes: list[Any] = []
|
|
437
|
+
for router in self._routers:
|
|
438
|
+
all_routes.extend(self._iter_routes_flat(router.routes))
|
|
348
439
|
|
|
349
|
-
return
|
|
440
|
+
return self._resolve_lifecycle(all_routes)
|
|
350
441
|
|
|
351
442
|
def _get_webhooks_by_version(self) -> dict[VersionT, list[Any]]:
|
|
352
443
|
if not self._webhook_routers:
|
|
@@ -354,32 +445,24 @@ class RouterVersioner:
|
|
|
354
445
|
|
|
355
446
|
all_webhooks: list[Any] = []
|
|
356
447
|
for router in self._webhook_routers:
|
|
357
|
-
all_webhooks.extend(_iter_routes_flat(router.routes))
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
for route in webhooks_introduced[version]:
|
|
376
|
-
active.update(self._get_route_keys(route=route))
|
|
377
|
-
for route in webhooks_removed.get(version, []):
|
|
378
|
-
for key in self._get_route_keys(route=route):
|
|
379
|
-
active.pop(key, None)
|
|
380
|
-
result[version] = list(active.values())
|
|
381
|
-
|
|
382
|
-
return result
|
|
448
|
+
all_webhooks.extend(self._iter_routes_flat(router.routes))
|
|
449
|
+
|
|
450
|
+
return {version: list(routes.values()) for version, routes in self._resolve_lifecycle(all_webhooks).items()}
|
|
451
|
+
|
|
452
|
+
def _claim_prefix(self, prefix: str) -> None:
|
|
453
|
+
claimed: set[str] | None = getattr(self._app.state, "_router_versioner_claimed_prefixes", None)
|
|
454
|
+
if claimed is None:
|
|
455
|
+
claimed = set()
|
|
456
|
+
self._app.state._router_versioner_claimed_prefixes = claimed
|
|
457
|
+
|
|
458
|
+
if prefix in claimed:
|
|
459
|
+
raise RuntimeError(
|
|
460
|
+
f"Prefix '{prefix}' is already used by another RouterVersioner attached to this app. "
|
|
461
|
+
"Two RouterVersioner instances sharing the same app must use distinct "
|
|
462
|
+
"prefix_format/latest_prefix values, otherwise their docs/openapi routes silently "
|
|
463
|
+
"shadow each other."
|
|
464
|
+
)
|
|
465
|
+
claimed.add(prefix)
|
|
383
466
|
|
|
384
467
|
def _resolve_webhooks_for_version(
|
|
385
468
|
self, version: VersionT, webhooks_by_version: dict[VersionT, list[Any]]
|
|
@@ -395,19 +478,56 @@ class RouterVersioner:
|
|
|
395
478
|
return []
|
|
396
479
|
return webhooks_by_version[max(candidates)]
|
|
397
480
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
481
|
+
def _build_version_router(
|
|
482
|
+
self,
|
|
483
|
+
version: VersionT,
|
|
484
|
+
version_prefix: str,
|
|
485
|
+
routes_by_key: dict[tuple[str, str], Any],
|
|
486
|
+
webhooks: list[Any],
|
|
487
|
+
) -> APIRouter:
|
|
488
|
+
router = APIRouter(prefix=version_prefix, responses=self._app.router.responses)
|
|
403
489
|
|
|
404
|
-
if
|
|
405
|
-
|
|
406
|
-
routes_by_key[(path, method)] = route
|
|
407
|
-
elif isinstance(route_type, APIWebSocketRoute):
|
|
408
|
-
routes_by_key[(path, "")] = route
|
|
490
|
+
if self._sort_routes:
|
|
491
|
+
routes_by_key = dict(sorted(routes_by_key.items()))
|
|
409
492
|
|
|
410
|
-
|
|
493
|
+
for route in routes_by_key.values():
|
|
494
|
+
self._add_route_to_router(route=route, router=router, version=version)
|
|
495
|
+
|
|
496
|
+
self._add_version_docs(router=router, version=version, version_prefix=version_prefix, webhooks=webhooks)
|
|
497
|
+
|
|
498
|
+
return router
|
|
499
|
+
|
|
500
|
+
def _add_route_to_router(self, route: Any, router: APIRouter, version: VersionT) -> None:
|
|
501
|
+
# Read attributes from the original route, not the RouteContext proxy. The proxy
|
|
502
|
+
# (FastAPI >= 0.137.2) only merges path/tags/deps; other fields such as
|
|
503
|
+
# response_model, status_code, and operation_id would be silently lost.
|
|
504
|
+
source_route = self._unwrap_route(route)
|
|
505
|
+
add_method: Callable[..., Any]
|
|
506
|
+
|
|
507
|
+
if isinstance(source_route, APIRoute):
|
|
508
|
+
add_method = router.add_api_route
|
|
509
|
+
elif isinstance(source_route, APIWebSocketRoute):
|
|
510
|
+
add_method = router.add_api_websocket_route
|
|
511
|
+
else:
|
|
512
|
+
raise TypeError(f"Unsupported route type: {type(source_route).__name__}")
|
|
513
|
+
|
|
514
|
+
valid_params = inspect.signature(add_method).parameters.keys()
|
|
515
|
+
filtered_kwargs = {k: getattr(source_route, k) for k in valid_params if hasattr(source_route, k)}
|
|
516
|
+
filtered_kwargs.setdefault("endpoint", source_route.endpoint)
|
|
517
|
+
# Override path/tags/deps with the merged values from RouteContext when present.
|
|
518
|
+
for merged_attr in ("path", "tags", "dependencies"):
|
|
519
|
+
if hasattr(route, merged_attr) and merged_attr in valid_params:
|
|
520
|
+
filtered_kwargs[merged_attr] = getattr(route, merged_attr)
|
|
521
|
+
|
|
522
|
+
deprecated_in_version = self._extract_version_attribute(route.endpoint, _ATTR_DEPRECATE_IN, route.path)
|
|
523
|
+
if deprecated_in_version is not None and self._version_gte(version, deprecated_in_version):
|
|
524
|
+
filtered_kwargs["deprecated"] = True
|
|
525
|
+
|
|
526
|
+
# An empty string name causes an internal FastAPI error; drop it to use the default.
|
|
527
|
+
if "name" in filtered_kwargs and not filtered_kwargs["name"]:
|
|
528
|
+
filtered_kwargs.pop("name")
|
|
529
|
+
|
|
530
|
+
add_method(**filtered_kwargs)
|
|
411
531
|
|
|
412
532
|
def _add_version_docs(self, router: APIRouter, version: VersionT, version_prefix: str, webhooks: list[Any]) -> None:
|
|
413
533
|
doc_version_str = self._format_string(self._semantic_version_format, version)
|
|
@@ -563,94 +683,3 @@ class RouterVersioner:
|
|
|
563
683
|
version_models.append(version_model)
|
|
564
684
|
|
|
565
685
|
return {"versions": version_models}
|
|
566
|
-
|
|
567
|
-
def _add_route_to_router(self, route: Any, router: APIRouter, version: VersionT) -> None:
|
|
568
|
-
# Read attributes from the original route, not the RouteContext proxy. The proxy
|
|
569
|
-
# (FastAPI >= 0.137.2) only merges path/tags/deps; other fields such as
|
|
570
|
-
# response_model, status_code, and operation_id would be silently lost.
|
|
571
|
-
source_route = _unwrap_route(route)
|
|
572
|
-
add_method: Callable[..., Any]
|
|
573
|
-
|
|
574
|
-
if isinstance(source_route, APIRoute):
|
|
575
|
-
add_method = router.add_api_route
|
|
576
|
-
elif isinstance(source_route, APIWebSocketRoute):
|
|
577
|
-
add_method = router.add_api_websocket_route
|
|
578
|
-
else:
|
|
579
|
-
raise TypeError(f"Unsupported route type: {type(source_route).__name__}")
|
|
580
|
-
|
|
581
|
-
valid_params = inspect.signature(add_method).parameters.keys()
|
|
582
|
-
filtered_kwargs = {k: getattr(source_route, k) for k in valid_params if hasattr(source_route, k)}
|
|
583
|
-
filtered_kwargs.setdefault("endpoint", source_route.endpoint)
|
|
584
|
-
# Override path/tags/deps with the merged values from RouteContext when present.
|
|
585
|
-
for merged_attr in ("path", "tags", "dependencies"):
|
|
586
|
-
if hasattr(route, merged_attr) and merged_attr in valid_params:
|
|
587
|
-
filtered_kwargs[merged_attr] = getattr(route, merged_attr)
|
|
588
|
-
|
|
589
|
-
deprecated_in_version = self._extract_version_attribute(route.endpoint, _ATTR_DEPRECATE_IN, route.path)
|
|
590
|
-
if deprecated_in_version is not None:
|
|
591
|
-
if isinstance(version, tuple) and isinstance(deprecated_in_version, tuple):
|
|
592
|
-
if version >= deprecated_in_version:
|
|
593
|
-
filtered_kwargs["deprecated"] = True
|
|
594
|
-
elif isinstance(version, str) and isinstance(deprecated_in_version, str):
|
|
595
|
-
if version >= deprecated_in_version:
|
|
596
|
-
filtered_kwargs["deprecated"] = True
|
|
597
|
-
|
|
598
|
-
# An empty string name causes an internal FastAPI error; drop it to use the default.
|
|
599
|
-
if "name" in filtered_kwargs and not filtered_kwargs["name"]:
|
|
600
|
-
filtered_kwargs.pop("name")
|
|
601
|
-
|
|
602
|
-
add_method(**filtered_kwargs)
|
|
603
|
-
|
|
604
|
-
def _register_validation_exception_handler(self) -> None:
|
|
605
|
-
if getattr(self._app.state, "_validation_overridden", False):
|
|
606
|
-
return
|
|
607
|
-
|
|
608
|
-
@self._app.exception_handler(RequestValidationError)
|
|
609
|
-
async def custom_validation_exception_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
610
|
-
return JSONResponse(
|
|
611
|
-
status_code=self._validation_error_code,
|
|
612
|
-
content={"detail": jsonable_encoder(exc.errors())},
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
self._app.state._validation_overridden = True
|
|
616
|
-
|
|
617
|
-
def _patch_validation_error_openapi(self, schema: dict[str, Any]) -> None:
|
|
618
|
-
if self._validation_error_code == 422:
|
|
619
|
-
return
|
|
620
|
-
|
|
621
|
-
target_code = str(self._validation_error_code)
|
|
622
|
-
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
623
|
-
|
|
624
|
-
for _path, path_item in schema.get("paths", {}).items():
|
|
625
|
-
for _method, operation in path_item.items():
|
|
626
|
-
if _method not in http_methods:
|
|
627
|
-
continue
|
|
628
|
-
|
|
629
|
-
responses = operation.get("responses", {})
|
|
630
|
-
if "422" in responses:
|
|
631
|
-
response_422 = responses["422"]
|
|
632
|
-
content_422 = response_422.get("content", {}).get("application/json", {})
|
|
633
|
-
schema_422 = content_422.get("schema", {})
|
|
634
|
-
ref = schema_422.get("$ref", "")
|
|
635
|
-
|
|
636
|
-
if ref.endswith("HTTPValidationError"):
|
|
637
|
-
if target_code in responses:
|
|
638
|
-
existing_response = responses[target_code]
|
|
639
|
-
existing_content = existing_response.setdefault("content", {}).setdefault(
|
|
640
|
-
"application/json", {}
|
|
641
|
-
)
|
|
642
|
-
existing_schema = existing_content.setdefault("schema", {})
|
|
643
|
-
|
|
644
|
-
if "anyOf" in existing_schema:
|
|
645
|
-
existing_schema["anyOf"].append(schema_422)
|
|
646
|
-
elif existing_schema:
|
|
647
|
-
existing_content["schema"] = {"anyOf": [existing_schema, schema_422]}
|
|
648
|
-
else:
|
|
649
|
-
existing_content["schema"] = schema_422
|
|
650
|
-
|
|
651
|
-
old_desc = existing_response.get("description", "Error")
|
|
652
|
-
existing_response["description"] = f"{old_desc} / Validation Error"
|
|
653
|
-
|
|
654
|
-
del responses["422"]
|
|
655
|
-
else:
|
|
656
|
-
responses[target_code] = responses.pop("422")
|
|
@@ -218,6 +218,43 @@ def test_openapi_with_root_path_and_oauth2() -> None:
|
|
|
218
218
|
assert schema["servers"][0]["url"] == "/api"
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
def test_versioned_app_mounted_as_real_sub_application() -> None:
|
|
222
|
+
"""A RouterVersioner-managed app works correctly when actually mounted via app.mount(),
|
|
223
|
+
not just simulated with a root_path passed to TestClient. Covers the ASGI root_path
|
|
224
|
+
FastAPI injects for real sub-applications: versioned docs/openapi, the root openapi patch
|
|
225
|
+
(validation_error_code), and runtime behavior must all resolve under the mount prefix."""
|
|
226
|
+
main_app = FastAPI()
|
|
227
|
+
sub_app = FastAPI()
|
|
228
|
+
router = APIRouter()
|
|
229
|
+
|
|
230
|
+
@router.post("/items")
|
|
231
|
+
@api_version((1, 0))
|
|
232
|
+
def create_item(count: int) -> dict[str, str]: ...
|
|
233
|
+
|
|
234
|
+
RouterVersioner(
|
|
235
|
+
app=sub_app, routers=router, version_format=VersionFormat.SEMVER, validation_error_code=400
|
|
236
|
+
).versionize()
|
|
237
|
+
|
|
238
|
+
main_app.mount("/sub", sub_app)
|
|
239
|
+
|
|
240
|
+
client = TestClient(main_app)
|
|
241
|
+
|
|
242
|
+
# Sub-app's own root schema (accessed through the mount) reflects validation_error_code.
|
|
243
|
+
sub_root_schema = client.get("/sub/openapi.json").json()
|
|
244
|
+
operation = sub_root_schema["paths"]["/v1_0/items"]["post"]
|
|
245
|
+
assert "400" in operation["responses"]
|
|
246
|
+
assert "422" not in operation["responses"]
|
|
247
|
+
assert sub_root_schema["servers"][0]["url"] == "/sub"
|
|
248
|
+
|
|
249
|
+
# Versioned schema is consistent too.
|
|
250
|
+
versioned_schema = client.get("/sub/v1_0/openapi.json").json()
|
|
251
|
+
assert versioned_schema["servers"][0]["url"] == "/sub"
|
|
252
|
+
|
|
253
|
+
# Runtime resolves correctly through the mount, with the custom code applied.
|
|
254
|
+
assert client.post("/sub/v1_0/items?count=bad", json={}).status_code == 400
|
|
255
|
+
assert client.get("/sub/v1_0/docs").status_code == 200
|
|
256
|
+
|
|
257
|
+
|
|
221
258
|
def test_custom_formats_coverage() -> None:
|
|
222
259
|
"""Custom prefix_format and semantic_version_format are applied correctly."""
|
|
223
260
|
app = FastAPI()
|
|
@@ -1067,7 +1104,18 @@ def test_validation_error_handler_registered_once_for_multiple_versioners() -> N
|
|
|
1067
1104
|
client = TestClient(app)
|
|
1068
1105
|
assert client.get("/v1_0/a?count=bad").status_code == 400
|
|
1069
1106
|
assert client.get("/v2_0/b?count=bad").status_code == 400
|
|
1070
|
-
assert getattr(app.state, "
|
|
1107
|
+
assert getattr(app.state, "_validation_effective_code", None) == 400
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def test_version_gte_mismatched_types_returns_false() -> None:
|
|
1111
|
+
"""_version_gte returns False for values that aren't both tuples or both strings.
|
|
1112
|
+
|
|
1113
|
+
Defensive branch: normally unreachable via the public API, since _validate_version_type
|
|
1114
|
+
enforces a single, consistent VersionT type (tuple for SEMVER, str for CALVER) per
|
|
1115
|
+
RouterVersioner instance.
|
|
1116
|
+
"""
|
|
1117
|
+
assert RouterVersioner._version_gte((1, 0), "2025-01-01") is False
|
|
1118
|
+
assert RouterVersioner._version_gte("2025-01-01", (1, 0)) is False
|
|
1071
1119
|
|
|
1072
1120
|
|
|
1073
1121
|
def test_patch_validation_error_openapi_skips_non_method_keys() -> None:
|
|
@@ -1098,6 +1146,163 @@ def test_patch_validation_error_openapi_skips_non_method_keys() -> None:
|
|
|
1098
1146
|
assert "parameters" in schema["paths"]["/items"] # non-method key untouched
|
|
1099
1147
|
|
|
1100
1148
|
|
|
1149
|
+
def test_root_openapi_reflects_validation_error_code() -> None:
|
|
1150
|
+
"""The app's own /openapi.json (not versioned) also reflects validation_error_code."""
|
|
1151
|
+
app = FastAPI()
|
|
1152
|
+
router = APIRouter()
|
|
1153
|
+
|
|
1154
|
+
@router.get("/items")
|
|
1155
|
+
@api_version((1, 0))
|
|
1156
|
+
def get_items(count: int) -> dict[str, str]: ...
|
|
1157
|
+
|
|
1158
|
+
RouterVersioner(
|
|
1159
|
+
app=app, routers=router, version_format=VersionFormat.SEMVER, validation_error_code=400
|
|
1160
|
+
).versionize()
|
|
1161
|
+
|
|
1162
|
+
client = TestClient(app)
|
|
1163
|
+
root_schema = client.get("/openapi.json").json()
|
|
1164
|
+
operation = root_schema["paths"]["/v1_0/items"]["get"]
|
|
1165
|
+
assert "400" in operation["responses"]
|
|
1166
|
+
assert "422" not in operation["responses"]
|
|
1167
|
+
assert client.get("/v1_0/items?count=bad").status_code == 400
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def test_root_openapi_reflects_effective_code_for_default_versioner() -> None:
|
|
1171
|
+
"""A RouterVersioner left at the default 422 still shows the real app-wide code in the
|
|
1172
|
+
root schema, when another RouterVersioner on the same app registered the actual handler."""
|
|
1173
|
+
app = FastAPI()
|
|
1174
|
+
router1 = APIRouter()
|
|
1175
|
+
router2 = APIRouter()
|
|
1176
|
+
|
|
1177
|
+
@router1.get("/a")
|
|
1178
|
+
@api_version((1, 0))
|
|
1179
|
+
def route_a(count: int) -> dict[str, str]: ...
|
|
1180
|
+
|
|
1181
|
+
@router2.get("/b")
|
|
1182
|
+
@api_version((2, 0))
|
|
1183
|
+
def route_b(count: int) -> dict[str, str]: ...
|
|
1184
|
+
|
|
1185
|
+
RouterVersioner(
|
|
1186
|
+
app=app, routers=router1, version_format=VersionFormat.SEMVER, validation_error_code=400
|
|
1187
|
+
).versionize()
|
|
1188
|
+
RouterVersioner(app=app, routers=router2, version_format=VersionFormat.SEMVER).versionize()
|
|
1189
|
+
|
|
1190
|
+
client = TestClient(app)
|
|
1191
|
+
root_schema = client.get("/openapi.json").json()
|
|
1192
|
+
assert "400" in root_schema["paths"]["/v2_0/b"]["get"]["responses"]
|
|
1193
|
+
assert "422" not in root_schema["paths"]["/v2_0/b"]["get"]["responses"]
|
|
1194
|
+
assert client.get("/v2_0/b?count=bad").status_code == 400
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def test_versioned_schema_reflects_effective_code_for_default_versioner() -> None:
|
|
1198
|
+
"""A RouterVersioner left at the default 422 still shows the real app-wide code in its
|
|
1199
|
+
own versioned schema when another RouterVersioner on the same app registered the actual
|
|
1200
|
+
handler."""
|
|
1201
|
+
app = FastAPI()
|
|
1202
|
+
router1 = APIRouter()
|
|
1203
|
+
router2 = APIRouter()
|
|
1204
|
+
|
|
1205
|
+
@router1.get("/a")
|
|
1206
|
+
@api_version((1, 0))
|
|
1207
|
+
def route_a(count: int) -> dict[str, str]: ...
|
|
1208
|
+
|
|
1209
|
+
@router2.get("/b")
|
|
1210
|
+
@api_version((2, 0))
|
|
1211
|
+
def route_b(count: int) -> dict[str, str]: ...
|
|
1212
|
+
|
|
1213
|
+
RouterVersioner(
|
|
1214
|
+
app=app, routers=router1, version_format=VersionFormat.SEMVER, validation_error_code=400
|
|
1215
|
+
).versionize()
|
|
1216
|
+
RouterVersioner(app=app, routers=router2, version_format=VersionFormat.SEMVER).versionize()
|
|
1217
|
+
|
|
1218
|
+
client = TestClient(app)
|
|
1219
|
+
schema = client.get("/v2_0/openapi.json").json()
|
|
1220
|
+
assert "400" in schema["paths"]["/v2_0/b"]["get"]["responses"]
|
|
1221
|
+
assert "422" not in schema["paths"]["/v2_0/b"]["get"]["responses"]
|
|
1222
|
+
assert client.get("/v2_0/b?count=bad").status_code == 400
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def test_conflicting_validation_error_codes_raise() -> None:
|
|
1226
|
+
"""Two RouterVersioners with different explicit validation_error_code and
|
|
1227
|
+
handle_validation_exceptions=True must fail fast instead of silently ignoring the second."""
|
|
1228
|
+
app = FastAPI()
|
|
1229
|
+
router1 = APIRouter()
|
|
1230
|
+
router2 = APIRouter()
|
|
1231
|
+
|
|
1232
|
+
RouterVersioner(app=app, routers=router1, version_format=VersionFormat.SEMVER, validation_error_code=400)
|
|
1233
|
+
|
|
1234
|
+
with pytest.raises(RuntimeError, match="conflicts with the requested"):
|
|
1235
|
+
RouterVersioner(app=app, routers=router2, version_format=VersionFormat.SEMVER, validation_error_code=409)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def test_handle_validation_exceptions_false_ignores_other_versioner_code() -> None:
|
|
1239
|
+
"""A versioner with handle_validation_exceptions=False always shows its own requested
|
|
1240
|
+
code in its schema, regardless of what other versioners on the same app registered."""
|
|
1241
|
+
app = FastAPI()
|
|
1242
|
+
router1 = APIRouter()
|
|
1243
|
+
router2 = APIRouter()
|
|
1244
|
+
|
|
1245
|
+
@router2.get("/items")
|
|
1246
|
+
@api_version((1, 0))
|
|
1247
|
+
def get_items(count: int) -> dict[str, str]: ...
|
|
1248
|
+
|
|
1249
|
+
RouterVersioner(app=app, routers=router1, version_format=VersionFormat.SEMVER, validation_error_code=400)
|
|
1250
|
+
RouterVersioner(
|
|
1251
|
+
app=app,
|
|
1252
|
+
routers=router2,
|
|
1253
|
+
version_format=VersionFormat.SEMVER,
|
|
1254
|
+
validation_error_code=409,
|
|
1255
|
+
handle_validation_exceptions=False,
|
|
1256
|
+
).versionize()
|
|
1257
|
+
|
|
1258
|
+
client = TestClient(app)
|
|
1259
|
+
schema = client.get("/v1_0/openapi.json").json()
|
|
1260
|
+
assert "409" in schema["paths"]["/v1_0/items"]["get"]["responses"]
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def test_duplicate_version_prefix_across_versioners_raises() -> None:
|
|
1264
|
+
"""Two RouterVersioners producing the same version prefix on the same app must fail fast
|
|
1265
|
+
instead of silently shadowing each other's docs/openapi routes."""
|
|
1266
|
+
app = FastAPI()
|
|
1267
|
+
router1 = APIRouter()
|
|
1268
|
+
router2 = APIRouter()
|
|
1269
|
+
|
|
1270
|
+
@router1.get("/a")
|
|
1271
|
+
@api_version((1, 0))
|
|
1272
|
+
def route_a() -> dict[str, str]: ...
|
|
1273
|
+
|
|
1274
|
+
@router2.get("/b")
|
|
1275
|
+
@api_version((1, 0))
|
|
1276
|
+
def route_b() -> dict[str, str]: ...
|
|
1277
|
+
|
|
1278
|
+
RouterVersioner(app=app, routers=router1, version_format=VersionFormat.SEMVER).versionize()
|
|
1279
|
+
|
|
1280
|
+
with pytest.raises(RuntimeError, match="already used by another RouterVersioner"):
|
|
1281
|
+
RouterVersioner(app=app, routers=router2, version_format=VersionFormat.SEMVER).versionize()
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def test_duplicate_latest_prefix_across_versioners_raises() -> None:
|
|
1285
|
+
"""Two RouterVersioners sharing the same latest_prefix on the same app must fail fast."""
|
|
1286
|
+
app = FastAPI()
|
|
1287
|
+
router1 = APIRouter()
|
|
1288
|
+
router2 = APIRouter()
|
|
1289
|
+
|
|
1290
|
+
@router1.get("/a")
|
|
1291
|
+
@api_version((1, 0))
|
|
1292
|
+
def route_a() -> dict[str, str]: ...
|
|
1293
|
+
|
|
1294
|
+
@router2.get("/b")
|
|
1295
|
+
@api_version("2025-01-01")
|
|
1296
|
+
def route_b() -> dict[str, str]: ...
|
|
1297
|
+
|
|
1298
|
+
RouterVersioner(app=app, routers=router1, version_format=VersionFormat.SEMVER, latest_prefix="/latest").versionize()
|
|
1299
|
+
|
|
1300
|
+
with pytest.raises(RuntimeError, match="already used by another RouterVersioner"):
|
|
1301
|
+
RouterVersioner(
|
|
1302
|
+
app=app, routers=router2, version_format=VersionFormat.CALVER, latest_prefix="/latest"
|
|
1303
|
+
).versionize()
|
|
1304
|
+
|
|
1305
|
+
|
|
1101
1306
|
def test_iter_routes_flat_fallback_without_route_context_fn() -> None:
|
|
1102
1307
|
"""Covers the _route_contexts_fn=None fallback (legacy FastAPI < 0.137.2).
|
|
1103
1308
|
|
|
@@ -1115,7 +1320,7 @@ def test_iter_routes_flat_fallback_without_route_context_fn() -> None:
|
|
|
1115
1320
|
original_fn = versioner_module._route_contexts_fn
|
|
1116
1321
|
try:
|
|
1117
1322
|
versioner_module._route_contexts_fn = None
|
|
1118
|
-
result = list(versioner_module._iter_routes_flat(router.routes))
|
|
1323
|
+
result = list(versioner_module.RouterVersioner._iter_routes_flat(router.routes))
|
|
1119
1324
|
assert result == list(router.routes)
|
|
1120
1325
|
finally:
|
|
1121
1326
|
versioner_module._route_contexts_fn = original_fn
|
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/pre-commit.yml
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/prepare_release.py
RENAMED
|
File without changes
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test-cov-html.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_prepare_release.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|