fastapi-router-versioning 0.1.2__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 (41) hide show
  1. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/workflows/pre-commit.yml +1 -1
  2. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/workflows/prepare-release.yml +1 -1
  3. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/workflows/publish.yml +1 -1
  4. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/workflows/test-redistribute.yml +2 -2
  5. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/workflows/test.yml +3 -3
  6. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/PKG-INFO +42 -1
  7. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/README.md +40 -0
  8. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/RELEASE_NOTES.md +11 -0
  9. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/examples/openapi_hook_app.py +1 -1
  10. {fastapi_router_versioning-0.1.2 → 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.2 → fastapi_router_versioning-0.2.0}/examples/webhook_versioning_app.py +1 -1
  14. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/fastapi_router_versioning/__init__.py +1 -1
  15. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/fastapi_router_versioning/versioner.py +71 -0
  16. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/pyproject.toml +1 -0
  17. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/tests/test_versioner.py +206 -1
  18. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/uv.lock +163 -178
  19. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/dependabot.yml +0 -0
  20. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.github/workflows/detect-conflicts.yml +0 -0
  21. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.gitignore +0 -0
  22. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.pre-commit-config.yaml +0 -0
  23. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/.python-version +0 -0
  24. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/LICENSE +0 -0
  25. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/examples/calver_app.py +0 -0
  26. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/examples/download_static_assets.py +0 -0
  27. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/examples/multi_router_app.py +0 -0
  28. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/examples/semver_app.py +0 -0
  29. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/examples/semver_major_only_app.py +0 -0
  30. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/fastapi_router_versioning/py.typed +0 -0
  31. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/scripts/format.sh +0 -0
  32. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/scripts/lint.sh +0 -0
  33. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/scripts/prepare_release.py +0 -0
  34. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/scripts/test-cov-html.sh +0 -0
  35. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/scripts/test-cov.sh +0 -0
  36. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/scripts/test.sh +0 -0
  37. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/tests/__init__.py +0 -0
  38. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/tests/test_calver.py +0 -0
  39. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/tests/test_fastapi_integration.py +0 -0
  40. {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.0}/tests/test_prepare_release.py +0 -0
  41. {fastapi_router_versioning-0.1.2 → 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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-router-versioning
3
- Version: 0.1.2
3
+ Version: 0.2.0
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
@@ -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
@@ -46,6 +47,7 @@ FastAPI has no built-in versioning mechanism. The common workaround — duplicat
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
  ---
@@ -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
 
@@ -361,6 +365,41 @@ RouterVersioner(
361
365
 
362
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.
363
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
+ ```
402
+
364
403
  ### Reverse proxy / sub-app mounting
365
404
 
366
405
  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:
@@ -401,6 +440,8 @@ Runnable examples are available in the [`examples/`](examples/) directory:
401
440
  | [`webhook_versioning_app.py`](examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
402
441
  | [`multi_router_app.py`](examples/multi_router_app.py) | Multiple routers versioned together |
403
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 |
404
445
 
405
446
  ---
406
447
 
@@ -18,6 +18,7 @@ FastAPI has no built-in versioning mechanism. The common workaround — duplicat
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
  ---
@@ -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
 
@@ -333,6 +336,41 @@ RouterVersioner(
333
336
 
334
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.
335
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
+ ```
373
+
336
374
  ### Reverse proxy / sub-app mounting
337
375
 
338
376
  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:
@@ -373,6 +411,8 @@ Runnable examples are available in the [`examples/`](examples/) directory:
373
411
  | [`webhook_versioning_app.py`](examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
374
412
  | [`multi_router_app.py`](examples/multi_router_app.py) | Multiple routers versioned together |
375
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 |
376
416
 
377
417
  ---
378
418
 
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Latest Changes
4
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
+
5
16
  ## 0.1.2 (2026-06-25)
6
17
 
7
18
  ### Internal
@@ -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.2"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  __all__ = ["RouterVersioner", "api_version", "VersionFormat", "VersionT"]
@@ -9,6 +9,8 @@ import fastapi.openapi.utils
9
9
  import fastapi.routing
10
10
 
11
11
  from fastapi import APIRouter, FastAPI
12
+ from fastapi.encoders import jsonable_encoder
13
+ from fastapi.exceptions import RequestValidationError
12
14
  from fastapi.openapi.docs import (
13
15
  get_redoc_html,
14
16
  get_swagger_ui_html,
@@ -137,6 +139,8 @@ class RouterVersioner:
137
139
  redoc_js_url: str | None = None,
138
140
  redoc_favicon_url: str | None = None,
139
141
  redoc_with_google_fonts: bool = True,
142
+ validation_error_code: int = 422,
143
+ handle_validation_exceptions: bool = True,
140
144
  ):
141
145
  """
142
146
  Versionize your FastAPI application in-place, organizing routes based on their API version.
@@ -171,6 +175,10 @@ class RouterVersioner:
171
175
  :param redoc_js_url: Custom URL for the ReDoc JS bundle. Defaults to FastAPI's CDN URL.
172
176
  :param redoc_favicon_url: Custom URL for the ReDoc favicon. Defaults to FastAPI's favicon.
173
177
  :param redoc_with_google_fonts: If False, ReDoc will not load Google Fonts. Defaults to True.
178
+ :param validation_error_code: HTTP status code returned for request validation errors. Defaults to 422.
179
+ :param handle_validation_exceptions: If True (default), registers an exception handler that returns
180
+ validation errors with the given ``validation_error_code``. Set to False to register your own
181
+ handler while still having the OpenAPI schema reflect the custom code.
174
182
  """
175
183
  self._app = app
176
184
  self._routers = [routers] if isinstance(routers, APIRouter) else routers
@@ -214,6 +222,12 @@ class RouterVersioner:
214
222
  self._docs_url = getattr(app, "docs_url", "/docs")
215
223
  self._redoc_url = getattr(app, "redoc_url", "/redoc")
216
224
 
225
+ self._validation_error_code = validation_error_code
226
+ self._handle_validation_exceptions = handle_validation_exceptions
227
+
228
+ if self._validation_error_code != 422 and self._handle_validation_exceptions:
229
+ self._register_validation_exception_handler()
230
+
217
231
  def _validate_version_type(self, version: Any, route_path: str) -> None:
218
232
  if self._version_format == VersionFormat.SEMVER:
219
233
  if not isinstance(version, tuple) or len(version) != 2 or not all(isinstance(i, int) for i in version):
@@ -456,6 +470,9 @@ class RouterVersioner:
456
470
  separate_input_output_schemas=self._app.separate_input_output_schemas,
457
471
  external_docs=self._app.openapi_external_docs,
458
472
  )
473
+
474
+ self._patch_validation_error_openapi(schema)
475
+
459
476
  if self._openapi_hook is not None:
460
477
  schema = self._openapi_hook(schema, version)
461
478
  self._openapi_schemas_cache[version] = schema
@@ -582,3 +599,57 @@ class RouterVersioner:
582
599
  filtered_kwargs.pop("name")
583
600
 
584
601
  add_method(**filtered_kwargs)
602
+
603
+ def _register_validation_exception_handler(self) -> None:
604
+ if getattr(self._app.state, "_validation_overridden", False):
605
+ return
606
+
607
+ @self._app.exception_handler(RequestValidationError)
608
+ async def custom_validation_exception_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
609
+ return JSONResponse(
610
+ status_code=self._validation_error_code,
611
+ content={"detail": jsonable_encoder(exc.errors())},
612
+ )
613
+
614
+ self._app.state._validation_overridden = True
615
+
616
+ def _patch_validation_error_openapi(self, schema: dict[str, Any]) -> None:
617
+ if self._validation_error_code == 422:
618
+ return
619
+
620
+ target_code = str(self._validation_error_code)
621
+ http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
622
+
623
+ for _path, path_item in schema.get("paths", {}).items():
624
+ for _method, operation in path_item.items():
625
+ if _method not in http_methods:
626
+ continue
627
+
628
+ responses = operation.get("responses", {})
629
+ if "422" in responses:
630
+ response_422 = responses["422"]
631
+ content_422 = response_422.get("content", {}).get("application/json", {})
632
+ schema_422 = content_422.get("schema", {})
633
+ ref = schema_422.get("$ref", "")
634
+
635
+ if ref.endswith("HTTPValidationError"):
636
+ if target_code in responses:
637
+ existing_response = responses[target_code]
638
+ existing_content = existing_response.setdefault("content", {}).setdefault(
639
+ "application/json", {}
640
+ )
641
+ existing_schema = existing_content.setdefault("schema", {})
642
+
643
+ if "anyOf" in existing_schema:
644
+ existing_schema["anyOf"].append(schema_422)
645
+ elif existing_schema:
646
+ existing_content["schema"] = {"anyOf": [existing_schema, schema_422]}
647
+ else:
648
+ existing_content["schema"] = schema_422
649
+
650
+ old_desc = existing_response.get("description", "Error")
651
+ existing_response["description"] = f"{old_desc} / Validation Error"
652
+
653
+ del responses["422"]
654
+ else:
655
+ responses[target_code] = responses.pop("422")
@@ -25,6 +25,7 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3.11",
26
26
  "Programming Language :: Python :: 3.12",
27
27
  "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
28
29
  "Topic :: Internet :: WWW/HTTP",
29
30
  "Topic :: Software Development :: Libraries :: Python Modules",
30
31
  "Typing :: Typed"