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.
Files changed (42) hide show
  1. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/PKG-INFO +23 -1
  2. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/README.md +22 -0
  3. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/RELEASE_NOTES.md +15 -0
  4. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/calver_app.py +14 -0
  5. fastapi_router_versioning-0.2.2/examples/mounted_subapps_app.py +76 -0
  6. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/multi_router_app.py +14 -0
  7. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/openapi_hook_app.py +14 -0
  8. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/self_hosted_docs_app.py +14 -0
  9. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/semver_app.py +14 -0
  10. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/semver_major_only_app.py +14 -0
  11. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/webhook_versioning_app.py +14 -0
  12. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/fastapi_router_versioning/__init__.py +1 -1
  13. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/fastapi_router_versioning/versioner.py +228 -199
  14. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_versioner.py +207 -2
  15. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/dependabot.yml +0 -0
  16. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/detect-conflicts.yml +0 -0
  17. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/pre-commit.yml +0 -0
  18. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/prepare-release.yml +0 -0
  19. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/publish.yml +0 -0
  20. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/test-redistribute.yml +0 -0
  21. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.github/workflows/test.yml +0 -0
  22. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.gitignore +0 -0
  23. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.pre-commit-config.yaml +0 -0
  24. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/.python-version +0 -0
  25. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/LICENSE +0 -0
  26. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/download_static_assets.py +0 -0
  27. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/validation_error_code_app.py +0 -0
  28. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/examples/validation_error_code_custom_handler_app.py +0 -0
  29. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/fastapi_router_versioning/py.typed +0 -0
  30. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/pyproject.toml +0 -0
  31. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/format.sh +0 -0
  32. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/lint.sh +0 -0
  33. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/prepare_release.py +0 -0
  34. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test-cov-html.sh +0 -0
  35. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test-cov.sh +0 -0
  36. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/scripts/test.sh +0 -0
  37. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/__init__.py +0 -0
  38. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_calver.py +0 -0
  39. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_fastapi_integration.py +0 -0
  40. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_prepare_release.py +0 -0
  41. {fastapi_router_versioning-0.2.1 → fastapi_router_versioning-0.2.2}/tests/test_semver.py +0 -0
  42. {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.1
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)
@@ -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
@@ -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"}
@@ -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.
@@ -1,5 +1,5 @@
1
1
  from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
4
4
 
5
5
  __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]
@@ -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
- def _build_version_router(
298
- self,
299
- version: VersionT,
300
- version_prefix: str,
301
- routes_by_key: dict[tuple[str, str], Any],
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
- self._add_version_docs(router=router, version=version, version_prefix=version_prefix, webhooks=webhooks)
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 router
403
+ return routes_by_key
315
404
 
316
- def _get_routes_by_version(self) -> dict[VersionT, dict[tuple[str, str], Any]]:
317
- all_routes: list[Any] = []
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
- routes_introduced: dict[VersionT, list[Any]] = defaultdict(list)
322
- for route in all_routes:
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
- routes_removed: dict[VersionT, list[Any]] = defaultdict(list)
329
- for route in all_routes:
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
- routes_removed[end_version].append(route)
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
- for version in versions:
340
- for route in routes_introduced[version]:
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
- for route in routes_removed.get(version, []):
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
- active_routes.pop(route_key, None)
430
+ active.pop(route_key, None)
431
+ result[version] = dict(active)
346
432
 
347
- routes_by_version[version] = dict(active_routes)
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 routes_by_version
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
- webhooks_introduced: dict[VersionT, list[Any]] = defaultdict(list)
360
- for route in all_webhooks:
361
- v = self._extract_version_attribute(route.endpoint, _ATTR_API_VERSION, route.path)
362
- webhooks_introduced[v if v is not None else self._default_version].append(route)
363
-
364
- webhooks_removed: dict[VersionT, list[Any]] = defaultdict(list)
365
- for route in all_webhooks:
366
- v = self._extract_version_attribute(route.endpoint, _ATTR_REMOVE_IN, route.path)
367
- if v is not None:
368
- webhooks_removed[v].append(route)
369
-
370
- combined_keys = set(webhooks_introduced.keys()) | set(webhooks_removed.keys())
371
- result: dict[VersionT, list[Any]] = {}
372
- active: dict[tuple[str, str], Any] = {}
373
-
374
- for version in sorted(combined_keys):
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
- @classmethod
399
- def _get_route_keys(cls, route: Any) -> dict[tuple[str, str], Any]:
400
- path = route.path
401
- routes_by_key: dict[tuple[str, str], Any] = {}
402
- route_type = _unwrap_route(route)
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 isinstance(route_type, APIRoute):
405
- for method in route.methods:
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
- return routes_by_key
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, "_validation_overridden", False) is True
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