fastapi-router-versioning 0.1.2__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/pre-commit.yml +1 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/prepare-release.yml +1 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/publish.yml +1 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/test-redistribute.yml +2 -2
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/test.yml +3 -3
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/PKG-INFO +42 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/README.md +40 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/RELEASE_NOTES.md +17 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/openapi_hook_app.py +1 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/self_hosted_docs_app.py +1 -1
- fastapi_router_versioning-0.2.1/examples/validation_error_code_app.py +57 -0
- fastapi_router_versioning-0.2.1/examples/validation_error_code_custom_handler_app.py +70 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/webhook_versioning_app.py +1 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/fastapi_router_versioning/__init__.py +1 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/fastapi_router_versioning/versioner.py +72 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/pyproject.toml +1 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/tests/test_versioner.py +206 -1
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/uv.lock +163 -178
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/dependabot.yml +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/detect-conflicts.yml +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.gitignore +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.pre-commit-config.yaml +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.python-version +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/LICENSE +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/calver_app.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/download_static_assets.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/multi_router_app.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/semver_app.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/examples/semver_major_only_app.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/fastapi_router_versioning/py.typed +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/scripts/format.sh +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/scripts/lint.sh +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/scripts/prepare_release.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/scripts/test-cov-html.sh +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/scripts/test-cov.sh +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/scripts/test.sh +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/tests/__init__.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/tests/test_calver.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/tests/test_fastapi_integration.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/tests/test_prepare_release.py +0 -0
- {fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/tests/test_semver.py +0 -0
{fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/pre-commit.yml
RENAMED
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
run: echo "$GITHUB_CONTEXT"
|
|
22
22
|
|
|
23
23
|
- name: Checkout code
|
|
24
|
-
uses: actions/checkout@
|
|
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@
|
|
34
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
35
35
|
with:
|
|
36
36
|
persist-credentials: true
|
|
37
37
|
- name: Set up Python
|
{fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/publish.yml
RENAMED
|
@@ -20,7 +20,7 @@ jobs:
|
|
|
20
20
|
env:
|
|
21
21
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
|
22
22
|
run: echo "$GITHUB_CONTEXT"
|
|
23
|
-
- uses: actions/checkout@
|
|
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@
|
|
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@
|
|
51
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
52
52
|
with:
|
|
53
53
|
persist-credentials: false
|
|
54
54
|
- name: Set up Python
|
{fastapi_router_versioning-0.1.2 → fastapi_router_versioning-0.2.1}/.github/workflows/test.yml
RENAMED
|
@@ -25,7 +25,7 @@ jobs:
|
|
|
25
25
|
outputs:
|
|
26
26
|
src: ${{ steps.filter.outputs.src }}
|
|
27
27
|
steps:
|
|
28
|
-
- uses: actions/checkout@
|
|
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@
|
|
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@
|
|
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
|
|
3
|
+
Version: 0.2.1
|
|
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,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Latest Changes
|
|
4
4
|
|
|
5
|
+
## 0.2.1 (2026-06-26)
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
* 🐛 Fix `api_version` signature to enforce `deprecate_in` and `remove_in` as keyword-only arguments, aligning the implementation with the documented signature. PR [#18](https://github.com/mat81black/fastapi-router-versioning/pull/18) by [@mat81black](https://github.com/mat81black).
|
|
10
|
+
|
|
11
|
+
## 0.2.0 (2026-06-26)
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* ✨ 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).
|
|
16
|
+
* ✨ 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).
|
|
17
|
+
|
|
18
|
+
### Fixes
|
|
19
|
+
|
|
20
|
+
* 🐛 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).
|
|
21
|
+
|
|
5
22
|
## 0.1.2 (2026-06-25)
|
|
6
23
|
|
|
7
24
|
### Internal
|
|
@@ -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)
|
|
@@ -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,
|
|
@@ -83,6 +85,7 @@ def _validate_api_version_arg(value: Any, param_name: str) -> None:
|
|
|
83
85
|
|
|
84
86
|
def api_version(
|
|
85
87
|
version: VersionT,
|
|
88
|
+
*,
|
|
86
89
|
deprecate_in: VersionT | None = None,
|
|
87
90
|
remove_in: VersionT | None = None,
|
|
88
91
|
) -> Callable[[CallableT], CallableT]:
|
|
@@ -137,6 +140,8 @@ class RouterVersioner:
|
|
|
137
140
|
redoc_js_url: str | None = None,
|
|
138
141
|
redoc_favicon_url: str | None = None,
|
|
139
142
|
redoc_with_google_fonts: bool = True,
|
|
143
|
+
validation_error_code: int = 422,
|
|
144
|
+
handle_validation_exceptions: bool = True,
|
|
140
145
|
):
|
|
141
146
|
"""
|
|
142
147
|
Versionize your FastAPI application in-place, organizing routes based on their API version.
|
|
@@ -171,6 +176,10 @@ class RouterVersioner:
|
|
|
171
176
|
:param redoc_js_url: Custom URL for the ReDoc JS bundle. Defaults to FastAPI's CDN URL.
|
|
172
177
|
:param redoc_favicon_url: Custom URL for the ReDoc favicon. Defaults to FastAPI's favicon.
|
|
173
178
|
:param redoc_with_google_fonts: If False, ReDoc will not load Google Fonts. Defaults to True.
|
|
179
|
+
:param validation_error_code: HTTP status code returned for request validation errors. Defaults to 422.
|
|
180
|
+
:param handle_validation_exceptions: If True (default), registers an exception handler that returns
|
|
181
|
+
validation errors with the given ``validation_error_code``. Set to False to register your own
|
|
182
|
+
handler while still having the OpenAPI schema reflect the custom code.
|
|
174
183
|
"""
|
|
175
184
|
self._app = app
|
|
176
185
|
self._routers = [routers] if isinstance(routers, APIRouter) else routers
|
|
@@ -214,6 +223,12 @@ class RouterVersioner:
|
|
|
214
223
|
self._docs_url = getattr(app, "docs_url", "/docs")
|
|
215
224
|
self._redoc_url = getattr(app, "redoc_url", "/redoc")
|
|
216
225
|
|
|
226
|
+
self._validation_error_code = validation_error_code
|
|
227
|
+
self._handle_validation_exceptions = handle_validation_exceptions
|
|
228
|
+
|
|
229
|
+
if self._validation_error_code != 422 and self._handle_validation_exceptions:
|
|
230
|
+
self._register_validation_exception_handler()
|
|
231
|
+
|
|
217
232
|
def _validate_version_type(self, version: Any, route_path: str) -> None:
|
|
218
233
|
if self._version_format == VersionFormat.SEMVER:
|
|
219
234
|
if not isinstance(version, tuple) or len(version) != 2 or not all(isinstance(i, int) for i in version):
|
|
@@ -456,6 +471,9 @@ class RouterVersioner:
|
|
|
456
471
|
separate_input_output_schemas=self._app.separate_input_output_schemas,
|
|
457
472
|
external_docs=self._app.openapi_external_docs,
|
|
458
473
|
)
|
|
474
|
+
|
|
475
|
+
self._patch_validation_error_openapi(schema)
|
|
476
|
+
|
|
459
477
|
if self._openapi_hook is not None:
|
|
460
478
|
schema = self._openapi_hook(schema, version)
|
|
461
479
|
self._openapi_schemas_cache[version] = schema
|
|
@@ -582,3 +600,57 @@ class RouterVersioner:
|
|
|
582
600
|
filtered_kwargs.pop("name")
|
|
583
601
|
|
|
584
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")
|
|
@@ -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"
|