fastapi-router-versioning 0.1.1__tar.gz → 0.2.0__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.1.1 → fastapi_router_versioning-0.2.0}/.github/workflows/pre-commit.yml +1 -1
  2. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.github/workflows/prepare-release.yml +1 -1
  3. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.github/workflows/publish.yml +1 -1
  4. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.github/workflows/test-redistribute.yml +2 -2
  5. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.github/workflows/test.yml +3 -3
  6. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/PKG-INFO +56 -16
  7. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/README.md +53 -14
  8. fastapi_router_versioning-0.2.0/RELEASE_NOTES.md +58 -0
  9. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/openapi_hook_app.py +1 -1
  10. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/self_hosted_docs_app.py +1 -1
  11. fastapi_router_versioning-0.2.0/examples/validation_error_code_app.py +57 -0
  12. fastapi_router_versioning-0.2.0/examples/validation_error_code_custom_handler_app.py +70 -0
  13. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/webhook_versioning_app.py +1 -1
  14. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/fastapi_router_versioning/__init__.py +1 -1
  15. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/fastapi_router_versioning/versioner.py +71 -0
  16. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/pyproject.toml +2 -1
  17. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/tests/test_versioner.py +206 -1
  18. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/uv.lock +163 -178
  19. fastapi_router_versioning-0.1.1/RELEASE_NOTES.md +0 -37
  20. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.github/dependabot.yml +0 -0
  21. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.github/workflows/detect-conflicts.yml +0 -0
  22. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.gitignore +0 -0
  23. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.pre-commit-config.yaml +0 -0
  24. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/.python-version +0 -0
  25. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/LICENSE +0 -0
  26. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/calver_app.py +0 -0
  27. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/download_static_assets.py +0 -0
  28. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/multi_router_app.py +0 -0
  29. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/semver_app.py +0 -0
  30. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/examples/semver_major_only_app.py +0 -0
  31. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/fastapi_router_versioning/py.typed +0 -0
  32. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/scripts/format.sh +0 -0
  33. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/scripts/lint.sh +0 -0
  34. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/scripts/prepare_release.py +0 -0
  35. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/scripts/test-cov-html.sh +0 -0
  36. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/scripts/test-cov.sh +0 -0
  37. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/scripts/test.sh +0 -0
  38. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/tests/__init__.py +0 -0
  39. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/tests/test_calver.py +0 -0
  40. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/tests/test_fastapi_integration.py +0 -0
  41. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/tests/test_prepare_release.py +0 -0
  42. {fastapi_router_versioning-0.1.1 → fastapi_router_versioning-0.2.0}/tests/test_semver.py +0 -0
@@ -21,7 +21,7 @@ jobs:
21
21
  run: echo "$GITHUB_CONTEXT"
22
22
 
23
23
  - name: Checkout code
24
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
24
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
25
25
  with:
26
26
  persist-credentials: false
27
27
  # Full history required for --from-ref/--to-ref diff in prek
@@ -31,7 +31,7 @@ jobs:
31
31
  issues: write
32
32
  pull-requests: write
33
33
  steps:
34
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
34
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35
35
  with:
36
36
  persist-credentials: true
37
37
  - name: Set up Python
@@ -20,7 +20,7 @@ jobs:
20
20
  env:
21
21
  GITHUB_CONTEXT: ${{ toJson(github) }}
22
22
  run: echo "$GITHUB_CONTEXT"
23
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
23
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
24
24
  with:
25
25
  persist-credentials: false
26
26
  - name: Set up Python
@@ -20,7 +20,7 @@ jobs:
20
20
  outputs:
21
21
  src: ${{ steps.filter.outputs.src }}
22
22
  steps:
23
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
23
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
24
24
  with:
25
25
  persist-credentials: false
26
26
  - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
@@ -48,7 +48,7 @@ jobs:
48
48
  env:
49
49
  GITHUB_CONTEXT: ${{ toJson(github) }}
50
50
  run: echo "$GITHUB_CONTEXT"
51
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
51
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
52
52
  with:
53
53
  persist-credentials: false
54
54
  - name: Set up Python
@@ -25,7 +25,7 @@ jobs:
25
25
  outputs:
26
26
  src: ${{ steps.filter.outputs.src }}
27
27
  steps:
28
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
28
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
29
29
  with:
30
30
  persist-credentials: false
31
31
  - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
@@ -70,7 +70,7 @@ jobs:
70
70
  GITHUB_CONTEXT: ${{ toJson(github) }}
71
71
  run: echo "$GITHUB_CONTEXT"
72
72
 
73
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
73
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
74
74
  with:
75
75
  persist-credentials: false
76
76
 
@@ -122,7 +122,7 @@ jobs:
122
122
  permissions:
123
123
  contents: read
124
124
  steps:
125
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
125
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
126
126
  with:
127
127
  persist-credentials: false
128
128
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-router-versioning
3
- Version: 0.1.1
4
- Summary: Native, router-based API versioning for FastAPI, featuring per-version docs and a declarative route lifecycle.
3
+ Version: 0.2.0
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
7
7
  Project-URL: Issues, https://github.com/mat81black/fastapi-router-versioning/issues
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
22
23
  Classifier: Topic :: Internet :: WWW/HTTP
23
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
25
  Classifier: Typing :: Typed
@@ -26,15 +27,15 @@ Requires-Python: >=3.10
26
27
  Requires-Dist: fastapi!=0.137.0,!=0.137.1,>=0.120.0
27
28
  Description-Content-Type: text/markdown
28
29
 
29
- # FastAPI Router Versioning (Native API Versioning)
30
+ # FastAPI Router Versioning
30
31
 
31
32
  [![PyPI](https://img.shields.io/pypi/v/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
32
33
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
33
34
  [![Python](https://img.shields.io/pypi/pyversions/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
34
35
 
35
- The native, router-based solution for **API versioning in FastAPI**.
36
+ Router-based API versioning for FastAPI.
36
37
 
37
- If you want to implement **FastAPI API versioning**, you can simply annotate your routes with `@api_version`, call `.versionize()`, and get isolated URL prefixes, per-version Swagger UI, and a full route lifecycle — all without touching your existing application structure.
38
+ FastAPI has no built-in versioning mechanism. The common workaround duplicating routers or managing prefixes manually breaks down quickly as the number of versions grows. This package adds declarative versioning directly on routes, with isolated URL prefixes, per-version Swagger UI, and a full route lifecycle, without touching the existing application structure.
38
39
 
39
40
  ---
40
41
 
@@ -46,6 +47,7 @@ If you want to implement **FastAPI API versioning**, you can simply annotate you
46
47
  - **Latest alias** — serve the newest version under a stable `/latest` prefix
47
48
  - **Self-hosted docs** — point Swagger UI and ReDoc at your own assets for air-gapped environments
48
49
  - **Reverse proxy aware** — doc URLs include the ASGI `root_path` at request time, so sub-app mounting works out of the box
50
+ - **Configurable validation error code** — return `400` (or any code) instead of `422` for request validation errors, with the OpenAPI schema updated automatically
49
51
  - **Broad compatibility** — works with nested routers, WebSockets, `Depends`, and OpenAPI Callbacks
50
52
 
51
53
  ---
@@ -93,7 +95,7 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).ve
93
95
  # Mounts: GET /v1_0/items GET /v2_0/items
94
96
  ```
95
97
 
96
- Each version also gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
98
+ Each version gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
97
99
 
98
100
  ---
99
101
 
@@ -117,9 +119,9 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).ve
117
119
  # Mounts: GET /2025-01-01/items
118
120
  ```
119
121
 
120
- Any string is a valid CalVer token: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
122
+ Valid CalVer tokens: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
121
123
 
122
- > **CalVer sorting:** versions are sorted lexicographically, so strings must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Strings like `"v1"`, `"v10"`, `"v2"` will **not** sort correctly and will cause routes to appear in the wrong versions.
124
+ **CalVer sorting:** versions are sorted lexicographically, so tokens must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Non-padded strings like `"v1"`, `"v10"`, `"v2"` will not sort correctly and will cause routes to appear in the wrong versions.
123
125
 
124
126
  ---
125
127
 
@@ -168,6 +170,8 @@ Routes without `@api_version` fall back to `default_version` (default: `(1, 0)`
168
170
  | `redoc_js_url` | `str \| None` | FastAPI CDN | Custom URL for the ReDoc JS bundle |
169
171
  | `redoc_favicon_url` | `str \| None` | FastAPI favicon | Custom URL for the ReDoc favicon |
170
172
  | `redoc_with_google_fonts` | `bool` | `True` | If `False`, ReDoc will not load Google Fonts |
173
+ | `validation_error_code` | `int` | `422` | HTTP status code returned for request validation errors; also replaces the `422` entry in the OpenAPI schema |
174
+ | `handle_validation_exceptions` | `bool` | `True` | If `False`, only the schema is updated — register your own `RequestValidationError` handler to control the response body |
171
175
 
172
176
  Call `.versionize()` after constructing the object. It returns the list of active versions.
173
177
 
@@ -181,8 +185,8 @@ Call `.versionize()` after constructing the object. It returns the list of activ
181
185
 
182
186
  | Parameter | Type | Required | Description |
183
187
  |---|---|---|---|
184
- | `version` | `tuple[int, int] \| str` | yes | Version when this route is introduced |
185
- | `deprecate_in` | same \| `None` | no | Version when this route is marked deprecated in the docs |
188
+ | `version` | `tuple[int, int] \| str` | yes | First version in which this route is active |
189
+ | `deprecate_in` | same \| `None` | no | Version in which this route is marked deprecated in the docs |
186
190
  | `remove_in` | same \| `None` | no | Version from which this route is removed entirely |
187
191
 
188
192
  All three parameters must match the `version_format` configured on `RouterVersioner`
@@ -278,7 +282,7 @@ RouterVersioner(
278
282
  ).versionize()
279
283
  ```
280
284
 
281
- The hook receives `(schema: dict, version: VersionT)` and must return the (modified) dict.
285
+ The hook receives `(schema: dict, version: VersionT)` and must return the modified dict.
282
286
 
283
287
  ### OpenAPI Callbacks and Webhooks
284
288
 
@@ -325,12 +329,11 @@ RouterVersioner(
325
329
  # /v2_0/openapi.json → webhooks: /order-created (V2) ← /payment-failed removed
326
330
  ```
327
331
 
328
- The same introduce / `remove_in` lifecycle applies. Webhook versions follow route versions:
329
- a new webhook version only appears once a route version creates that API prefix.
332
+ The same `remove_in` lifecycle applies. A new webhook version only appears once a route version creates that API prefix.
330
333
 
331
334
  ### Multiple routers
332
335
 
333
- Pass a list of routers to version routes that are split across modules:
336
+ Pass a list of routers to version routes split across modules:
334
337
 
335
338
  ```python
336
339
  RouterVersioner(
@@ -344,7 +347,7 @@ All routers are versioned together under the same prefix tree.
344
347
 
345
348
  ### Self-hosted docs (air-gapped environments)
346
349
 
347
- By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at assets you host yourself:
350
+ By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at locally hosted assets:
348
351
 
349
352
  ```python
350
353
  RouterVersioner(
@@ -360,7 +363,42 @@ RouterVersioner(
360
363
  ).versionize()
361
364
  ```
362
365
 
363
- See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a ready-made script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
366
+ See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
367
+
368
+ ### Validation error code
369
+
370
+ By default FastAPI returns `422 Unprocessable Entity` for request validation errors.
371
+ Use `validation_error_code` to change the code returned both at runtime and in the OpenAPI schema:
372
+
373
+ ```python
374
+ RouterVersioner(
375
+ app=app,
376
+ routers=router,
377
+ version_format=VersionFormat.SEMVER,
378
+ validation_error_code=400,
379
+ ).versionize()
380
+ # Validation errors now return 400; the schema shows 400 instead of 422
381
+ ```
382
+
383
+ To keep full control over the response body, set `handle_validation_exceptions=False` and register your own handler:
384
+
385
+ ```python
386
+ from fastapi import Request
387
+ from fastapi.exceptions import RequestValidationError
388
+ from fastapi.responses import JSONResponse
389
+
390
+ @app.exception_handler(RequestValidationError)
391
+ async def my_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
392
+ return JSONResponse(status_code=400, content={"errors": exc.errors()})
393
+
394
+ RouterVersioner(
395
+ app=app,
396
+ routers=router,
397
+ version_format=VersionFormat.SEMVER,
398
+ validation_error_code=400,
399
+ handle_validation_exceptions=False, # schema updated; handler is yours
400
+ ).versionize()
401
+ ```
364
402
 
365
403
  ### Reverse proxy / sub-app mounting
366
404
 
@@ -402,6 +440,8 @@ Runnable examples are available in the [`examples/`](examples/) directory:
402
440
  | [`webhook_versioning_app.py`](examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
403
441
  | [`multi_router_app.py`](examples/multi_router_app.py) | Multiple routers versioned together |
404
442
  | [`self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) | Swagger UI and ReDoc from local static assets |
443
+ | [`validation_error_code_app.py`](examples/validation_error_code_app.py) | Return `400` instead of `422` for validation errors (automatic handler via `RouterVersioner`) |
444
+ | [`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 |
405
445
 
406
446
  ---
407
447
 
@@ -1,12 +1,12 @@
1
- # FastAPI Router Versioning (Native API Versioning)
1
+ # FastAPI Router Versioning
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Python](https://img.shields.io/pypi/pyversions/fastapi-router-versioning)](https://pypi.org/project/fastapi-router-versioning/)
6
6
 
7
- The native, router-based solution for **API versioning in FastAPI**.
7
+ Router-based API versioning for FastAPI.
8
8
 
9
- If you want to implement **FastAPI API versioning**, you can simply annotate your routes with `@api_version`, call `.versionize()`, and get isolated URL prefixes, per-version Swagger UI, and a full route lifecycle — all without touching your existing application structure.
9
+ FastAPI has no built-in versioning mechanism. The common workaround duplicating routers or managing prefixes manually breaks down quickly as the number of versions grows. This package adds declarative versioning directly on routes, with isolated URL prefixes, per-version Swagger UI, and a full route lifecycle, without touching the existing application structure.
10
10
 
11
11
  ---
12
12
 
@@ -18,6 +18,7 @@ If you want to implement **FastAPI API versioning**, you can simply annotate you
18
18
  - **Latest alias** — serve the newest version under a stable `/latest` prefix
19
19
  - **Self-hosted docs** — point Swagger UI and ReDoc at your own assets for air-gapped environments
20
20
  - **Reverse proxy aware** — doc URLs include the ASGI `root_path` at request time, so sub-app mounting works out of the box
21
+ - **Configurable validation error code** — return `400` (or any code) instead of `422` for request validation errors, with the OpenAPI schema updated automatically
21
22
  - **Broad compatibility** — works with nested routers, WebSockets, `Depends`, and OpenAPI Callbacks
22
23
 
23
24
  ---
@@ -65,7 +66,7 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).ve
65
66
  # Mounts: GET /v1_0/items GET /v2_0/items
66
67
  ```
67
68
 
68
- Each version also gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
69
+ Each version gets its own Swagger UI at `/v1_0/docs`, `/v2_0/docs`, and so on.
69
70
 
70
71
  ---
71
72
 
@@ -89,9 +90,9 @@ RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).ve
89
90
  # Mounts: GET /2025-01-01/items
90
91
  ```
91
92
 
92
- Any string is a valid CalVer token: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
93
+ Valid CalVer tokens: `"2025-01-01"`, `"v3"`, `"stable"`, etc.
93
94
 
94
- > **CalVer sorting:** versions are sorted lexicographically, so strings must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Strings like `"v1"`, `"v10"`, `"v2"` will **not** sort correctly and will cause routes to appear in the wrong versions.
95
+ **CalVer sorting:** versions are sorted lexicographically, so tokens must be comparable in the intended order. ISO dates (`"2025-01-01"`) and zero-padded numbers (`"v01"`, `"v02"`) work correctly. Non-padded strings like `"v1"`, `"v10"`, `"v2"` will not sort correctly and will cause routes to appear in the wrong versions.
95
96
 
96
97
  ---
97
98
 
@@ -140,6 +141,8 @@ Routes without `@api_version` fall back to `default_version` (default: `(1, 0)`
140
141
  | `redoc_js_url` | `str \| None` | FastAPI CDN | Custom URL for the ReDoc JS bundle |
141
142
  | `redoc_favicon_url` | `str \| None` | FastAPI favicon | Custom URL for the ReDoc favicon |
142
143
  | `redoc_with_google_fonts` | `bool` | `True` | If `False`, ReDoc will not load Google Fonts |
144
+ | `validation_error_code` | `int` | `422` | HTTP status code returned for request validation errors; also replaces the `422` entry in the OpenAPI schema |
145
+ | `handle_validation_exceptions` | `bool` | `True` | If `False`, only the schema is updated — register your own `RequestValidationError` handler to control the response body |
143
146
 
144
147
  Call `.versionize()` after constructing the object. It returns the list of active versions.
145
148
 
@@ -153,8 +156,8 @@ Call `.versionize()` after constructing the object. It returns the list of activ
153
156
 
154
157
  | Parameter | Type | Required | Description |
155
158
  |---|---|---|---|
156
- | `version` | `tuple[int, int] \| str` | yes | Version when this route is introduced |
157
- | `deprecate_in` | same \| `None` | no | Version when this route is marked deprecated in the docs |
159
+ | `version` | `tuple[int, int] \| str` | yes | First version in which this route is active |
160
+ | `deprecate_in` | same \| `None` | no | Version in which this route is marked deprecated in the docs |
158
161
  | `remove_in` | same \| `None` | no | Version from which this route is removed entirely |
159
162
 
160
163
  All three parameters must match the `version_format` configured on `RouterVersioner`
@@ -250,7 +253,7 @@ RouterVersioner(
250
253
  ).versionize()
251
254
  ```
252
255
 
253
- The hook receives `(schema: dict, version: VersionT)` and must return the (modified) dict.
256
+ The hook receives `(schema: dict, version: VersionT)` and must return the modified dict.
254
257
 
255
258
  ### OpenAPI Callbacks and Webhooks
256
259
 
@@ -297,12 +300,11 @@ RouterVersioner(
297
300
  # /v2_0/openapi.json → webhooks: /order-created (V2) ← /payment-failed removed
298
301
  ```
299
302
 
300
- The same introduce / `remove_in` lifecycle applies. Webhook versions follow route versions:
301
- a new webhook version only appears once a route version creates that API prefix.
303
+ The same `remove_in` lifecycle applies. A new webhook version only appears once a route version creates that API prefix.
302
304
 
303
305
  ### Multiple routers
304
306
 
305
- Pass a list of routers to version routes that are split across modules:
307
+ Pass a list of routers to version routes split across modules:
306
308
 
307
309
  ```python
308
310
  RouterVersioner(
@@ -316,7 +318,7 @@ All routers are versioned together under the same prefix tree.
316
318
 
317
319
  ### Self-hosted docs (air-gapped environments)
318
320
 
319
- By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at assets you host yourself:
321
+ By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at locally hosted assets:
320
322
 
321
323
  ```python
322
324
  RouterVersioner(
@@ -332,7 +334,42 @@ RouterVersioner(
332
334
  ).versionize()
333
335
  ```
334
336
 
335
- See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a ready-made script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
337
+ See [`examples/download_static_assets.py`](examples/download_static_assets.py) for a script that downloads all required assets in one step, and [`examples/self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) for a complete working example.
338
+
339
+ ### Validation error code
340
+
341
+ By default FastAPI returns `422 Unprocessable Entity` for request validation errors.
342
+ Use `validation_error_code` to change the code returned both at runtime and in the OpenAPI schema:
343
+
344
+ ```python
345
+ RouterVersioner(
346
+ app=app,
347
+ routers=router,
348
+ version_format=VersionFormat.SEMVER,
349
+ validation_error_code=400,
350
+ ).versionize()
351
+ # Validation errors now return 400; the schema shows 400 instead of 422
352
+ ```
353
+
354
+ To keep full control over the response body, set `handle_validation_exceptions=False` and register your own handler:
355
+
356
+ ```python
357
+ from fastapi import Request
358
+ from fastapi.exceptions import RequestValidationError
359
+ from fastapi.responses import JSONResponse
360
+
361
+ @app.exception_handler(RequestValidationError)
362
+ async def my_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
363
+ return JSONResponse(status_code=400, content={"errors": exc.errors()})
364
+
365
+ RouterVersioner(
366
+ app=app,
367
+ routers=router,
368
+ version_format=VersionFormat.SEMVER,
369
+ validation_error_code=400,
370
+ handle_validation_exceptions=False, # schema updated; handler is yours
371
+ ).versionize()
372
+ ```
336
373
 
337
374
  ### Reverse proxy / sub-app mounting
338
375
 
@@ -374,6 +411,8 @@ Runnable examples are available in the [`examples/`](examples/) directory:
374
411
  | [`webhook_versioning_app.py`](examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
375
412
  | [`multi_router_app.py`](examples/multi_router_app.py) | Multiple routers versioned together |
376
413
  | [`self_hosted_docs_app.py`](examples/self_hosted_docs_app.py) | Swagger UI and ReDoc from local static assets |
414
+ | [`validation_error_code_app.py`](examples/validation_error_code_app.py) | Return `400` instead of `422` for validation errors (automatic handler via `RouterVersioner`) |
415
+ | [`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 |
377
416
 
378
417
  ---
379
418
 
@@ -0,0 +1,58 @@
1
+ # Release Notes
2
+
3
+ ## Latest Changes
4
+
5
+ ## 0.2.0 (2026-06-26)
6
+
7
+ ### Features
8
+
9
+ * ✨ Add `validation_error_code` parameter to `RouterVersioner` to override the HTTP status code returned for request validation errors (default: `422`). The OpenAPI schema is updated automatically to reflect the custom code. PR [#16](https://github.com/mat81black/fastapi-router-versioning/pull/16) by [@mat81black](https://github.com/mat81black).
10
+ * ✨ Add `handle_validation_exceptions` parameter to `RouterVersioner`: when `False`, only the OpenAPI schema is updated, leaving the exception handler to the user. PR [#16](https://github.com/mat81black/fastapi-router-versioning/pull/16) by [@mat81black](https://github.com/mat81black).
11
+
12
+ ### Fixes
13
+
14
+ * 🐛 Fix Python 3.14 classifier not actually added to `pyproject.toml` in 0.1.2. PR [#15](https://github.com/mat81black/fastapi-router-versioning/pull/15) by [@mat81black](https://github.com/mat81black).
15
+
16
+ ## 0.1.2 (2026-06-25)
17
+
18
+ ### Internal
19
+
20
+ * 👷 Add Python 3.14 classifier to `pyproject.toml` to align with the versions already covered by CI.
21
+
22
+ ### Docs
23
+
24
+ * 📝 Update README and `pyproject.toml` to clarify package description and improve documentation consistency. PR [#12](https://github.com/mat81black/fastapi-router-versioning/pull/12) by [@mat81black](https://github.com/mat81black).
25
+
26
+ ## 0.1.1 (2026-06-24)
27
+
28
+ ### Features
29
+
30
+ * ✨ Add per-version OpenAPI schema caching: schemas are generated once and cached, reducing overhead on repeated requests. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
31
+ * ✨ Track which routes belong to each version to invalidate the cache automatically when the route set changes. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
32
+
33
+ ### Fixes
34
+
35
+ * 🐛 Fix WebSocket routes not being handled correctly in `versioned_routers` when the cache was active. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
36
+ * 🐛 Improve OpenAPI schema cache resilience: cache is now safely bypassed on unexpected errors instead of raising. PR [#5](https://github.com/mat81black/fastapi-router-versioning/pull/5) by [@mat81black](https://github.com/mat81black).
37
+
38
+ ### Internal
39
+
40
+ * 👷 Add `prepare-release.yml` workflow and `scripts/prepare_release.py` for automated version bumping and release PR creation. PR [#6](https://github.com/mat81black/fastapi-router-versioning/pull/6) by [@mat81black](https://github.com/mat81black).
41
+ * 👷 Add `changes` job to `test.yml` and `test-redistribute.yml` to skip CI when no relevant files are modified. PR [#10](https://github.com/mat81black/fastapi-router-versioning/pull/10) by [@mat81black](https://github.com/mat81black).
42
+
43
+ ## 0.1.0 (2025-06-24)
44
+
45
+ 🚀 First official public release of **fastapi-router-versioning**.
46
+
47
+ Router-based API versioning for FastAPI, with declarative route lifecycle, per-version OpenAPI schemas, and isolated documentation — without altering the core application structure.
48
+
49
+ ### Features
50
+
51
+ * ✨ SemVer & CalVer support: route versioning using `(major, minor)` tuples or lexicographically sortable arbitrary strings.
52
+ * ✨ Declarative route lifecycle: introduce, deprecate (`deprecate_in`), and remove (`remove_in`) routes across versions.
53
+ * ✨ Per-version documentation: isolated Swagger UI, ReDoc, and `openapi.json` for each active version.
54
+ * ✨ OpenAPI schema hook: modify the filtered schema per version via `openapi_hook`.
55
+ * ✨ Independent webhook versioning via `webhook_routers`, with propagation of per-route OpenAPI Callbacks.
56
+ * ✨ Latest-version alias: expose the highest active version under a stable configurable prefix.
57
+ * ✨ Self-hosted docs: full control over Swagger/ReDoc static assets and option to disable Google Fonts.
58
+ * ✨ Reverse proxy aware: resolves and injects the ASGI `root_path` at request time.
@@ -58,4 +58,4 @@ versioner.versionize()
58
58
  if __name__ == "__main__":
59
59
  import uvicorn
60
60
 
61
- uvicorn.run(app, host="127.0.0.1", port=8003)
61
+ uvicorn.run(app, host="127.0.0.1", port=8004)
@@ -54,4 +54,4 @@ versioner.versionize()
54
54
  if __name__ == "__main__":
55
55
  import uvicorn
56
56
 
57
- uvicorn.run(app, host="127.0.0.1", port=8004)
57
+ uvicorn.run(app, host="127.0.0.1", port=8005)
@@ -0,0 +1,57 @@
1
+ """Demonstrate overriding the HTTP status code returned for request validation errors.
2
+
3
+ By default FastAPI returns 422 Unprocessable Entity for validation errors.
4
+ ``validation_error_code=400`` makes RouterVersioner register an exception handler
5
+ and update the OpenAPI schema to reflect the custom code automatically.
6
+
7
+ Run:
8
+ python examples/validation_error_code_app.py
9
+
10
+ Then open http://127.0.0.1:8007/v1_0/docs and try ``POST /v1_0/items`` with body
11
+ ``{"name": "widget", "quantity": "not-a-number"}`` to see the 400 response from
12
+ the server (not a Swagger client-side message).
13
+ """
14
+
15
+ from fastapi import APIRouter, FastAPI
16
+ from pydantic import BaseModel
17
+
18
+ from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
19
+
20
+
21
+ class CreateItemRequest(BaseModel):
22
+ name: str
23
+ quantity: int
24
+
25
+
26
+ app = FastAPI(
27
+ title="Validation Error Code — Automatic Handler",
28
+ description=(
29
+ "Validation errors return **400 Bad Request** instead of 422.\n\n"
30
+ 'Try `POST /v1_0/items` with body `{"name": "widget", "quantity": "not-a-number"}` '
31
+ "to see the 400 response from the server."
32
+ ),
33
+ )
34
+
35
+ router = APIRouter()
36
+
37
+
38
+ @router.post("/items")
39
+ @api_version((1, 0))
40
+ def create_item(body: CreateItemRequest) -> dict[str, str]:
41
+ """Create an item. ``name`` must be a string, ``quantity`` must be an integer."""
42
+ return {"name": body.name, "quantity": str(body.quantity)}
43
+
44
+
45
+ RouterVersioner(
46
+ app=app,
47
+ routers=router,
48
+ version_format=VersionFormat.SEMVER,
49
+ validation_error_code=400,
50
+ # handle_validation_exceptions=True is the default: no extra code needed.
51
+ ).versionize()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ import uvicorn
56
+
57
+ uvicorn.run(app, host="127.0.0.1", port=8007)
@@ -0,0 +1,70 @@
1
+ """Demonstrate using a custom validation exception handler alongside validation_error_code.
2
+
3
+ Setting ``handle_validation_exceptions=False`` tells RouterVersioner to update the
4
+ OpenAPI schema (so it shows 400 instead of 422) without registering any handler.
5
+ You then register your own handler for full control over the response body.
6
+
7
+ Run:
8
+ python examples/validation_error_code_custom_handler_app.py
9
+
10
+ Then open http://127.0.0.1:8008/v1_0/docs and try ``POST /v1_0/orders`` with body
11
+ ``{"name": "widget", "quantity": "not-a-number"}`` to see the custom 400 response
12
+ with field-level error messages.
13
+ """
14
+
15
+ from fastapi import APIRouter, FastAPI, Request
16
+ from fastapi.exceptions import RequestValidationError
17
+ from fastapi.responses import JSONResponse
18
+ from pydantic import BaseModel
19
+
20
+ from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version
21
+
22
+
23
+ class CreateOrderRequest(BaseModel):
24
+ name: str
25
+ quantity: int
26
+
27
+
28
+ app = FastAPI(
29
+ title="Validation Error Code — Custom Handler",
30
+ description=(
31
+ "Validation errors return **400 Bad Request** with a custom response body.\n\n"
32
+ 'Try `POST /v1_0/orders` with body `{"name": "widget", "quantity": "not-a-number"}` '
33
+ "to see the custom handler response."
34
+ ),
35
+ )
36
+
37
+ router = APIRouter()
38
+
39
+
40
+ @router.post("/orders")
41
+ @api_version((1, 0))
42
+ def create_order(body: CreateOrderRequest) -> dict[str, str]:
43
+ """Create an order. ``name`` must be a string, ``quantity`` must be an integer."""
44
+ return {"name": body.name, "quantity": str(body.quantity)}
45
+
46
+
47
+ @app.exception_handler(RequestValidationError)
48
+ async def my_validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
49
+ """Returns field-level error messages under an 'errors' key."""
50
+ return JSONResponse(
51
+ status_code=400,
52
+ content={
53
+ "errors": [{"field": e["loc"][-1], "message": e["msg"]} for e in exc.errors()],
54
+ },
55
+ )
56
+
57
+
58
+ RouterVersioner(
59
+ app=app,
60
+ routers=router,
61
+ version_format=VersionFormat.SEMVER,
62
+ validation_error_code=400,
63
+ handle_validation_exceptions=False, # OpenAPI schema shows 400; handler is yours.
64
+ ).versionize()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ import uvicorn
69
+
70
+ uvicorn.run(app, host="127.0.0.1", port=8008)
@@ -88,4 +88,4 @@ versioner.versionize()
88
88
  if __name__ == "__main__":
89
89
  import uvicorn
90
90
 
91
- uvicorn.run(app, host="127.0.0.1", port=8004)
91
+ uvicorn.run(app, host="127.0.0.1", port=8006)
@@ -1,5 +1,5 @@
1
1
  from .versioner import RouterVersioner, VersionFormat, VersionT, api_version
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]