fastapi-viewsets 1.5.1__tar.gz → 1.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_viewsets-1.5.1/fastapi_viewsets.egg-info → fastapi_viewsets-1.5.2}/PKG-INFO +61 -14
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/README.md +57 -10
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/getting-started.md +4 -3
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/pagination-filtering.md +2 -2
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/__init__.py +11 -5
- fastapi_viewsets-1.5.2/fastapi_viewsets/_register.py +245 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/async_base.py +12 -6
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/peewee_adapter.py +12 -11
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/sqlalchemy_adapter.py +46 -30
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/tortoise_adapter.py +13 -11
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2/fastapi_viewsets.egg-info}/PKG-INFO +61 -14
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/SOURCES.txt +1 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/requires.txt +3 -3
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/pyproject.toml +4 -4
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_async_utils.py +10 -10
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_edge_cases.py +11 -13
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_error_handling.py +1 -1
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_exception_handling.py +3 -3
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_integration.py +1 -1
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_utils.py +9 -9
- fastapi_viewsets-1.5.2/tests/test_write_path_regressions.py +447 -0
- fastapi_viewsets-1.5.1/fastapi_viewsets/_register.py +0 -133
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/LICENSE +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/MANIFEST.in +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/api-reference.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/assets/custom.css +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/authentication.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/changelog.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/comparison.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/custom-routes.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/eager-loading.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/index.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/orm-adapters.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/overrides.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/quickstart-async.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/docs/quickstart-sync.md +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/_compat.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/async_utils.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/constants.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/db_conf.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/filtering.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/__init__.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/base.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/orm/factory.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/serializer_utils.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets/utils.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/fastapi_viewsets.egg-info/top_level.txt +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/mkdocs.yml +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/pytest.ini +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/setup.cfg +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/setup.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/__init__.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/conftest.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_adapter_methods_coverage.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_async_base_viewset.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_async_driver_missing.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_backward_compatibility.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_base_viewset.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_coverage_gaps.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_db_conf.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_db_conf_extended.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_missing_coverage.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_orm_adapters.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_orm_adapters_extended.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_quickstarts.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_search_ordering.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_select_prefetch_related.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_tortoise_lifecycle.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/test_viewsets_with_adapters.py +0 -0
- {fastapi_viewsets-1.5.1 → fastapi_viewsets-1.5.2}/tests/tortoise_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi_viewsets
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.2
|
|
4
4
|
Summary: DRF-style viewsets for FastAPI with SQLAlchemy/Tortoise/Peewee adapters and Pydantic v2 support.
|
|
5
5
|
Author: Alexander Valenchits
|
|
6
6
|
License: MIT
|
|
@@ -25,12 +25,10 @@ Requires-Python: >=3.9
|
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Dist: fastapi>=0.110.0
|
|
28
|
-
Requires-Dist: uvicorn>=0.17.6
|
|
29
|
-
Requires-Dist: SQLAlchemy>=2.0.0
|
|
30
28
|
Requires-Dist: pydantic<3,>=2.5
|
|
31
29
|
Requires-Dist: python-dotenv>=0.19.0
|
|
32
30
|
Provides-Extra: sqlalchemy
|
|
33
|
-
Requires-Dist: SQLAlchemy>=2.0.0; extra == "sqlalchemy"
|
|
31
|
+
Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "sqlalchemy"
|
|
34
32
|
Provides-Extra: tortoise
|
|
35
33
|
Requires-Dist: tortoise-orm<1.0,>=0.20.0; extra == "tortoise"
|
|
36
34
|
Requires-Dist: asyncpg>=0.28.0; extra == "tortoise"
|
|
@@ -43,6 +41,8 @@ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
|
43
41
|
Requires-Dist: httpx>=0.24.0; extra == "test"
|
|
44
42
|
Requires-Dist: faker>=18.0.0; extra == "test"
|
|
45
43
|
Requires-Dist: aiosqlite>=0.19.0; extra == "test"
|
|
44
|
+
Requires-Dist: uvicorn>=0.17.6; extra == "test"
|
|
45
|
+
Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "test"
|
|
46
46
|
Provides-Extra: lint
|
|
47
47
|
Requires-Dist: ruff>=0.5; extra == "lint"
|
|
48
48
|
Requires-Dist: black>=24; extra == "lint"
|
|
@@ -95,18 +95,21 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
95
95
|
pip install fastapi-viewsets
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Optional extras (see `pyproject.toml`):
|
|
98
|
+
The base install is ORM-agnostic: it pulls in only FastAPI, Pydantic and
|
|
99
|
+
python-dotenv. Pick your ORM via an extra (see `pyproject.toml`):
|
|
102
100
|
|
|
103
101
|
```bash
|
|
104
|
-
pip install "fastapi-viewsets[sqlalchemy]"
|
|
102
|
+
pip install "fastapi-viewsets[sqlalchemy]" # SQLAlchemy 2.x, incl. asyncio support (greenlet)
|
|
105
103
|
pip install "fastapi-viewsets[tortoise]"
|
|
106
104
|
pip install "fastapi-viewsets[peewee]"
|
|
107
105
|
pip install "fastapi-viewsets[test]" # pytest, httpx, coverage, etc.
|
|
108
106
|
```
|
|
109
107
|
|
|
108
|
+
For a local SQLite app, start with the
|
|
109
|
+
[sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
|
|
110
|
+
To execute the quickstart's `python main.py` (or serve any app) you also
|
|
111
|
+
need an ASGI server, e.g. `pip install uvicorn`.
|
|
112
|
+
|
|
110
113
|
For async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.
|
|
111
114
|
|
|
112
115
|
## Database connection examples
|
|
@@ -498,10 +501,23 @@ Notes:
|
|
|
498
501
|
`model_config = ConfigDict(from_attributes=True)` instead of the v1
|
|
499
502
|
`class Config: orm_mode = True`.
|
|
500
503
|
- `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
|
|
501
|
-
fields are no longer overwritten with defaults.
|
|
504
|
+
fields are no longer overwritten with defaults. Since v1.5.2 the PATCH
|
|
505
|
+
body is validated against an auto-generated all-optional variant of
|
|
506
|
+
your `response_model`, so partial bodies pass validation even when the
|
|
507
|
+
schema has required fields, and an explicit JSON `null` clears a
|
|
508
|
+
nullable column.
|
|
509
|
+
- `PUT` replaces exactly the fields present in the payload; model
|
|
510
|
+
columns that are not part of the `response_model` are left untouched.
|
|
511
|
+
- `POST` never sends an explicit `NULL` primary key to the database —
|
|
512
|
+
`id: Optional[int] = None` in the schema is safe on every adapter.
|
|
513
|
+
- Integrity violations (e.g. duplicate unique values) return
|
|
514
|
+
`409 Conflict` with a sanitized message; raw SQL and driver internals
|
|
515
|
+
are never exposed in the response body.
|
|
502
516
|
- If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
|
|
503
517
|
installed, sync usage still works — only `get_async_session()` raises
|
|
504
|
-
a helpful `RuntimeError`.
|
|
518
|
+
a helpful `RuntimeError`. The `sqlalchemy` extra installs
|
|
519
|
+
`SQLAlchemy[asyncio]`, which already includes `greenlet` for async
|
|
520
|
+
sessions.
|
|
505
521
|
|
|
506
522
|
## Overriding `list` and `create_element` (custom LIST and POST)
|
|
507
523
|
|
|
@@ -509,8 +525,11 @@ Every CRUD handler is a regular method, so subclassing the viewset is
|
|
|
509
525
|
the canonical way to add filtering, ordering, validation, conflict
|
|
510
526
|
handling, and so on. The example below subclasses `AsyncBaseViewset`
|
|
511
527
|
and overrides both `list` (case-insensitive search + simple ordering)
|
|
512
|
-
and `create_element` (input normalization +
|
|
513
|
-
|
|
528
|
+
and `create_element` (input normalization + custom conflict message).
|
|
529
|
+
|
|
530
|
+
> Since v1.5.2 the adapters themselves map `IntegrityError` to
|
|
531
|
+
> `409 Conflict`; override `create_element` only when you need a custom
|
|
532
|
+
> error payload or extra normalization.
|
|
514
533
|
|
|
515
534
|
```python
|
|
516
535
|
from typing import List, Optional
|
|
@@ -676,7 +695,7 @@ app.include_router(router)
|
|
|
676
695
|
|
|
677
696
|
## Pagination, filtering, ordering
|
|
678
697
|
|
|
679
|
-
**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.
|
|
698
|
+
**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route. Defaults are `limit=10`, `offset=0`; negative values are rejected with `422`, and `limit` is capped at `10000`.
|
|
680
699
|
|
|
681
700
|
```python
|
|
682
701
|
from fastapi_viewsets import BaseViewset
|
|
@@ -737,6 +756,34 @@ class ItemsWithStats(BaseViewset):
|
|
|
737
756
|
|
|
738
757
|
## What is new
|
|
739
758
|
|
|
759
|
+
### v1.5.2
|
|
760
|
+
|
|
761
|
+
Bugfix release for the CRUD write paths (see
|
|
762
|
+
[RELEASE_1.5.2.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.2.md)):
|
|
763
|
+
|
|
764
|
+
- **Multi-viewset apps fixed**: `register()` no longer leaks one viewset's
|
|
765
|
+
body schema into other viewsets — every `POST`/`PUT`/`PATCH` endpoint
|
|
766
|
+
now validates against its own `response_model`.
|
|
767
|
+
- **PATCH is truly partial**: the PATCH body is validated against an
|
|
768
|
+
auto-generated all-optional variant of the response schema, and an
|
|
769
|
+
explicit JSON `null` clears a nullable column.
|
|
770
|
+
- **PUT no longer nulls columns** that are absent from the Pydantic
|
|
771
|
+
schema.
|
|
772
|
+
- **Integrity violations return `409 Conflict`** with a sanitized message
|
|
773
|
+
instead of `400` with raw SQL/driver internals.
|
|
774
|
+
- **Create never passes an explicit `NULL` primary key** — fixes Tortoise
|
|
775
|
+
`POST` and PostgreSQL inserts with `id: Optional[int] = None` schemas.
|
|
776
|
+
- **Pagination validated**: negative `limit`/`offset` rejected with `422`.
|
|
777
|
+
- **Filters advertised in OpenAPI**: whitelisted `ListConfig.filters`
|
|
778
|
+
fields and their `__op` variants show up in Swagger UI.
|
|
779
|
+
- **Leaner dependencies**: the base install no longer requires SQLAlchemy
|
|
780
|
+
or uvicorn; the `sqlalchemy` extra installs `SQLAlchemy[asyncio]`
|
|
781
|
+
(includes `greenlet`).
|
|
782
|
+
|
|
783
|
+
Earlier releases: v1.5.0 (server-side `search`, declarative ordering and
|
|
784
|
+
filters — [RELEASE_1.5.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.0.md)),
|
|
785
|
+
v1.4.0 ([RELEASE_1.4.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.4.0.md)).
|
|
786
|
+
|
|
740
787
|
### v1.3.0
|
|
741
788
|
|
|
742
789
|
- **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.
|
|
@@ -40,18 +40,21 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
40
40
|
pip install fastapi-viewsets
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Optional extras (see `pyproject.toml`):
|
|
43
|
+
The base install is ORM-agnostic: it pulls in only FastAPI, Pydantic and
|
|
44
|
+
python-dotenv. Pick your ORM via an extra (see `pyproject.toml`):
|
|
47
45
|
|
|
48
46
|
```bash
|
|
49
|
-
pip install "fastapi-viewsets[sqlalchemy]"
|
|
47
|
+
pip install "fastapi-viewsets[sqlalchemy]" # SQLAlchemy 2.x, incl. asyncio support (greenlet)
|
|
50
48
|
pip install "fastapi-viewsets[tortoise]"
|
|
51
49
|
pip install "fastapi-viewsets[peewee]"
|
|
52
50
|
pip install "fastapi-viewsets[test]" # pytest, httpx, coverage, etc.
|
|
53
51
|
```
|
|
54
52
|
|
|
53
|
+
For a local SQLite app, start with the
|
|
54
|
+
[sync quickstart](#quickstart-sqlalchemy-sync); no separate database driver is needed.
|
|
55
|
+
To execute the quickstart's `python main.py` (or serve any app) you also
|
|
56
|
+
need an ASGI server, e.g. `pip install uvicorn`.
|
|
57
|
+
|
|
55
58
|
For async SQLAlchemy you still need a driver such as `aiosqlite`, `asyncpg`, or `aiomysql` alongside your database URL.
|
|
56
59
|
|
|
57
60
|
## Database connection examples
|
|
@@ -443,10 +446,23 @@ Notes:
|
|
|
443
446
|
`model_config = ConfigDict(from_attributes=True)` instead of the v1
|
|
444
447
|
`class Config: orm_mode = True`.
|
|
445
448
|
- `PATCH` uses `model_dump(exclude_unset=True)` internally, so unset
|
|
446
|
-
fields are no longer overwritten with defaults.
|
|
449
|
+
fields are no longer overwritten with defaults. Since v1.5.2 the PATCH
|
|
450
|
+
body is validated against an auto-generated all-optional variant of
|
|
451
|
+
your `response_model`, so partial bodies pass validation even when the
|
|
452
|
+
schema has required fields, and an explicit JSON `null` clears a
|
|
453
|
+
nullable column.
|
|
454
|
+
- `PUT` replaces exactly the fields present in the payload; model
|
|
455
|
+
columns that are not part of the `response_model` are left untouched.
|
|
456
|
+
- `POST` never sends an explicit `NULL` primary key to the database —
|
|
457
|
+
`id: Optional[int] = None` in the schema is safe on every adapter.
|
|
458
|
+
- Integrity violations (e.g. duplicate unique values) return
|
|
459
|
+
`409 Conflict` with a sanitized message; raw SQL and driver internals
|
|
460
|
+
are never exposed in the response body.
|
|
447
461
|
- If the async driver (`aiosqlite` / `asyncpg` / `aiomysql`) is not
|
|
448
462
|
installed, sync usage still works — only `get_async_session()` raises
|
|
449
|
-
a helpful `RuntimeError`.
|
|
463
|
+
a helpful `RuntimeError`. The `sqlalchemy` extra installs
|
|
464
|
+
`SQLAlchemy[asyncio]`, which already includes `greenlet` for async
|
|
465
|
+
sessions.
|
|
450
466
|
|
|
451
467
|
## Overriding `list` and `create_element` (custom LIST and POST)
|
|
452
468
|
|
|
@@ -454,8 +470,11 @@ Every CRUD handler is a regular method, so subclassing the viewset is
|
|
|
454
470
|
the canonical way to add filtering, ordering, validation, conflict
|
|
455
471
|
handling, and so on. The example below subclasses `AsyncBaseViewset`
|
|
456
472
|
and overrides both `list` (case-insensitive search + simple ordering)
|
|
457
|
-
and `create_element` (input normalization +
|
|
458
|
-
|
|
473
|
+
and `create_element` (input normalization + custom conflict message).
|
|
474
|
+
|
|
475
|
+
> Since v1.5.2 the adapters themselves map `IntegrityError` to
|
|
476
|
+
> `409 Conflict`; override `create_element` only when you need a custom
|
|
477
|
+
> error payload or extra normalization.
|
|
459
478
|
|
|
460
479
|
```python
|
|
461
480
|
from typing import List, Optional
|
|
@@ -621,7 +640,7 @@ app.include_router(router)
|
|
|
621
640
|
|
|
622
641
|
## Pagination, filtering, ordering
|
|
623
642
|
|
|
624
|
-
**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route.
|
|
643
|
+
**Pagination** — `BaseViewset.list` maps `limit` and `offset` to query parameters on the LIST route. Defaults are `limit=10`, `offset=0`; negative values are rejected with `422`, and `limit` is capped at `10000`.
|
|
625
644
|
|
|
626
645
|
```python
|
|
627
646
|
from fastapi_viewsets import BaseViewset
|
|
@@ -682,6 +701,34 @@ class ItemsWithStats(BaseViewset):
|
|
|
682
701
|
|
|
683
702
|
## What is new
|
|
684
703
|
|
|
704
|
+
### v1.5.2
|
|
705
|
+
|
|
706
|
+
Bugfix release for the CRUD write paths (see
|
|
707
|
+
[RELEASE_1.5.2.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.2.md)):
|
|
708
|
+
|
|
709
|
+
- **Multi-viewset apps fixed**: `register()` no longer leaks one viewset's
|
|
710
|
+
body schema into other viewsets — every `POST`/`PUT`/`PATCH` endpoint
|
|
711
|
+
now validates against its own `response_model`.
|
|
712
|
+
- **PATCH is truly partial**: the PATCH body is validated against an
|
|
713
|
+
auto-generated all-optional variant of the response schema, and an
|
|
714
|
+
explicit JSON `null` clears a nullable column.
|
|
715
|
+
- **PUT no longer nulls columns** that are absent from the Pydantic
|
|
716
|
+
schema.
|
|
717
|
+
- **Integrity violations return `409 Conflict`** with a sanitized message
|
|
718
|
+
instead of `400` with raw SQL/driver internals.
|
|
719
|
+
- **Create never passes an explicit `NULL` primary key** — fixes Tortoise
|
|
720
|
+
`POST` and PostgreSQL inserts with `id: Optional[int] = None` schemas.
|
|
721
|
+
- **Pagination validated**: negative `limit`/`offset` rejected with `422`.
|
|
722
|
+
- **Filters advertised in OpenAPI**: whitelisted `ListConfig.filters`
|
|
723
|
+
fields and their `__op` variants show up in Swagger UI.
|
|
724
|
+
- **Leaner dependencies**: the base install no longer requires SQLAlchemy
|
|
725
|
+
or uvicorn; the `sqlalchemy` extra installs `SQLAlchemy[asyncio]`
|
|
726
|
+
(includes `greenlet`).
|
|
727
|
+
|
|
728
|
+
Earlier releases: v1.5.0 (server-side `search`, declarative ordering and
|
|
729
|
+
filters — [RELEASE_1.5.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.5.0.md)),
|
|
730
|
+
v1.4.0 ([RELEASE_1.4.0.md](https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.4.0.md)).
|
|
731
|
+
|
|
685
732
|
### v1.3.0
|
|
686
733
|
|
|
687
734
|
- **Declarative eager loading** via `RelatedConfig` inside Pydantic schemas.
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
| Python | >= 3.9 |
|
|
8
8
|
| FastAPI | >= 0.110 |
|
|
9
9
|
| Pydantic | >= 2.5, < 3 |
|
|
10
|
-
| SQLAlchemy | >= 2.0.0 |
|
|
11
10
|
| python-dotenv | >= 0.19 |
|
|
11
|
+
| SQLAlchemy | >= 2.0.0 (only for the `sqlalchemy` extra / default `ORM_TYPE`) |
|
|
12
12
|
|
|
13
13
|
## Install from PyPI
|
|
14
14
|
|
|
@@ -18,8 +18,9 @@ pip install fastapi-viewsets
|
|
|
18
18
|
|
|
19
19
|
## Optional extras
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
The base install is ORM-agnostic (FastAPI + Pydantic + python-dotenv only).
|
|
22
|
+
Add the extra for your ORM — and an ASGI server such as `uvicorn` to run
|
|
23
|
+
your app:
|
|
23
24
|
|
|
24
25
|
=== "SQLAlchemy"
|
|
25
26
|
|
|
@@ -10,8 +10,8 @@ GET /items?limit=10&offset=20
|
|
|
10
10
|
|
|
11
11
|
| Parameter | Type | Default | Description |
|
|
12
12
|
| --- | --- | --- | --- |
|
|
13
|
-
| `limit` | `
|
|
14
|
-
| `offset` | `
|
|
13
|
+
| `limit` | `int` | `10` | Maximum number of items to return (must be `0..10000`, negative values are rejected with `422`) |
|
|
14
|
+
| `offset` | `int` | `0` | Number of items to skip (negative values are rejected with `422`) |
|
|
15
15
|
|
|
16
16
|
No additional configuration needed — pagination is built into the default `list()` handler.
|
|
17
17
|
|
|
@@ -9,9 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
|
|
11
11
|
|
|
12
|
-
from fastapi import APIRouter, Body, Depends, Request
|
|
12
|
+
from fastapi import APIRouter, Body, Depends, Query, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
|
-
from sqlalchemy.orm import Session
|
|
15
14
|
|
|
16
15
|
from fastapi_viewsets._compat import model_to_dict
|
|
17
16
|
from fastapi_viewsets._register import _RegisterMixin
|
|
@@ -82,7 +81,7 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
82
81
|
self.endpoint: Optional[str] = endpoint
|
|
83
82
|
self.response_model: Optional[Type[ResponseModelType]] = response_model
|
|
84
83
|
self.model: Optional[Type[ModelType]] = model
|
|
85
|
-
self.db_session: Optional[Callable[[],
|
|
84
|
+
self.db_session: Optional[Callable[[], Any]] = db_session
|
|
86
85
|
self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter()
|
|
87
86
|
|
|
88
87
|
# ------------------------------------------------------------------
|
|
@@ -91,8 +90,8 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
91
90
|
|
|
92
91
|
def list(
|
|
93
92
|
self,
|
|
94
|
-
limit:
|
|
95
|
-
offset:
|
|
93
|
+
limit: int = Query(10, ge=0, le=10000),
|
|
94
|
+
offset: int = Query(0, ge=0),
|
|
96
95
|
search: Optional[str] = None,
|
|
97
96
|
ordering: Optional[str] = None,
|
|
98
97
|
request: Request = None,
|
|
@@ -117,6 +116,13 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
117
116
|
parse_ordering_param,
|
|
118
117
|
)
|
|
119
118
|
|
|
119
|
+
# FastAPI resolves ``Query`` defaults for HTTP calls; direct
|
|
120
|
+
# programmatic calls receive the unresolved ``Query`` object.
|
|
121
|
+
if not isinstance(limit, int):
|
|
122
|
+
limit = 10
|
|
123
|
+
if not isinstance(offset, int):
|
|
124
|
+
offset = 0
|
|
125
|
+
|
|
120
126
|
config = get_list_config(self.response_model)
|
|
121
127
|
return get_list_queryset(
|
|
122
128
|
self.model,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Shared route registration logic for sync and async viewsets.
|
|
2
|
+
|
|
3
|
+
Both ``BaseViewset`` and ``AsyncBaseViewset`` need to wire CRUD methods
|
|
4
|
+
into FastAPI ``APIRouter`` routes in exactly the same way. The logic is
|
|
5
|
+
collected here as a mixin to avoid two divergent copies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import types
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from typing import Any, Dict, List, Optional, Type
|
|
14
|
+
|
|
15
|
+
from fastapi import Depends
|
|
16
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
17
|
+
from pydantic import BaseModel, create_model
|
|
18
|
+
|
|
19
|
+
from fastapi_viewsets.constants import MAP_METHODS
|
|
20
|
+
|
|
21
|
+
# Routing order matters: routes without path parameters must be added
|
|
22
|
+
# before routes that use them, otherwise FastAPI may match LIST against
|
|
23
|
+
# the ``/{id}`` route.
|
|
24
|
+
_METHOD_ORDER = ("LIST", "POST", "GET", "PUT", "PATCH", "DELETE")
|
|
25
|
+
|
|
26
|
+
# Cache of generated partial (all-optional) PATCH body models, keyed by
|
|
27
|
+
# the source schema, so repeated ``register()`` calls reuse one model.
|
|
28
|
+
_PARTIAL_MODEL_CACHE: Dict[type, type] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_partial_model(model: Type[BaseModel]) -> Type[BaseModel]:
|
|
32
|
+
"""Build an all-optional variant of a Pydantic schema for PATCH bodies.
|
|
33
|
+
|
|
34
|
+
PATCH semantics require that clients may send any subset of fields,
|
|
35
|
+
so required fields in the response schema must become optional in
|
|
36
|
+
the request body. Unset fields are excluded later via
|
|
37
|
+
``model_dump(exclude_unset=True)``; explicitly sent ``null`` values
|
|
38
|
+
are preserved so nullable columns can be cleared.
|
|
39
|
+
"""
|
|
40
|
+
cached = _PARTIAL_MODEL_CACHE.get(model)
|
|
41
|
+
if cached is not None:
|
|
42
|
+
return cached
|
|
43
|
+
|
|
44
|
+
if not hasattr(model, "model_fields"): # pragma: no cover - pydantic v1 fallback
|
|
45
|
+
return model
|
|
46
|
+
|
|
47
|
+
fields: Dict[str, Any] = {}
|
|
48
|
+
for name, field_info in model.model_fields.items():
|
|
49
|
+
fields[name] = (Optional[field_info.annotation], None)
|
|
50
|
+
|
|
51
|
+
partial = create_model(f"{model.__name__}Patch", **fields)
|
|
52
|
+
_PARTIAL_MODEL_CACHE[model] = partial
|
|
53
|
+
return partial
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clone_bound_method(bound: Any, annotation_overrides: Dict[str, Any]) -> Any:
|
|
57
|
+
"""Return a per-instance copy of a bound method with patched annotations.
|
|
58
|
+
|
|
59
|
+
``register()`` is called once per viewset instance, but CRUD handlers
|
|
60
|
+
are functions defined once on the class. Mutating
|
|
61
|
+
``handler.__annotations__`` directly would leak one viewset's body
|
|
62
|
+
schema into every other viewset in the process, so the function is
|
|
63
|
+
cloned first and the clone's annotations are patched instead.
|
|
64
|
+
"""
|
|
65
|
+
func = getattr(bound, "__func__", None)
|
|
66
|
+
if func is None:
|
|
67
|
+
# Not a plain bound method (already a partial/closure): patch a
|
|
68
|
+
# fresh annotation dict on the object itself, best-effort.
|
|
69
|
+
try:
|
|
70
|
+
merged = {**getattr(bound, "__annotations__", {}), **annotation_overrides}
|
|
71
|
+
bound.__annotations__ = merged
|
|
72
|
+
except (AttributeError, TypeError):
|
|
73
|
+
pass
|
|
74
|
+
return bound
|
|
75
|
+
|
|
76
|
+
new_func = types.FunctionType(
|
|
77
|
+
func.__code__,
|
|
78
|
+
func.__globals__,
|
|
79
|
+
func.__name__,
|
|
80
|
+
func.__defaults__,
|
|
81
|
+
func.__closure__,
|
|
82
|
+
)
|
|
83
|
+
new_func.__dict__.update(func.__dict__)
|
|
84
|
+
new_func.__kwdefaults__ = func.__kwdefaults__
|
|
85
|
+
new_func.__annotations__ = {**func.__annotations__, **annotation_overrides}
|
|
86
|
+
new_func.__doc__ = func.__doc__
|
|
87
|
+
new_func.__module__ = func.__module__
|
|
88
|
+
new_func.__qualname__ = func.__qualname__
|
|
89
|
+
return types.MethodType(new_func, bound.__self__)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _RegisterMixin:
|
|
93
|
+
"""Mixin providing :meth:`register` for CRUD endpoint wiring.
|
|
94
|
+
|
|
95
|
+
Subclasses are expected to expose ``self.endpoint``, ``self.tags``,
|
|
96
|
+
``self.response_model`` and the CRUD handlers
|
|
97
|
+
(``list``, ``get_element``, ``create_element``, ``update_element``,
|
|
98
|
+
``delete_element``) used by :data:`MAP_METHODS`.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
ALLOWED_METHODS: List[str]
|
|
102
|
+
|
|
103
|
+
def register(
|
|
104
|
+
self,
|
|
105
|
+
methods: Optional[List[str]] = None,
|
|
106
|
+
oauth_protect: Optional[OAuth2PasswordBearer] = None,
|
|
107
|
+
protected_methods: Optional[List[str]] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Register CRUD endpoints on this router.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
methods: Logical methods to register. Defaults to all
|
|
113
|
+
allowed methods. Allowed values:
|
|
114
|
+
``LIST``, ``GET``, ``POST``, ``PUT``, ``PATCH``,
|
|
115
|
+
``DELETE``.
|
|
116
|
+
oauth_protect: ``OAuth2PasswordBearer`` instance used as a
|
|
117
|
+
FastAPI dependency on protected operations.
|
|
118
|
+
protected_methods: Subset of ``methods`` that should require
|
|
119
|
+
the bearer token. Ignored if ``oauth_protect`` is None.
|
|
120
|
+
"""
|
|
121
|
+
protected_methods = list(protected_methods or [])
|
|
122
|
+
|
|
123
|
+
if methods is None:
|
|
124
|
+
methods = list(self.ALLOWED_METHODS)
|
|
125
|
+
if not isinstance(methods, Iterable):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
'methods must be List of methods (e.g. ["GET"])'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Sort so LIST is registered before GET/{id}.
|
|
131
|
+
sorted_methods = sorted(
|
|
132
|
+
methods,
|
|
133
|
+
key=lambda m: _METHOD_ORDER.index(m) if m in _METHOD_ORDER else len(_METHOD_ORDER),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for method in sorted_methods:
|
|
137
|
+
spec = MAP_METHODS.get(method)
|
|
138
|
+
if not spec:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Unknown method: {method}. "
|
|
141
|
+
f"Allowed methods: {list(MAP_METHODS.keys())}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
handler = getattr(self, spec["method"])
|
|
145
|
+
|
|
146
|
+
# Hint FastAPI/Pydantic about the body schema if the handler
|
|
147
|
+
# carries an ``item`` parameter. The handler is cloned first
|
|
148
|
+
# so patching never mutates the shared class-level function
|
|
149
|
+
# (which would leak this viewset's schema into other
|
|
150
|
+
# viewsets). PATCH gets an all-optional partial model so
|
|
151
|
+
# clients may send any subset of fields.
|
|
152
|
+
if self.response_model is not None and "item" in getattr(
|
|
153
|
+
handler, "__annotations__", {}
|
|
154
|
+
):
|
|
155
|
+
body_model = self.response_model
|
|
156
|
+
if spec["http_method"] == "PATCH":
|
|
157
|
+
body_model = _build_partial_model(self.response_model)
|
|
158
|
+
handler = _clone_bound_method(handler, {"item": body_model})
|
|
159
|
+
|
|
160
|
+
original_doc = handler.__doc__
|
|
161
|
+
|
|
162
|
+
# PUT vs PATCH share an underlying handler but differ in the
|
|
163
|
+
# ``partial`` flag.
|
|
164
|
+
if spec["method"] == "update_element":
|
|
165
|
+
handler = functools.partial(
|
|
166
|
+
handler, partial=method == "PATCH"
|
|
167
|
+
)
|
|
168
|
+
handler.__doc__ = original_doc
|
|
169
|
+
|
|
170
|
+
if method in protected_methods and oauth_protect is not None:
|
|
171
|
+
handler = functools.partial(handler, token=Depends(oauth_protect))
|
|
172
|
+
handler.__doc__ = original_doc
|
|
173
|
+
|
|
174
|
+
endpoint = (self.endpoint or "") + spec.get("path", "")
|
|
175
|
+
response_model = self._build_response_model(spec)
|
|
176
|
+
route_name = self._build_route_name(method)
|
|
177
|
+
openapi_extra = self._build_openapi_extra(spec)
|
|
178
|
+
|
|
179
|
+
self.add_api_route(
|
|
180
|
+
endpoint,
|
|
181
|
+
handler,
|
|
182
|
+
response_model=response_model,
|
|
183
|
+
tags=self.tags,
|
|
184
|
+
methods=[spec["http_method"]],
|
|
185
|
+
name=route_name,
|
|
186
|
+
openapi_extra=openapi_extra,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# --- helpers ---------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _build_openapi_extra(self, spec) -> Optional[Dict[str, Any]]:
|
|
192
|
+
"""Document dynamic LIST filter parameters in OpenAPI.
|
|
193
|
+
|
|
194
|
+
Filters are parsed from ``request.query_params`` at runtime, so
|
|
195
|
+
FastAPI cannot see them in the handler signature. Advertise the
|
|
196
|
+
whitelisted ``ListConfig.filters`` fields (and their ``__op``
|
|
197
|
+
variants) explicitly via ``openapi_extra``.
|
|
198
|
+
"""
|
|
199
|
+
if not spec.get("is_list") or self.response_model is None:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
from fastapi_viewsets.filtering import FILTER_OPS, get_list_config
|
|
203
|
+
|
|
204
|
+
config = get_list_config(self.response_model)
|
|
205
|
+
if not config.filters:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
parameters: List[Dict[str, Any]] = []
|
|
209
|
+
for field_name in config.filters:
|
|
210
|
+
parameters.append(
|
|
211
|
+
{
|
|
212
|
+
"name": field_name,
|
|
213
|
+
"in": "query",
|
|
214
|
+
"required": False,
|
|
215
|
+
"schema": {"type": "string"},
|
|
216
|
+
"description": f"Exact-match filter on `{field_name}`.",
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
for op in FILTER_OPS:
|
|
220
|
+
parameters.append(
|
|
221
|
+
{
|
|
222
|
+
"name": f"{field_name}__{op}",
|
|
223
|
+
"in": "query",
|
|
224
|
+
"required": False,
|
|
225
|
+
"schema": {"type": "string"},
|
|
226
|
+
"description": f"Comparison filter `{op}` on `{field_name}`.",
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
return {"parameters": parameters}
|
|
230
|
+
|
|
231
|
+
def _build_response_model(self, spec):
|
|
232
|
+
"""Compute the FastAPI ``response_model`` for a given method spec."""
|
|
233
|
+
if spec.get("http_method") == "DELETE":
|
|
234
|
+
return None
|
|
235
|
+
if self.response_model is None:
|
|
236
|
+
return None
|
|
237
|
+
if spec.get("is_list"):
|
|
238
|
+
return List[self.response_model]
|
|
239
|
+
return self.response_model
|
|
240
|
+
|
|
241
|
+
def _build_route_name(self, method: str) -> str:
|
|
242
|
+
"""Build a stable route name for OpenAPI/url_path_for."""
|
|
243
|
+
endpoint = self.endpoint or ""
|
|
244
|
+
slug = endpoint.replace("/", "_").strip("_") or "root"
|
|
245
|
+
return f"{method.lower()}_{slug}"
|
|
@@ -4,9 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
|
|
6
6
|
|
|
7
|
-
from fastapi import APIRouter, Body, Depends, Request
|
|
7
|
+
from fastapi import APIRouter, Body, Depends, Query, Request
|
|
8
8
|
from pydantic import BaseModel
|
|
9
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
9
|
|
|
11
10
|
from fastapi_viewsets._compat import model_to_dict
|
|
12
11
|
from fastapi_viewsets._register import _RegisterMixin
|
|
@@ -51,7 +50,7 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
51
50
|
allowed_methods: Optional[List[str]] = None,
|
|
52
51
|
endpoint: Optional[str] = None,
|
|
53
52
|
model: Optional[Type[ModelType]] = None,
|
|
54
|
-
db_session: Optional[Callable[[],
|
|
53
|
+
db_session: Optional[Callable[[], Any]] = None,
|
|
55
54
|
response_model: Optional[Type[ResponseModelType]] = None,
|
|
56
55
|
orm_adapter: Optional[BaseORMAdapter] = None,
|
|
57
56
|
**kwargs,
|
|
@@ -73,7 +72,7 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
73
72
|
self.endpoint: Optional[str] = endpoint
|
|
74
73
|
self.response_model: Optional[Type[ResponseModelType]] = response_model
|
|
75
74
|
self.model: Optional[Type[ModelType]] = model
|
|
76
|
-
self.db_session: Optional[Callable[[],
|
|
75
|
+
self.db_session: Optional[Callable[[], Any]] = db_session
|
|
77
76
|
self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter()
|
|
78
77
|
|
|
79
78
|
# ------------------------------------------------------------------
|
|
@@ -82,8 +81,8 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
82
81
|
|
|
83
82
|
async def list(
|
|
84
83
|
self,
|
|
85
|
-
limit:
|
|
86
|
-
offset:
|
|
84
|
+
limit: int = Query(10, ge=0, le=10000),
|
|
85
|
+
offset: int = Query(0, ge=0),
|
|
87
86
|
search: Optional[str] = None,
|
|
88
87
|
ordering: Optional[str] = None,
|
|
89
88
|
request: Request = None,
|
|
@@ -108,6 +107,13 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
108
107
|
parse_ordering_param,
|
|
109
108
|
)
|
|
110
109
|
|
|
110
|
+
# FastAPI resolves ``Query`` defaults for HTTP calls; direct
|
|
111
|
+
# programmatic calls receive the unresolved ``Query`` object.
|
|
112
|
+
if not isinstance(limit, int):
|
|
113
|
+
limit = 10
|
|
114
|
+
if not isinstance(offset, int):
|
|
115
|
+
offset = 0
|
|
116
|
+
|
|
111
117
|
config = get_list_config(self.response_model)
|
|
112
118
|
return await get_list_queryset(
|
|
113
119
|
self.model,
|