fastapi-viewsets 1.3.0__tar.gz → 1.5.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.
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/PKG-INFO +72 -57
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/README.md +62 -53
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/__init__.py +30 -2
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_base.py +30 -2
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/async_utils.py +54 -0
- fastapi_viewsets-1.5.0/fastapi_viewsets/filtering.py +197 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/base.py +21 -1
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/peewee_adapter.py +47 -1
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/sqlalchemy_adapter.py +71 -2
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/tortoise_adapter.py +56 -1
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/utils.py +59 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/PKG-INFO +72 -57
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/SOURCES.txt +3 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/requires.txt +7 -2
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/pyproject.toml +8 -5
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_adapter_methods_coverage.py +20 -33
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_exception_handling.py +13 -25
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_missing_coverage.py +2 -1
- fastapi_viewsets-1.5.0/tests/test_search_ordering.py +441 -0
- fastapi_viewsets-1.5.0/tests/test_tortoise_lifecycle.py +100 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/LICENSE +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_compat.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/_register.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/constants.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/db_conf.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/__init__.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/orm/factory.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets/serializer_utils.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/dependency_links.txt +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/fastapi_viewsets.egg-info/top_level.txt +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/setup.cfg +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/setup.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_async_base_viewset.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_async_driver_missing.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_async_utils.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_backward_compatibility.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_base_viewset.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_coverage_gaps.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_db_conf_extended.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_edge_cases.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_error_handling.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_integration.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_orm_adapters_extended.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_select_prefetch_related.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_utils.py +0 -0
- {fastapi_viewsets-1.3.0 → fastapi_viewsets-1.5.0}/tests/test_viewsets_with_adapters.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi_viewsets
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
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
|
|
7
7
|
Project-URL: Homepage, https://github.com/svalench/fastapi_viewsets
|
|
8
|
+
Project-URL: Documentation, https://svalench.github.io/fastapi_viewsets/
|
|
8
9
|
Project-URL: Issues, https://github.com/svalench/fastapi_viewsets/issues
|
|
9
|
-
Project-URL: Changelog, https://github.com/svalench/fastapi_viewsets/blob/
|
|
10
|
+
Project-URL: Changelog, https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_NOTES.md
|
|
10
11
|
Keywords: fastapi,viewsets,crud,sqlalchemy,tortoise,peewee,pydantic
|
|
11
12
|
Classifier: Programming Language :: Python :: 3
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.9
|
|
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
19
|
Classifier: License :: OSI Approved :: MIT License
|
|
18
20
|
Classifier: Operating System :: OS Independent
|
|
19
21
|
Classifier: Framework :: FastAPI
|
|
@@ -30,10 +32,10 @@ Requires-Dist: python-dotenv>=0.19.0
|
|
|
30
32
|
Provides-Extra: sqlalchemy
|
|
31
33
|
Requires-Dist: SQLAlchemy>=1.4.36; extra == "sqlalchemy"
|
|
32
34
|
Provides-Extra: tortoise
|
|
33
|
-
Requires-Dist: tortoise-orm
|
|
35
|
+
Requires-Dist: tortoise-orm<1.0,>=0.20.0; extra == "tortoise"
|
|
34
36
|
Requires-Dist: asyncpg>=0.28.0; extra == "tortoise"
|
|
35
37
|
Provides-Extra: peewee
|
|
36
|
-
Requires-Dist: peewee
|
|
38
|
+
Requires-Dist: peewee<4,>=3.17.0; extra == "peewee"
|
|
37
39
|
Provides-Extra: test
|
|
38
40
|
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
39
41
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
|
|
@@ -45,6 +47,10 @@ Provides-Extra: lint
|
|
|
45
47
|
Requires-Dist: ruff>=0.5; extra == "lint"
|
|
46
48
|
Requires-Dist: black>=24; extra == "lint"
|
|
47
49
|
Requires-Dist: mypy>=1.8; extra == "lint"
|
|
50
|
+
Provides-Extra: docs
|
|
51
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
52
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
53
|
+
Requires-Dist: pymdown-extensions>=10.7; extra == "docs"
|
|
48
54
|
Dynamic: license-file
|
|
49
55
|
|
|
50
56
|
# fastapi-viewsets
|
|
@@ -53,12 +59,15 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
53
59
|
|
|
54
60
|
[](https://pypi.org/project/fastapi-viewsets/)
|
|
55
61
|
[](https://pypi.org/project/fastapi-viewsets/)
|
|
56
|
-
[](https://github.com/svalench/fastapi_viewsets/blob/
|
|
57
|
-
[](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE)
|
|
63
|
+
[](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
|
|
58
64
|
[](https://codecov.io/gh/svalench/fastapi_viewsets)
|
|
59
65
|
[](https://pepy.tech/project/fastapi-viewsets)
|
|
60
66
|
[](https://github.com/psf/black)
|
|
61
67
|
[](https://github.com/svalench/fastapi_viewsets/pulls)
|
|
68
|
+
[](https://svalench.github.io/fastapi_viewsets/)
|
|
69
|
+
|
|
70
|
+
**Documentation:** [https://svalench.github.io/fastapi_viewsets/](https://svalench.github.io/fastapi_viewsets/)
|
|
62
71
|
|
|
63
72
|
## Why fastapi-viewsets
|
|
64
73
|
|
|
@@ -67,7 +76,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
67
76
|
- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
|
|
68
77
|
- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
|
|
69
78
|
- **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
|
|
70
|
-
- **Built-in list pagination** (`limit` / `offset`),
|
|
79
|
+
- **Built-in list pagination** (`limit` / `offset`), **server-side search**, **declarative ordering** and **advanced filters** (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`) driven by an inner `ListConfig` class on Pydantic schemas, plus optional OAuth2 on selected operations.
|
|
71
80
|
|
|
72
81
|
## Feature matrix
|
|
73
82
|
|
|
@@ -77,8 +86,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
77
86
|
| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
|
|
78
87
|
| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
|
|
79
88
|
| Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
|
|
80
|
-
| `search` query on LIST (server-side) |
|
|
81
|
-
| Declarative ordering / advanced filters |
|
|
89
|
+
| `search` query on LIST (server-side) | Supported | Supported | Supported | Supported |
|
|
90
|
+
| Declarative ordering / advanced filters | Supported | Supported | Supported | Supported |
|
|
82
91
|
|
|
83
92
|
## Installation
|
|
84
93
|
|
|
@@ -108,7 +117,7 @@ you need, and the URL shape for **PostgreSQL**, **MySQL**, and
|
|
|
108
117
|
| --- | --- | --- | --- |
|
|
109
118
|
| **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 |
|
|
110
119
|
| **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 |
|
|
111
|
-
| **Tortoise ORM** | `asyncpg` (
|
|
120
|
+
| **Tortoise ORM** | `asyncpg` (included in `[tortoise]` extra) | `aiomysql` (install separately) | Not supported by Tortoise |
|
|
112
121
|
| **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter |
|
|
113
122
|
|
|
114
123
|
> The `SQLAlchemyAdapter` auto-converts a sync URL to its async
|
|
@@ -191,8 +200,9 @@ TORTOISE_APP_LABEL=models
|
|
|
191
200
|
```
|
|
192
201
|
|
|
193
202
|
```python
|
|
203
|
+
from contextlib import asynccontextmanager
|
|
204
|
+
|
|
194
205
|
from fastapi import FastAPI
|
|
195
|
-
from tortoise import Tortoise
|
|
196
206
|
|
|
197
207
|
from fastapi_viewsets import AsyncBaseViewset
|
|
198
208
|
from fastapi_viewsets.orm.factory import ORMFactory
|
|
@@ -201,24 +211,19 @@ app = FastAPI()
|
|
|
201
211
|
adapter = ORMFactory.get_default_adapter() # built from the env vars above
|
|
202
212
|
|
|
203
213
|
|
|
204
|
-
@
|
|
205
|
-
async def
|
|
206
|
-
"""Open the Tortoise connection pool
|
|
214
|
+
@asynccontextmanager
|
|
215
|
+
async def lifespan(app: FastAPI):
|
|
216
|
+
"""Open the Tortoise connection pool at startup.
|
|
207
217
|
|
|
208
|
-
The adapter also
|
|
209
|
-
|
|
218
|
+
The adapter also initialises Tortoise lazily on the first DB call;
|
|
219
|
+
calling ``initialize()`` here gives you control over schema creation
|
|
220
|
+
and avoids a cold-start penalty on the first request.
|
|
210
221
|
"""
|
|
211
|
-
await
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
await Tortoise.generate_schemas(safe=True)
|
|
222
|
+
await adapter.initialize(generate_schemas=True)
|
|
223
|
+
yield
|
|
224
|
+
await adapter.close()
|
|
216
225
|
|
|
217
|
-
|
|
218
|
-
@app.on_event("shutdown")
|
|
219
|
-
async def _close_tortoise() -> None:
|
|
220
|
-
"""Close the Tortoise connection pool."""
|
|
221
|
-
await Tortoise.close_connections()
|
|
226
|
+
app = FastAPI(lifespan=lifespan)
|
|
222
227
|
|
|
223
228
|
|
|
224
229
|
# Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset.
|
|
@@ -236,6 +241,10 @@ async def _close_tortoise() -> None:
|
|
|
236
241
|
# app.include_router(items)
|
|
237
242
|
```
|
|
238
243
|
|
|
244
|
+
> **Note:** `generate_schemas=True` is convenient for development. In
|
|
245
|
+
> production, use [Aerich](https://github.com/tortoise/aerich) or another
|
|
246
|
+
> migration tool instead of auto-generating schemas at startup.
|
|
247
|
+
>
|
|
239
248
|
> **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with
|
|
240
249
|
> `aioodbc` for SQL Server.
|
|
241
250
|
|
|
@@ -314,6 +323,7 @@ Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:a
|
|
|
314
323
|
from fastapi import FastAPI
|
|
315
324
|
from pydantic import BaseModel, ConfigDict
|
|
316
325
|
from sqlalchemy import Column, Integer, String
|
|
326
|
+
from typing import Optional
|
|
317
327
|
|
|
318
328
|
from fastapi_viewsets import BaseViewset
|
|
319
329
|
from fastapi_viewsets.db_conf import Base, engine, get_session
|
|
@@ -333,7 +343,7 @@ class ItemSchema(BaseModel):
|
|
|
333
343
|
"""Pydantic model for request and response bodies."""
|
|
334
344
|
|
|
335
345
|
model_config = ConfigDict(from_attributes=True)
|
|
336
|
-
id: int
|
|
346
|
+
id: Optional[int] = None
|
|
337
347
|
name: str
|
|
338
348
|
|
|
339
349
|
|
|
@@ -423,9 +433,12 @@ package auto-converts `sqlite://` to `sqlite+aiosqlite://`,
|
|
|
423
433
|
`postgresql://` to `postgresql+asyncpg://`, etc.
|
|
424
434
|
|
|
425
435
|
```python
|
|
436
|
+
from contextlib import asynccontextmanager
|
|
437
|
+
|
|
426
438
|
from fastapi import FastAPI
|
|
427
439
|
from pydantic import BaseModel, ConfigDict
|
|
428
440
|
from sqlalchemy import Column, Integer, String
|
|
441
|
+
from typing import Optional
|
|
429
442
|
|
|
430
443
|
from fastapi_viewsets import AsyncBaseViewset
|
|
431
444
|
from fastapi_viewsets.db_conf import (
|
|
@@ -434,7 +447,16 @@ from fastapi_viewsets.db_conf import (
|
|
|
434
447
|
get_async_session,
|
|
435
448
|
)
|
|
436
449
|
|
|
437
|
-
|
|
450
|
+
|
|
451
|
+
@asynccontextmanager
|
|
452
|
+
async def lifespan(app: FastAPI):
|
|
453
|
+
"""Create tables once on startup using the async engine."""
|
|
454
|
+
async with async_engine.begin() as conn:
|
|
455
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
456
|
+
yield
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
app = FastAPI(lifespan=lifespan)
|
|
438
460
|
|
|
439
461
|
|
|
440
462
|
class Item(Base):
|
|
@@ -449,17 +471,10 @@ class ItemSchema(BaseModel):
|
|
|
449
471
|
"""Pydantic v2 schema reused as request and response model."""
|
|
450
472
|
|
|
451
473
|
model_config = ConfigDict(from_attributes=True)
|
|
452
|
-
id: int
|
|
474
|
+
id: Optional[int] = None
|
|
453
475
|
name: str
|
|
454
476
|
|
|
455
477
|
|
|
456
|
-
@app.on_event("startup")
|
|
457
|
-
async def _create_tables() -> None:
|
|
458
|
-
"""Create tables once on startup using the async engine."""
|
|
459
|
-
async with async_engine.begin() as conn:
|
|
460
|
-
await conn.run_sync(Base.metadata.create_all)
|
|
461
|
-
|
|
462
|
-
|
|
463
478
|
items = AsyncBaseViewset(
|
|
464
479
|
endpoint="/items",
|
|
465
480
|
model=Item,
|
|
@@ -624,6 +639,7 @@ from fastapi import FastAPI
|
|
|
624
639
|
from fastapi.security import OAuth2PasswordBearer
|
|
625
640
|
from pydantic import BaseModel, ConfigDict
|
|
626
641
|
from sqlalchemy import Column, Integer, String
|
|
642
|
+
from typing import Optional
|
|
627
643
|
from fastapi_viewsets import BaseViewset
|
|
628
644
|
from fastapi_viewsets.db_conf import Base, engine, get_session
|
|
629
645
|
|
|
@@ -642,7 +658,7 @@ class ItemSchema(BaseModel):
|
|
|
642
658
|
"""Pydantic schema for Item payloads and responses."""
|
|
643
659
|
|
|
644
660
|
model_config = ConfigDict(from_attributes=True)
|
|
645
|
-
id: int
|
|
661
|
+
id: Optional[int] = None
|
|
646
662
|
name: str
|
|
647
663
|
|
|
648
664
|
|
|
@@ -664,26 +680,25 @@ def pagination_hint() -> str:
|
|
|
664
680
|
return "limit and offset are parsed by `BaseViewset.list`"
|
|
665
681
|
```
|
|
666
682
|
|
|
667
|
-
**
|
|
683
|
+
**Search, ordering and filters** — declare a `ListConfig` inner class on the response schema and the LIST endpoint gains `?search=`, `?ordering=` and whitelisted `?<field>` / `?<field>__<op>` query parameters, applied server-side by every ORM adapter:
|
|
668
684
|
|
|
669
685
|
```python
|
|
670
|
-
from
|
|
671
|
-
|
|
672
|
-
def filtering_hint() -> str:
|
|
673
|
-
"""Explain that `search` is reserved; override `list` for real filters today."""
|
|
674
|
-
return "search parameter is not yet applied in adapters"
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
**Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.
|
|
686
|
+
from pydantic import BaseModel
|
|
678
687
|
|
|
679
|
-
|
|
680
|
-
|
|
688
|
+
class ItemSchema(BaseModel):
|
|
689
|
+
id: int
|
|
690
|
+
name: str
|
|
691
|
+
status: str
|
|
681
692
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
693
|
+
class ListConfig:
|
|
694
|
+
search_fields = ["name"]
|
|
695
|
+
ordering_fields = ["name", "id"]
|
|
696
|
+
ordering = ["-id"]
|
|
697
|
+
filters = ["status"]
|
|
685
698
|
```
|
|
686
699
|
|
|
700
|
+
`GET /items?search=foo&ordering=name&status=active` then works out of the box. Operators: `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in` (e.g. `?status__in=active,pending`). Fields outside the whitelist are ignored; ordering by an unknown field returns `400`. Without a `ListConfig`, LIST behaves exactly as before.
|
|
701
|
+
|
|
687
702
|
## Permissions and custom routes
|
|
688
703
|
|
|
689
704
|
There is no `get_queryset` hook; scope queries by subclassing `BaseViewset` and overriding `list()`, `get_element()`, or related handlers. The class subclasses `APIRouter`, so attach extra endpoints with `add_api_route` **before** `register()` if paths must win over `/{id}`:
|
|
@@ -739,17 +754,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.
|
|
|
739
754
|
|
|
740
755
|
| Item | Target | Status |
|
|
741
756
|
| --- | --- | --- |
|
|
742
|
-
|
|
|
743
|
-
|
|
|
744
|
-
|
|
|
745
|
-
|
|
746
|
-
|
|
757
|
+
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |
|
|
758
|
+
| Range filters on dates (`date__range`) and null checks (`field__isnull`) | future | Planned |
|
|
759
|
+
| Cross-relation search (searching through `select_related` fields) | future | Planned |
|
|
760
|
+
|
|
761
|
+
Released: server-side `search` (v1.5.0), declarative ordering and advanced filters (v1.5.0).
|
|
747
762
|
|
|
748
763
|
## Comparison with alternatives
|
|
749
764
|
|
|
750
765
|
| Approach | Developer experience | ORM support | Permissions | Filtering |
|
|
751
766
|
| --- | --- | --- | --- | --- |
|
|
752
|
-
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset
|
|
767
|
+
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset`, `search`, `ordering` and operator filters via `ListConfig` |
|
|
753
768
|
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
|
|
754
769
|
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
|
|
755
770
|
|
|
@@ -4,12 +4,15 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
4
4
|
|
|
5
5
|
[](https://pypi.org/project/fastapi-viewsets/)
|
|
6
6
|
[](https://pypi.org/project/fastapi-viewsets/)
|
|
7
|
-
[](https://github.com/svalench/fastapi_viewsets/blob/
|
|
8
|
-
[](https://github.com/svalench/fastapi_viewsets/blob/master/LICENSE)
|
|
8
|
+
[](https://github.com/svalench/fastapi_viewsets/actions/workflows/test.yml)
|
|
9
9
|
[](https://codecov.io/gh/svalench/fastapi_viewsets)
|
|
10
10
|
[](https://pepy.tech/project/fastapi-viewsets)
|
|
11
11
|
[](https://github.com/psf/black)
|
|
12
12
|
[](https://github.com/svalench/fastapi_viewsets/pulls)
|
|
13
|
+
[](https://svalench.github.io/fastapi_viewsets/)
|
|
14
|
+
|
|
15
|
+
**Documentation:** [https://svalench.github.io/fastapi_viewsets/](https://svalench.github.io/fastapi_viewsets/)
|
|
13
16
|
|
|
14
17
|
## Why fastapi-viewsets
|
|
15
18
|
|
|
@@ -18,7 +21,7 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
18
21
|
- **ORM-agnostic core** — pluggable adapters for SQLAlchemy (sync/async), Tortoise ORM, and Peewee (`ORM_TYPE` / optional extras).
|
|
19
22
|
- **Typed, Pydantic-first responses** with OpenAPI tags and schemas generated from your `response_model`.
|
|
20
23
|
- **Declarative eager loading** (`select_related` / `prefetch_related`) via an inner `RelatedConfig` class on Pydantic schemas — eliminates N+1 without touching the viewset.
|
|
21
|
-
- **Built-in list pagination** (`limit` / `offset`),
|
|
24
|
+
- **Built-in list pagination** (`limit` / `offset`), **server-side search**, **declarative ordering** and **advanced filters** (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`) driven by an inner `ListConfig` class on Pydantic schemas, plus optional OAuth2 on selected operations.
|
|
22
25
|
|
|
23
26
|
## Feature matrix
|
|
24
27
|
|
|
@@ -28,8 +31,8 @@ Django REST Framework-style ViewSets for FastAPI — auto-generate CRUD endpoint
|
|
|
28
31
|
| `limit` / `offset` on LIST | Supported | Supported | Supported | Supported |
|
|
29
32
|
| OAuth2 on selected methods (`register`) | Supported | Supported | Supported | Supported |
|
|
30
33
|
| Declarative eager loading (`select_related` / `prefetch_related`) | Supported | Supported | Supported (`prefetch_related`) | Supported (`select_related`) |
|
|
31
|
-
| `search` query on LIST (server-side) |
|
|
32
|
-
| Declarative ordering / advanced filters |
|
|
34
|
+
| `search` query on LIST (server-side) | Supported | Supported | Supported | Supported |
|
|
35
|
+
| Declarative ordering / advanced filters | Supported | Supported | Supported | Supported |
|
|
33
36
|
|
|
34
37
|
## Installation
|
|
35
38
|
|
|
@@ -59,7 +62,7 @@ you need, and the URL shape for **PostgreSQL**, **MySQL**, and
|
|
|
59
62
|
| --- | --- | --- | --- |
|
|
60
63
|
| **SQLAlchemy (sync)** | `psycopg[binary]` or `psycopg2-binary` | `pymysql` or `mysqlclient` | `pyodbc` + ODBC Driver 17/18 |
|
|
61
64
|
| **SQLAlchemy (async)** | `asyncpg` | `aiomysql` or `asyncmy` | `aioodbc` + ODBC Driver 17/18 |
|
|
62
|
-
| **Tortoise ORM** | `asyncpg` (
|
|
65
|
+
| **Tortoise ORM** | `asyncpg` (included in `[tortoise]` extra) | `aiomysql` (install separately) | Not supported by Tortoise |
|
|
63
66
|
| **Peewee** | `psycopg2-binary` | `pymysql` or `mysqlclient` | Not supported by this adapter |
|
|
64
67
|
|
|
65
68
|
> The `SQLAlchemyAdapter` auto-converts a sync URL to its async
|
|
@@ -142,8 +145,9 @@ TORTOISE_APP_LABEL=models
|
|
|
142
145
|
```
|
|
143
146
|
|
|
144
147
|
```python
|
|
148
|
+
from contextlib import asynccontextmanager
|
|
149
|
+
|
|
145
150
|
from fastapi import FastAPI
|
|
146
|
-
from tortoise import Tortoise
|
|
147
151
|
|
|
148
152
|
from fastapi_viewsets import AsyncBaseViewset
|
|
149
153
|
from fastapi_viewsets.orm.factory import ORMFactory
|
|
@@ -152,24 +156,19 @@ app = FastAPI()
|
|
|
152
156
|
adapter = ORMFactory.get_default_adapter() # built from the env vars above
|
|
153
157
|
|
|
154
158
|
|
|
155
|
-
@
|
|
156
|
-
async def
|
|
157
|
-
"""Open the Tortoise connection pool
|
|
159
|
+
@asynccontextmanager
|
|
160
|
+
async def lifespan(app: FastAPI):
|
|
161
|
+
"""Open the Tortoise connection pool at startup.
|
|
158
162
|
|
|
159
|
-
The adapter also
|
|
160
|
-
|
|
163
|
+
The adapter also initialises Tortoise lazily on the first DB call;
|
|
164
|
+
calling ``initialize()`` here gives you control over schema creation
|
|
165
|
+
and avoids a cold-start penalty on the first request.
|
|
161
166
|
"""
|
|
162
|
-
await
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
await Tortoise.generate_schemas(safe=True)
|
|
167
|
+
await adapter.initialize(generate_schemas=True)
|
|
168
|
+
yield
|
|
169
|
+
await adapter.close()
|
|
167
170
|
|
|
168
|
-
|
|
169
|
-
@app.on_event("shutdown")
|
|
170
|
-
async def _close_tortoise() -> None:
|
|
171
|
-
"""Close the Tortoise connection pool."""
|
|
172
|
-
await Tortoise.close_connections()
|
|
171
|
+
app = FastAPI(lifespan=lifespan)
|
|
173
172
|
|
|
174
173
|
|
|
175
174
|
# Define your Tortoise models in app/models.py and pass them to AsyncBaseViewset.
|
|
@@ -187,6 +186,10 @@ async def _close_tortoise() -> None:
|
|
|
187
186
|
# app.include_router(items)
|
|
188
187
|
```
|
|
189
188
|
|
|
189
|
+
> **Note:** `generate_schemas=True` is convenient for development. In
|
|
190
|
+
> production, use [Aerich](https://github.com/tortoise/aerich) or another
|
|
191
|
+
> migration tool instead of auto-generating schemas at startup.
|
|
192
|
+
>
|
|
190
193
|
> **MSSQL is not supported by Tortoise ORM.** Use SQLAlchemy with
|
|
191
194
|
> `aioodbc` for SQL Server.
|
|
192
195
|
|
|
@@ -265,6 +268,7 @@ Save as `main.py` in an empty folder and run `python main.py` or `uvicorn main:a
|
|
|
265
268
|
from fastapi import FastAPI
|
|
266
269
|
from pydantic import BaseModel, ConfigDict
|
|
267
270
|
from sqlalchemy import Column, Integer, String
|
|
271
|
+
from typing import Optional
|
|
268
272
|
|
|
269
273
|
from fastapi_viewsets import BaseViewset
|
|
270
274
|
from fastapi_viewsets.db_conf import Base, engine, get_session
|
|
@@ -284,7 +288,7 @@ class ItemSchema(BaseModel):
|
|
|
284
288
|
"""Pydantic model for request and response bodies."""
|
|
285
289
|
|
|
286
290
|
model_config = ConfigDict(from_attributes=True)
|
|
287
|
-
id: int
|
|
291
|
+
id: Optional[int] = None
|
|
288
292
|
name: str
|
|
289
293
|
|
|
290
294
|
|
|
@@ -374,9 +378,12 @@ package auto-converts `sqlite://` to `sqlite+aiosqlite://`,
|
|
|
374
378
|
`postgresql://` to `postgresql+asyncpg://`, etc.
|
|
375
379
|
|
|
376
380
|
```python
|
|
381
|
+
from contextlib import asynccontextmanager
|
|
382
|
+
|
|
377
383
|
from fastapi import FastAPI
|
|
378
384
|
from pydantic import BaseModel, ConfigDict
|
|
379
385
|
from sqlalchemy import Column, Integer, String
|
|
386
|
+
from typing import Optional
|
|
380
387
|
|
|
381
388
|
from fastapi_viewsets import AsyncBaseViewset
|
|
382
389
|
from fastapi_viewsets.db_conf import (
|
|
@@ -385,7 +392,16 @@ from fastapi_viewsets.db_conf import (
|
|
|
385
392
|
get_async_session,
|
|
386
393
|
)
|
|
387
394
|
|
|
388
|
-
|
|
395
|
+
|
|
396
|
+
@asynccontextmanager
|
|
397
|
+
async def lifespan(app: FastAPI):
|
|
398
|
+
"""Create tables once on startup using the async engine."""
|
|
399
|
+
async with async_engine.begin() as conn:
|
|
400
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
401
|
+
yield
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
app = FastAPI(lifespan=lifespan)
|
|
389
405
|
|
|
390
406
|
|
|
391
407
|
class Item(Base):
|
|
@@ -400,17 +416,10 @@ class ItemSchema(BaseModel):
|
|
|
400
416
|
"""Pydantic v2 schema reused as request and response model."""
|
|
401
417
|
|
|
402
418
|
model_config = ConfigDict(from_attributes=True)
|
|
403
|
-
id: int
|
|
419
|
+
id: Optional[int] = None
|
|
404
420
|
name: str
|
|
405
421
|
|
|
406
422
|
|
|
407
|
-
@app.on_event("startup")
|
|
408
|
-
async def _create_tables() -> None:
|
|
409
|
-
"""Create tables once on startup using the async engine."""
|
|
410
|
-
async with async_engine.begin() as conn:
|
|
411
|
-
await conn.run_sync(Base.metadata.create_all)
|
|
412
|
-
|
|
413
|
-
|
|
414
423
|
items = AsyncBaseViewset(
|
|
415
424
|
endpoint="/items",
|
|
416
425
|
model=Item,
|
|
@@ -575,6 +584,7 @@ from fastapi import FastAPI
|
|
|
575
584
|
from fastapi.security import OAuth2PasswordBearer
|
|
576
585
|
from pydantic import BaseModel, ConfigDict
|
|
577
586
|
from sqlalchemy import Column, Integer, String
|
|
587
|
+
from typing import Optional
|
|
578
588
|
from fastapi_viewsets import BaseViewset
|
|
579
589
|
from fastapi_viewsets.db_conf import Base, engine, get_session
|
|
580
590
|
|
|
@@ -593,7 +603,7 @@ class ItemSchema(BaseModel):
|
|
|
593
603
|
"""Pydantic schema for Item payloads and responses."""
|
|
594
604
|
|
|
595
605
|
model_config = ConfigDict(from_attributes=True)
|
|
596
|
-
id: int
|
|
606
|
+
id: Optional[int] = None
|
|
597
607
|
name: str
|
|
598
608
|
|
|
599
609
|
|
|
@@ -615,26 +625,25 @@ def pagination_hint() -> str:
|
|
|
615
625
|
return "limit and offset are parsed by `BaseViewset.list`"
|
|
616
626
|
```
|
|
617
627
|
|
|
618
|
-
**
|
|
628
|
+
**Search, ordering and filters** — declare a `ListConfig` inner class on the response schema and the LIST endpoint gains `?search=`, `?ordering=` and whitelisted `?<field>` / `?<field>__<op>` query parameters, applied server-side by every ORM adapter:
|
|
619
629
|
|
|
620
630
|
```python
|
|
621
|
-
from
|
|
622
|
-
|
|
623
|
-
def filtering_hint() -> str:
|
|
624
|
-
"""Explain that `search` is reserved; override `list` for real filters today."""
|
|
625
|
-
return "search parameter is not yet applied in adapters"
|
|
626
|
-
```
|
|
627
|
-
|
|
628
|
-
**Ordering** — there is no shared `order_by` helper yet; override `list()` with an ordered query or wait for the Roadmap.
|
|
631
|
+
from pydantic import BaseModel
|
|
629
632
|
|
|
630
|
-
|
|
631
|
-
|
|
633
|
+
class ItemSchema(BaseModel):
|
|
634
|
+
id: int
|
|
635
|
+
name: str
|
|
636
|
+
status: str
|
|
632
637
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
638
|
+
class ListConfig:
|
|
639
|
+
search_fields = ["name"]
|
|
640
|
+
ordering_fields = ["name", "id"]
|
|
641
|
+
ordering = ["-id"]
|
|
642
|
+
filters = ["status"]
|
|
636
643
|
```
|
|
637
644
|
|
|
645
|
+
`GET /items?search=foo&ordering=name&status=active` then works out of the box. Operators: `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `in` (e.g. `?status__in=active,pending`). Fields outside the whitelist are ignored; ordering by an unknown field returns `400`. Without a `ListConfig`, LIST behaves exactly as before.
|
|
646
|
+
|
|
638
647
|
## Permissions and custom routes
|
|
639
648
|
|
|
640
649
|
There is no `get_queryset` hook; scope queries by subclassing `BaseViewset` and overriding `list()`, `get_element()`, or related handlers. The class subclasses `APIRouter`, so attach extra endpoints with `add_api_route` **before** `register()` if paths must win over `/{id}`:
|
|
@@ -690,17 +699,17 @@ Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.
|
|
|
690
699
|
|
|
691
700
|
| Item | Target | Status |
|
|
692
701
|
| --- | --- | --- |
|
|
693
|
-
|
|
|
694
|
-
|
|
|
695
|
-
|
|
|
696
|
-
|
|
697
|
-
|
|
702
|
+
| Transaction helpers (`begin` / `atomic`) across adapters | future | Planned |
|
|
703
|
+
| Range filters on dates (`date__range`) and null checks (`field__isnull`) | future | Planned |
|
|
704
|
+
| Cross-relation search (searching through `select_related` fields) | future | Planned |
|
|
705
|
+
|
|
706
|
+
Released: server-side `search` (v1.5.0), declarative ordering and advanced filters (v1.5.0).
|
|
698
707
|
|
|
699
708
|
## Comparison with alternatives
|
|
700
709
|
|
|
701
710
|
| Approach | Developer experience | ORM support | Permissions | Filtering |
|
|
702
711
|
| --- | --- | --- | --- | --- |
|
|
703
|
-
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset
|
|
712
|
+
| fastapi-viewsets | One `BaseViewset` registers CRUD routes | SQLAlchemy sync/async, Tortoise, Peewee via adapters | OAuth2 per logical method via `register` | `limit`/`offset`, `search`, `ordering` and operator filters via `ListConfig` |
|
|
704
713
|
| fastapi-crudrouter | CRUD-focused generators, less ViewSet-shaped | Primarily SQLAlchemy | Custom middleware/deps | Often extended manually |
|
|
705
714
|
| Hand-rolled FastAPI | Full control, most boilerplate | Any ORM you integrate | Fully custom | Fully custom |
|
|
706
715
|
|
|
@@ -9,7 +9,7 @@ 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
|
|
12
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
from sqlalchemy.orm import Session
|
|
15
15
|
|
|
@@ -94,9 +94,30 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
94
94
|
limit: Optional[int] = 10,
|
|
95
95
|
offset: Optional[int] = 0,
|
|
96
96
|
search: Optional[str] = None,
|
|
97
|
+
ordering: Optional[str] = None,
|
|
98
|
+
request: Request = None,
|
|
97
99
|
token: str = Depends(_noop_dependency),
|
|
98
100
|
) -> List[ResponseModelType]:
|
|
99
|
-
"""List items with
|
|
101
|
+
"""List items with pagination, search, ordering and filters.
|
|
102
|
+
|
|
103
|
+
Query parameters (behaviour is driven by the response schema's
|
|
104
|
+
``ListConfig`` — see :mod:`fastapi_viewsets.filtering`):
|
|
105
|
+
|
|
106
|
+
* ``limit`` / ``offset`` — pagination.
|
|
107
|
+
* ``search`` — case-insensitive substring match across
|
|
108
|
+
``ListConfig.search_fields``.
|
|
109
|
+
* ``ordering`` — comma-separated fields, ``-`` prefix for
|
|
110
|
+
descending, validated against ``ListConfig.ordering_fields``.
|
|
111
|
+
* ``<field>`` / ``<field>__<op>`` — exact and comparison filters
|
|
112
|
+
on ``ListConfig.filters`` fields.
|
|
113
|
+
"""
|
|
114
|
+
from fastapi_viewsets.filtering import (
|
|
115
|
+
get_list_config,
|
|
116
|
+
parse_filters,
|
|
117
|
+
parse_ordering_param,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
config = get_list_config(self.response_model)
|
|
100
121
|
return get_list_queryset(
|
|
101
122
|
self.model,
|
|
102
123
|
db_session=self.db_session,
|
|
@@ -104,6 +125,13 @@ class BaseViewset(_RegisterMixin, APIRouter):
|
|
|
104
125
|
offset=offset,
|
|
105
126
|
orm_adapter=self.orm_adapter,
|
|
106
127
|
response_model=self.response_model,
|
|
128
|
+
search=search,
|
|
129
|
+
ordering=parse_ordering_param(
|
|
130
|
+
ordering, config.ordering_fields, config.ordering
|
|
131
|
+
),
|
|
132
|
+
filters=parse_filters(request.query_params, config.filters)
|
|
133
|
+
if request is not None
|
|
134
|
+
else None,
|
|
107
135
|
)
|
|
108
136
|
|
|
109
137
|
def get_element(
|
|
@@ -4,7 +4,7 @@ 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
|
|
7
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
10
|
|
|
@@ -85,9 +85,30 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
85
85
|
limit: Optional[int] = 10,
|
|
86
86
|
offset: Optional[int] = 0,
|
|
87
87
|
search: Optional[str] = None,
|
|
88
|
+
ordering: Optional[str] = None,
|
|
89
|
+
request: Request = None,
|
|
88
90
|
token: str = Depends(_noop_dependency),
|
|
89
91
|
) -> List[ResponseModelType]:
|
|
90
|
-
"""List items with
|
|
92
|
+
"""List items with pagination, search, ordering and filters (async).
|
|
93
|
+
|
|
94
|
+
Query parameters (behaviour is driven by the response schema's
|
|
95
|
+
``ListConfig`` — see :mod:`fastapi_viewsets.filtering`):
|
|
96
|
+
|
|
97
|
+
* ``limit`` / ``offset`` — pagination.
|
|
98
|
+
* ``search`` — case-insensitive substring match across
|
|
99
|
+
``ListConfig.search_fields``.
|
|
100
|
+
* ``ordering`` — comma-separated fields, ``-`` prefix for
|
|
101
|
+
descending, validated against ``ListConfig.ordering_fields``.
|
|
102
|
+
* ``<field>`` / ``<field>__<op>`` — exact and comparison filters
|
|
103
|
+
on ``ListConfig.filters`` fields.
|
|
104
|
+
"""
|
|
105
|
+
from fastapi_viewsets.filtering import (
|
|
106
|
+
get_list_config,
|
|
107
|
+
parse_filters,
|
|
108
|
+
parse_ordering_param,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
config = get_list_config(self.response_model)
|
|
91
112
|
return await get_list_queryset(
|
|
92
113
|
self.model,
|
|
93
114
|
db_session=self.db_session,
|
|
@@ -95,6 +116,13 @@ class AsyncBaseViewset(_RegisterMixin, APIRouter):
|
|
|
95
116
|
offset=offset,
|
|
96
117
|
orm_adapter=self.orm_adapter,
|
|
97
118
|
response_model=self.response_model,
|
|
119
|
+
search=search,
|
|
120
|
+
ordering=parse_ordering_param(
|
|
121
|
+
ordering, config.ordering_fields, config.ordering
|
|
122
|
+
),
|
|
123
|
+
filters=parse_filters(request.query_params, config.filters)
|
|
124
|
+
if request is not None
|
|
125
|
+
else None,
|
|
98
126
|
)
|
|
99
127
|
|
|
100
128
|
async def get_element(
|