fastapi-restly 0.7.0__tar.gz → 0.9.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_restly-0.7.0/fastapi_restly.egg-info → fastapi_restly-0.9.0}/PKG-INFO +74 -36
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/README.md +71 -34
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/__init__.py +15 -1
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/_exception_handlers.py +51 -7
- fastapi_restly-0.9.0/fastapi_restly/_pytest_fixtures.py +849 -0
- fastapi_restly-0.9.0/fastapi_restly/_test_setup.py +643 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/__init__.py +0 -5
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/_globals.py +12 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/_proxy.py +14 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/_session.py +94 -94
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/exc.py +6 -4
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/models/_base.py +12 -2
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/objects.py +6 -0
- fastapi_restly-0.9.0/fastapi_restly/pytest_fixtures.py +36 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/query/_impl.py +135 -76
- fastapi_restly-0.9.0/fastapi_restly/query/_shared.py +6 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/schemas/__init__.py +2 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/schemas/_base.py +361 -48
- fastapi_restly-0.9.0/fastapi_restly/testing/__init__.py +17 -0
- fastapi_restly-0.9.0/fastapi_restly/testing/_client.py +239 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/__init__.py +7 -3
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_async.py +39 -21
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_base.py +507 -219
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_react_admin.py +16 -5
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_sync.py +35 -18
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0/fastapi_restly.egg-info}/PKG-INFO +74 -36
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/SOURCES.txt +1 -1
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/requires.txt +2 -1
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/pyproject.toml +21 -7
- fastapi_restly-0.7.0/fastapi_restly/_pytest_fixtures.py +0 -268
- fastapi_restly-0.7.0/fastapi_restly/pytest_fixtures.py +0 -24
- fastapi_restly-0.7.0/fastapi_restly/query/_shared.py +0 -22
- fastapi_restly-0.7.0/fastapi_restly/testing/__init__.py +0 -20
- fastapi_restly-0.7.0/fastapi_restly/testing/_client.py +0 -98
- fastapi_restly-0.7.0/fastapi_restly/testing/_fixtures.py +0 -20
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/LICENSE +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/MANIFEST.in +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/models/__init__.py +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/py.typed +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/query/__init__.py +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/schemas/_generator.py +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_lifecycle.py +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_openapi.py +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/dependency_links.txt +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/entry_points.txt +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/top_level.txt +0 -0
- {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-restly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A REST Framework for FastAPI
|
|
5
5
|
Author-email: Rutger Prins <rutgerprins@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -29,30 +29,42 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
License-File: LICENSE
|
|
30
30
|
Requires-Dist: alembic>=1.15.2
|
|
31
31
|
Requires-Dist: fastapi>=0.115.0
|
|
32
|
-
Requires-Dist: orjson>=3.10.18
|
|
33
32
|
Requires-Dist: pydantic>=2.11.0
|
|
34
33
|
Requires-Dist: pydantic-settings>=2.9.1
|
|
35
34
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.22
|
|
36
35
|
Provides-Extra: standard
|
|
37
36
|
Requires-Dist: fastapi[standard]>=0.115.0; extra == "standard"
|
|
38
37
|
Provides-Extra: testing
|
|
38
|
+
Requires-Dist: asgi-lifespan>=2.1.0; extra == "testing"
|
|
39
39
|
Requires-Dist: httpx>=0.27.0; extra == "testing"
|
|
40
|
+
Requires-Dist: httpx2>=2.0.0; extra == "testing"
|
|
40
41
|
Requires-Dist: pytest>=8.3.5; extra == "testing"
|
|
41
42
|
Requires-Dist: pytest-asyncio>=0.24.0; extra == "testing"
|
|
42
43
|
Dynamic: license-file
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
<p align="center">
|
|
46
|
+
<img src="https://raw.githubusercontent.com/rjprins/fastapi-restly/main/docs/_static/fr-wordmark.svg" alt="FastAPI-Restly" width="664">
|
|
47
|
+
</p>
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
<p align="center">
|
|
50
|
+
<a href="https://pypi.org/project/fastapi-restly/"><img src="https://img.shields.io/pypi/v/fastapi-restly" alt="PyPI"></a>
|
|
51
|
+
<a href="https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml"><img src="https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
|
52
|
+
<a href="https://github.com/rjprins/fastapi-restly/blob/main/pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue" alt="Python"></a>
|
|
53
|
+
<a href="https://github.com/rjprins/fastapi-restly/blob/main/LICENSE"><img src="https://img.shields.io/github/license/rjprins/fastapi-restly" alt="License"></a>
|
|
54
|
+
<a href="https://www.fastapi-restly.org/coverage/"><img src="https://www.fastapi-restly.org/coverage/badge.svg" alt="Coverage"></a>
|
|
55
|
+
</p>
|
|
50
56
|
|
|
51
57
|
<p align="center">
|
|
52
|
-
<
|
|
58
|
+
<a href="https://codespaces.new/rjprins/fastapi-restly?quickstart=1"><img src="https://github.com/codespaces/badge.svg" alt="Open in GitHub Codespaces"></a>
|
|
53
59
|
</p>
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
<p align="center">
|
|
62
|
+
Try FastAPI-Restly in your browser: the badge above opens a GitHub Codespace with the dev environment installed, ready to run a live example API.
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+
<p align="center">
|
|
66
|
+
<strong>Build maintainable REST APIs on FastAPI, SQLAlchemy 2.0, and Pydantic v2 — with real class-based views.</strong>
|
|
67
|
+
</p>
|
|
56
68
|
|
|
57
69
|
> **Status:** public beta release ([changelog](https://github.com/rjprins/fastapi-restly/blob/main/CHANGELOG.md)).
|
|
58
70
|
>
|
|
@@ -77,30 +89,28 @@ Its class-based views are real Python classes: use inheritance, mixins, and
|
|
|
77
89
|
method overrides to share behavior across resources.
|
|
78
90
|
|
|
79
91
|
- **Class-based views**: group endpoints on Python classes with inheritance and method overrides.
|
|
80
|
-
- **REST endpoints in minutes**: use `View` for custom
|
|
92
|
+
- **REST endpoints in minutes**: use `View` for custom endpoint groups, or `AsyncRestView` / `RestView` for generated CRUD.
|
|
81
93
|
- **Incremental adoption**: use Restly per resource; drop to ordinary FastAPI when needed — see [Existing Project Integration](https://www.fastapi-restly.org/howto_existing_project.html).
|
|
82
94
|
- **Class-level dependencies**: declare shared dependencies once and read their values from `self`.
|
|
83
95
|
- **Explicit override points**: change the route shell, request handler, or business verb.
|
|
84
96
|
- **Filtering, pagination, sorting**: get schema-derived list parameters.
|
|
85
|
-
- **Field control**: `ReadOnly` / `WriteOnly` markers, plus foreign-key validation through `
|
|
86
|
-
- **React Admin ready**: `AsyncReactAdminView`
|
|
97
|
+
- **Field control**: `ReadOnly` / `WriteOnly` markers, plus foreign-key validation through `MustExist[...]`.
|
|
98
|
+
- **React Admin ready**: `AsyncReactAdminView` / `ReactAdminView` speak `ra-data-simple-rest`.
|
|
87
99
|
- **App utilities**: SQLAlchemy engine/session setup, exception handlers, and test fixtures.
|
|
88
100
|
|
|
89
|
-
Coming from another CRUD generator? See
|
|
90
|
-
[How Restly compares](https://www.fastapi-restly.org/about.html#how-restly-compares).
|
|
91
|
-
|
|
92
101
|
## Quickstart
|
|
93
102
|
|
|
94
103
|
FastAPI-Restly turns a SQLAlchemy model into a class-based CRUD resource.
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
This example is complete and runnable; it creates SQLite tables at startup for
|
|
105
|
+
local development. Use Alembic migrations in production.
|
|
97
106
|
|
|
98
107
|
```python
|
|
108
|
+
from contextlib import asynccontextmanager
|
|
109
|
+
|
|
99
110
|
import fastapi_restly as fr
|
|
100
111
|
from fastapi import FastAPI
|
|
101
112
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
102
113
|
|
|
103
|
-
app = FastAPI()
|
|
104
114
|
fr.configure(async_database_url="sqlite+aiosqlite:///app.db")
|
|
105
115
|
|
|
106
116
|
class Base(DeclarativeBase):
|
|
@@ -113,6 +123,13 @@ class User(Base):
|
|
|
113
123
|
name: Mapped[str]
|
|
114
124
|
email: Mapped[str]
|
|
115
125
|
|
|
126
|
+
@asynccontextmanager
|
|
127
|
+
async def lifespan(_app: FastAPI):
|
|
128
|
+
await fr.db.async_create_all(Base) # dev tables; use Alembic in production
|
|
129
|
+
yield
|
|
130
|
+
|
|
131
|
+
app = FastAPI(lifespan=lifespan)
|
|
132
|
+
|
|
116
133
|
@fr.include_view(app)
|
|
117
134
|
class UserView(fr.AsyncRestView):
|
|
118
135
|
prefix = "/users"
|
|
@@ -131,6 +148,10 @@ DELETE /users/{id} # delete one user
|
|
|
131
148
|
|
|
132
149
|
Restly generates the Pydantic schemas automatically.
|
|
133
150
|
|
|
151
|
+
The Quickstart uses plain SQLAlchemy on purpose — Restly doesn't hide it. In
|
|
152
|
+
real projects you'll usually inherit `fr.IDBase` for models and `fr.IDSchema`
|
|
153
|
+
for schemas, which supply the `id` for you; both appear in the examples below.
|
|
154
|
+
|
|
134
155
|
### Not just CRUD
|
|
135
156
|
|
|
136
157
|
`View` is the same class-based machinery without the generated routes — use it
|
|
@@ -150,7 +171,7 @@ class AuthView(fr.View):
|
|
|
150
171
|
async def logout(self) -> None: ...
|
|
151
172
|
```
|
|
152
173
|
|
|
153
|
-
And because views truly subclass, the biggest everyday win takes
|
|
174
|
+
And because views truly subclass, the biggest everyday win takes only a few lines:
|
|
154
175
|
declare your app's request context once on a base view, and read it from
|
|
155
176
|
`self` everywhere — instead of re-declaring the same `Depends` parameters on
|
|
156
177
|
every function in the project:
|
|
@@ -170,7 +191,6 @@ resource, and `RestView` plus custom `@fr.post` methods for CRUD with actions.
|
|
|
170
191
|
|
|
171
192
|
Why a `View` over a bare `APIRouter`?
|
|
172
193
|
|
|
173
|
-
- Shared dependencies are typed attributes you read from `self` (`self.session`, `self.current_user`).
|
|
174
194
|
- Prefix, tags, responses, and dependencies are declared once on the class.
|
|
175
195
|
- Views compose: inheritance and mixins share behavior across endpoint groups.
|
|
176
196
|
- One registration call (`fr.include_view`) per class, bindable to any app or router.
|
|
@@ -239,7 +259,7 @@ Use **auto-schema** for prototypes and internal tools. Use an **explicit schema*
|
|
|
239
259
|
List endpoints expose a stable URL parameter dialect generated from the response schema:
|
|
240
260
|
|
|
241
261
|
```bash
|
|
242
|
-
GET /users/?name=John&
|
|
262
|
+
GET /users/?name=John&created_at__gte=2024-01-01
|
|
243
263
|
GET /users/?email__icontains=example
|
|
244
264
|
GET /users/?sort=-created_at&page=2&page_size=10
|
|
245
265
|
```
|
|
@@ -247,8 +267,10 @@ GET /users/?sort=-created_at&page=2&page_size=10
|
|
|
247
267
|
Parameter keys use the **response schema's public names**, including dotted
|
|
248
268
|
relation paths; unknown keys are rejected with `422`.
|
|
249
269
|
|
|
250
|
-
Pagination is
|
|
251
|
-
|
|
270
|
+
Pagination is on by default: list endpoints wrap rows in a `data` envelope and
|
|
271
|
+
cap each page at `default_page_size` (50). Clients page with `?page=` and
|
|
272
|
+
`?page_size=`. Tune the default and set a `max_page_size` ceiling on public
|
|
273
|
+
endpoints:
|
|
252
274
|
|
|
253
275
|
```python
|
|
254
276
|
class UserView(fr.AsyncRestView):
|
|
@@ -272,8 +294,8 @@ class UserRead(fr.IDSchema):
|
|
|
272
294
|
|
|
273
295
|
### Relationship handling
|
|
274
296
|
|
|
275
|
-
Validate
|
|
276
|
-
|
|
297
|
+
Validate a foreign-key column on create and update with `fr.MustExist[int, Model]`.
|
|
298
|
+
It keeps the plain id (`customer_id`) and checks the referenced row exists; declare the relationship (`customer`) separately when you also want the nested object.
|
|
277
299
|
|
|
278
300
|
```python
|
|
279
301
|
class Order(fr.IDBase):
|
|
@@ -281,7 +303,7 @@ class Order(fr.IDBase):
|
|
|
281
303
|
customer: Mapped[Customer] = relationship()
|
|
282
304
|
|
|
283
305
|
class OrderRead(fr.IDSchema):
|
|
284
|
-
customer_id: fr.
|
|
306
|
+
customer_id: fr.MustExist[int, Customer]
|
|
285
307
|
customer: fr.ReadOnly[CustomerRead]
|
|
286
308
|
```
|
|
287
309
|
|
|
@@ -315,8 +337,9 @@ class UploadView(fr.AsyncRestView):
|
|
|
315
337
|
|
|
316
338
|
### React Admin integration
|
|
317
339
|
|
|
318
|
-
Use `AsyncReactAdminView` for a
|
|
319
|
-
|
|
340
|
+
Use `AsyncReactAdminView` (or `ReactAdminView` for a sync stack) for a
|
|
341
|
+
[react-admin](https://marmelab.com/react-admin/) backend compatible with
|
|
342
|
+
[`ra-data-simple-rest`](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest):
|
|
320
343
|
|
|
321
344
|
```python
|
|
322
345
|
@fr.include_view(app)
|
|
@@ -340,21 +363,35 @@ class UserView(fr.AsyncRestView):
|
|
|
340
363
|
exclude_routes = (fr.ViewRoute.DELETE,)
|
|
341
364
|
```
|
|
342
365
|
|
|
343
|
-
###
|
|
366
|
+
### List response envelope
|
|
367
|
+
|
|
368
|
+
List endpoints return a `data` envelope with pagination metadata:
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{"data": [...], "total_count": 123, "page": 1, "page_size": 50, "total_pages": 3}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Set `paginated = False` to return every matching row in a bare `data` envelope
|
|
375
|
+
(no cap, no metadata):
|
|
344
376
|
|
|
345
377
|
```python
|
|
346
378
|
@fr.include_view(app)
|
|
347
|
-
class
|
|
348
|
-
prefix = "/
|
|
349
|
-
model =
|
|
350
|
-
|
|
351
|
-
# Response: {"
|
|
379
|
+
class TagView(fr.AsyncRestView):
|
|
380
|
+
prefix = "/tags"
|
|
381
|
+
model = Tag
|
|
382
|
+
paginated = False
|
|
383
|
+
# Response: {"data": [...]}
|
|
352
384
|
```
|
|
353
385
|
|
|
386
|
+
For a different response shape, replace `get_many_endpoint` with a matching
|
|
387
|
+
`response_model`; use `to_listing_response()` inside the replacement when you
|
|
388
|
+
want to reuse the default serialization and page-metadata calculation.
|
|
389
|
+
|
|
354
390
|
## Testing
|
|
355
391
|
|
|
356
392
|
`fastapi_restly.pytest_fixtures` provides client and session fixtures with
|
|
357
|
-
savepoint-based isolation.
|
|
393
|
+
savepoint-based isolation. Restly registers them as a pytest plugin
|
|
394
|
+
automatically whenever it and pytest are installed.
|
|
358
395
|
|
|
359
396
|
Install the testing extra when consuming FastAPI-Restly as a package:
|
|
360
397
|
|
|
@@ -379,7 +416,8 @@ def test_create_and_fetch_user(restly_client):
|
|
|
379
416
|
assert data["name"] == "John"
|
|
380
417
|
```
|
|
381
418
|
|
|
382
|
-
Pass `assert_status_code=None` to
|
|
419
|
+
Pass `assert_status_code=None` to relax the check to any success status (2xx/3xx).
|
|
420
|
+
To inspect an error response, assert its exact code instead (e.g. `assert_status_code=422`).
|
|
383
421
|
|
|
384
422
|
## Configuration
|
|
385
423
|
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/rjprins/fastapi-restly/main/docs/_static/fr-wordmark.svg" alt="FastAPI-Restly" width="664">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://pypi.org/project/fastapi-restly/"><img src="https://img.shields.io/pypi/v/fastapi-restly" alt="PyPI"></a>
|
|
7
|
+
<a href="https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml"><img src="https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
|
8
|
+
<a href="https://github.com/rjprins/fastapi-restly/blob/main/pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue" alt="Python"></a>
|
|
9
|
+
<a href="https://github.com/rjprins/fastapi-restly/blob/main/LICENSE"><img src="https://img.shields.io/github/license/rjprins/fastapi-restly" alt="License"></a>
|
|
10
|
+
<a href="https://www.fastapi-restly.org/coverage/"><img src="https://www.fastapi-restly.org/coverage/badge.svg" alt="Coverage"></a>
|
|
11
|
+
</p>
|
|
7
12
|
|
|
8
13
|
<p align="center">
|
|
9
|
-
<
|
|
14
|
+
<a href="https://codespaces.new/rjprins/fastapi-restly?quickstart=1"><img src="https://github.com/codespaces/badge.svg" alt="Open in GitHub Codespaces"></a>
|
|
10
15
|
</p>
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
<p align="center">
|
|
18
|
+
Try FastAPI-Restly in your browser: the badge above opens a GitHub Codespace with the dev environment installed, ready to run a live example API.
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<strong>Build maintainable REST APIs on FastAPI, SQLAlchemy 2.0, and Pydantic v2 — with real class-based views.</strong>
|
|
23
|
+
</p>
|
|
13
24
|
|
|
14
25
|
> **Status:** public beta release ([changelog](https://github.com/rjprins/fastapi-restly/blob/main/CHANGELOG.md)).
|
|
15
26
|
>
|
|
@@ -34,30 +45,28 @@ Its class-based views are real Python classes: use inheritance, mixins, and
|
|
|
34
45
|
method overrides to share behavior across resources.
|
|
35
46
|
|
|
36
47
|
- **Class-based views**: group endpoints on Python classes with inheritance and method overrides.
|
|
37
|
-
- **REST endpoints in minutes**: use `View` for custom
|
|
48
|
+
- **REST endpoints in minutes**: use `View` for custom endpoint groups, or `AsyncRestView` / `RestView` for generated CRUD.
|
|
38
49
|
- **Incremental adoption**: use Restly per resource; drop to ordinary FastAPI when needed — see [Existing Project Integration](https://www.fastapi-restly.org/howto_existing_project.html).
|
|
39
50
|
- **Class-level dependencies**: declare shared dependencies once and read their values from `self`.
|
|
40
51
|
- **Explicit override points**: change the route shell, request handler, or business verb.
|
|
41
52
|
- **Filtering, pagination, sorting**: get schema-derived list parameters.
|
|
42
|
-
- **Field control**: `ReadOnly` / `WriteOnly` markers, plus foreign-key validation through `
|
|
43
|
-
- **React Admin ready**: `AsyncReactAdminView`
|
|
53
|
+
- **Field control**: `ReadOnly` / `WriteOnly` markers, plus foreign-key validation through `MustExist[...]`.
|
|
54
|
+
- **React Admin ready**: `AsyncReactAdminView` / `ReactAdminView` speak `ra-data-simple-rest`.
|
|
44
55
|
- **App utilities**: SQLAlchemy engine/session setup, exception handlers, and test fixtures.
|
|
45
56
|
|
|
46
|
-
Coming from another CRUD generator? See
|
|
47
|
-
[How Restly compares](https://www.fastapi-restly.org/about.html#how-restly-compares).
|
|
48
|
-
|
|
49
57
|
## Quickstart
|
|
50
58
|
|
|
51
59
|
FastAPI-Restly turns a SQLAlchemy model into a class-based CRUD resource.
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
This example is complete and runnable; it creates SQLite tables at startup for
|
|
61
|
+
local development. Use Alembic migrations in production.
|
|
54
62
|
|
|
55
63
|
```python
|
|
64
|
+
from contextlib import asynccontextmanager
|
|
65
|
+
|
|
56
66
|
import fastapi_restly as fr
|
|
57
67
|
from fastapi import FastAPI
|
|
58
68
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
59
69
|
|
|
60
|
-
app = FastAPI()
|
|
61
70
|
fr.configure(async_database_url="sqlite+aiosqlite:///app.db")
|
|
62
71
|
|
|
63
72
|
class Base(DeclarativeBase):
|
|
@@ -70,6 +79,13 @@ class User(Base):
|
|
|
70
79
|
name: Mapped[str]
|
|
71
80
|
email: Mapped[str]
|
|
72
81
|
|
|
82
|
+
@asynccontextmanager
|
|
83
|
+
async def lifespan(_app: FastAPI):
|
|
84
|
+
await fr.db.async_create_all(Base) # dev tables; use Alembic in production
|
|
85
|
+
yield
|
|
86
|
+
|
|
87
|
+
app = FastAPI(lifespan=lifespan)
|
|
88
|
+
|
|
73
89
|
@fr.include_view(app)
|
|
74
90
|
class UserView(fr.AsyncRestView):
|
|
75
91
|
prefix = "/users"
|
|
@@ -88,6 +104,10 @@ DELETE /users/{id} # delete one user
|
|
|
88
104
|
|
|
89
105
|
Restly generates the Pydantic schemas automatically.
|
|
90
106
|
|
|
107
|
+
The Quickstart uses plain SQLAlchemy on purpose — Restly doesn't hide it. In
|
|
108
|
+
real projects you'll usually inherit `fr.IDBase` for models and `fr.IDSchema`
|
|
109
|
+
for schemas, which supply the `id` for you; both appear in the examples below.
|
|
110
|
+
|
|
91
111
|
### Not just CRUD
|
|
92
112
|
|
|
93
113
|
`View` is the same class-based machinery without the generated routes — use it
|
|
@@ -107,7 +127,7 @@ class AuthView(fr.View):
|
|
|
107
127
|
async def logout(self) -> None: ...
|
|
108
128
|
```
|
|
109
129
|
|
|
110
|
-
And because views truly subclass, the biggest everyday win takes
|
|
130
|
+
And because views truly subclass, the biggest everyday win takes only a few lines:
|
|
111
131
|
declare your app's request context once on a base view, and read it from
|
|
112
132
|
`self` everywhere — instead of re-declaring the same `Depends` parameters on
|
|
113
133
|
every function in the project:
|
|
@@ -127,7 +147,6 @@ resource, and `RestView` plus custom `@fr.post` methods for CRUD with actions.
|
|
|
127
147
|
|
|
128
148
|
Why a `View` over a bare `APIRouter`?
|
|
129
149
|
|
|
130
|
-
- Shared dependencies are typed attributes you read from `self` (`self.session`, `self.current_user`).
|
|
131
150
|
- Prefix, tags, responses, and dependencies are declared once on the class.
|
|
132
151
|
- Views compose: inheritance and mixins share behavior across endpoint groups.
|
|
133
152
|
- One registration call (`fr.include_view`) per class, bindable to any app or router.
|
|
@@ -196,7 +215,7 @@ Use **auto-schema** for prototypes and internal tools. Use an **explicit schema*
|
|
|
196
215
|
List endpoints expose a stable URL parameter dialect generated from the response schema:
|
|
197
216
|
|
|
198
217
|
```bash
|
|
199
|
-
GET /users/?name=John&
|
|
218
|
+
GET /users/?name=John&created_at__gte=2024-01-01
|
|
200
219
|
GET /users/?email__icontains=example
|
|
201
220
|
GET /users/?sort=-created_at&page=2&page_size=10
|
|
202
221
|
```
|
|
@@ -204,8 +223,10 @@ GET /users/?sort=-created_at&page=2&page_size=10
|
|
|
204
223
|
Parameter keys use the **response schema's public names**, including dotted
|
|
205
224
|
relation paths; unknown keys are rejected with `422`.
|
|
206
225
|
|
|
207
|
-
Pagination is
|
|
208
|
-
|
|
226
|
+
Pagination is on by default: list endpoints wrap rows in a `data` envelope and
|
|
227
|
+
cap each page at `default_page_size` (50). Clients page with `?page=` and
|
|
228
|
+
`?page_size=`. Tune the default and set a `max_page_size` ceiling on public
|
|
229
|
+
endpoints:
|
|
209
230
|
|
|
210
231
|
```python
|
|
211
232
|
class UserView(fr.AsyncRestView):
|
|
@@ -229,8 +250,8 @@ class UserRead(fr.IDSchema):
|
|
|
229
250
|
|
|
230
251
|
### Relationship handling
|
|
231
252
|
|
|
232
|
-
Validate
|
|
233
|
-
|
|
253
|
+
Validate a foreign-key column on create and update with `fr.MustExist[int, Model]`.
|
|
254
|
+
It keeps the plain id (`customer_id`) and checks the referenced row exists; declare the relationship (`customer`) separately when you also want the nested object.
|
|
234
255
|
|
|
235
256
|
```python
|
|
236
257
|
class Order(fr.IDBase):
|
|
@@ -238,7 +259,7 @@ class Order(fr.IDBase):
|
|
|
238
259
|
customer: Mapped[Customer] = relationship()
|
|
239
260
|
|
|
240
261
|
class OrderRead(fr.IDSchema):
|
|
241
|
-
customer_id: fr.
|
|
262
|
+
customer_id: fr.MustExist[int, Customer]
|
|
242
263
|
customer: fr.ReadOnly[CustomerRead]
|
|
243
264
|
```
|
|
244
265
|
|
|
@@ -272,8 +293,9 @@ class UploadView(fr.AsyncRestView):
|
|
|
272
293
|
|
|
273
294
|
### React Admin integration
|
|
274
295
|
|
|
275
|
-
Use `AsyncReactAdminView` for a
|
|
276
|
-
|
|
296
|
+
Use `AsyncReactAdminView` (or `ReactAdminView` for a sync stack) for a
|
|
297
|
+
[react-admin](https://marmelab.com/react-admin/) backend compatible with
|
|
298
|
+
[`ra-data-simple-rest`](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest):
|
|
277
299
|
|
|
278
300
|
```python
|
|
279
301
|
@fr.include_view(app)
|
|
@@ -297,21 +319,35 @@ class UserView(fr.AsyncRestView):
|
|
|
297
319
|
exclude_routes = (fr.ViewRoute.DELETE,)
|
|
298
320
|
```
|
|
299
321
|
|
|
300
|
-
###
|
|
322
|
+
### List response envelope
|
|
323
|
+
|
|
324
|
+
List endpoints return a `data` envelope with pagination metadata:
|
|
325
|
+
|
|
326
|
+
```json
|
|
327
|
+
{"data": [...], "total_count": 123, "page": 1, "page_size": 50, "total_pages": 3}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Set `paginated = False` to return every matching row in a bare `data` envelope
|
|
331
|
+
(no cap, no metadata):
|
|
301
332
|
|
|
302
333
|
```python
|
|
303
334
|
@fr.include_view(app)
|
|
304
|
-
class
|
|
305
|
-
prefix = "/
|
|
306
|
-
model =
|
|
307
|
-
|
|
308
|
-
# Response: {"
|
|
335
|
+
class TagView(fr.AsyncRestView):
|
|
336
|
+
prefix = "/tags"
|
|
337
|
+
model = Tag
|
|
338
|
+
paginated = False
|
|
339
|
+
# Response: {"data": [...]}
|
|
309
340
|
```
|
|
310
341
|
|
|
342
|
+
For a different response shape, replace `get_many_endpoint` with a matching
|
|
343
|
+
`response_model`; use `to_listing_response()` inside the replacement when you
|
|
344
|
+
want to reuse the default serialization and page-metadata calculation.
|
|
345
|
+
|
|
311
346
|
## Testing
|
|
312
347
|
|
|
313
348
|
`fastapi_restly.pytest_fixtures` provides client and session fixtures with
|
|
314
|
-
savepoint-based isolation.
|
|
349
|
+
savepoint-based isolation. Restly registers them as a pytest plugin
|
|
350
|
+
automatically whenever it and pytest are installed.
|
|
315
351
|
|
|
316
352
|
Install the testing extra when consuming FastAPI-Restly as a package:
|
|
317
353
|
|
|
@@ -336,7 +372,8 @@ def test_create_and_fetch_user(restly_client):
|
|
|
336
372
|
assert data["name"] == "John"
|
|
337
373
|
```
|
|
338
374
|
|
|
339
|
-
Pass `assert_status_code=None` to
|
|
375
|
+
Pass `assert_status_code=None` to relax the check to any success status (2xx/3xx).
|
|
376
|
+
To inspect an error response, assert its exact code instead (e.g. `assert_status_code=422`).
|
|
340
377
|
|
|
341
378
|
## Configuration
|
|
342
379
|
|
|
@@ -20,6 +20,7 @@ from .schemas import (
|
|
|
20
20
|
BaseSchema,
|
|
21
21
|
IDRef,
|
|
22
22
|
IDSchema,
|
|
23
|
+
MustExist,
|
|
23
24
|
ReadOnly,
|
|
24
25
|
TimestampsSchemaMixin,
|
|
25
26
|
WriteOnly,
|
|
@@ -51,7 +52,9 @@ from .views import (
|
|
|
51
52
|
|
|
52
53
|
try:
|
|
53
54
|
__version__ = _version("fastapi-restly")
|
|
54
|
-
except
|
|
55
|
+
except (
|
|
56
|
+
_PackageNotFoundError
|
|
57
|
+
): # pragma: no cover - only possible from an unpackaged tree
|
|
55
58
|
__version__ = "0+unknown"
|
|
56
59
|
|
|
57
60
|
# Public API surface for fastapi-restly.
|
|
@@ -78,6 +81,7 @@ __all__ = [
|
|
|
78
81
|
"BaseSchema",
|
|
79
82
|
"IDRef",
|
|
80
83
|
"IDSchema",
|
|
84
|
+
"MustExist",
|
|
81
85
|
"ReadOnly",
|
|
82
86
|
"WriteOnly",
|
|
83
87
|
"TimestampsSchemaMixin",
|
|
@@ -100,3 +104,13 @@ __all__ = [
|
|
|
100
104
|
"patch",
|
|
101
105
|
"delete",
|
|
102
106
|
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def __getattr__(name: str):
|
|
110
|
+
# ``fr.testing`` on demand: importing it eagerly would drag the HTTP test
|
|
111
|
+
# client (and httpx) into every production import of fastapi_restly.
|
|
112
|
+
if name == "testing":
|
|
113
|
+
import fastapi_restly.testing as testing_module
|
|
114
|
+
|
|
115
|
+
return testing_module
|
|
116
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -15,8 +15,8 @@ registering their own handler for ``IntegrityError`` *before* the framework
|
|
|
15
15
|
gets a chance to install one.
|
|
16
16
|
|
|
17
17
|
The detail-message extraction is best-effort: it understands the most common
|
|
18
|
-
PostgreSQL SQLSTATE codes (via
|
|
19
|
-
error-message conventions. For unrecognised dialects/messages we fall back
|
|
18
|
+
PostgreSQL SQLSTATE codes (via ``orig.sqlstate`` / ``orig.pgcode``) and the
|
|
19
|
+
SQLite error-message conventions. For unrecognised dialects/messages we fall back
|
|
20
20
|
to a generic conflict message that includes a truncated version of the
|
|
21
21
|
underlying error text.
|
|
22
22
|
"""
|
|
@@ -61,11 +61,13 @@ _PG_SQLSTATE_DETAILS: dict[str, str] = {
|
|
|
61
61
|
def _extract_postgres_detail(orig: Any) -> str | None:
|
|
62
62
|
"""Return a user-facing detail message for a Postgres-driver error.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
Reads the SQLSTATE code and, when psycopg's ``orig.diag`` is present, its
|
|
65
|
+
``constraint_name`` / ``column_name`` to enrich the message. The attribute
|
|
66
|
+
holding the code differs by driver: psycopg 3 exposes ``sqlstate``, psycopg 2
|
|
67
|
+
exposes ``pgcode`` -- read whichever is present. Reading only ``pgcode``
|
|
68
|
+
silently degraded every psycopg 3 error to the generic fallback.
|
|
67
69
|
"""
|
|
68
|
-
pgcode = getattr(orig, "pgcode", None)
|
|
70
|
+
pgcode = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None)
|
|
69
71
|
if not pgcode:
|
|
70
72
|
return None
|
|
71
73
|
|
|
@@ -125,7 +127,8 @@ def _build_integrity_detail(exc: IntegrityError) -> str:
|
|
|
125
127
|
|
|
126
128
|
Best-effort across dialects:
|
|
127
129
|
|
|
128
|
-
* PostgreSQL — switches on ``exc.orig.
|
|
130
|
+
* PostgreSQL — switches on the SQLSTATE code (``exc.orig.sqlstate`` /
|
|
131
|
+
``pgcode``, class 23).
|
|
129
132
|
* SQLite — pattern-matches ``str(exc.orig)`` against known prefixes.
|
|
130
133
|
* Anything else — returns a generic fallback that includes a truncated
|
|
131
134
|
copy of the original error text so the body is still useful for
|
|
@@ -189,6 +192,47 @@ def register_default_exception_handlers(app: FastAPI) -> None:
|
|
|
189
192
|
|
|
190
193
|
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
|
191
194
|
setattr(app.state, _HANDLERS_INSTALLED_FLAG, True)
|
|
195
|
+
_register_on_built_stack(app)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _register_on_built_stack(app: FastAPI) -> None:
|
|
199
|
+
"""Also register on an application that has already been called.
|
|
200
|
+
|
|
201
|
+
Starlette builds its middleware stack on the first call and the stack caches
|
|
202
|
+
the handler map, so a view registered after that would leave this handler out
|
|
203
|
+
and an ``IntegrityError`` would surface as a 500. Entering a test client to
|
|
204
|
+
run the lifespan is enough to build it.
|
|
205
|
+
|
|
206
|
+
Reach the ``ExceptionMiddleware`` already in the stack rather than discarding
|
|
207
|
+
the stack to force a rebuild: rebuilding constructs every user middleware
|
|
208
|
+
again, dropping whatever state the live instances hold, and it would briefly
|
|
209
|
+
disarm Starlette's own "cannot add middleware after startup" check.
|
|
210
|
+
"""
|
|
211
|
+
stack = getattr(app, "middleware_stack", None)
|
|
212
|
+
if stack is None:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
from starlette.middleware.exceptions import ExceptionMiddleware
|
|
216
|
+
|
|
217
|
+
node: Any = stack
|
|
218
|
+
while node is not None:
|
|
219
|
+
if isinstance(node, ExceptionMiddleware):
|
|
220
|
+
node.add_exception_handler(IntegrityError, integrity_error_handler)
|
|
221
|
+
return
|
|
222
|
+
node = getattr(node, "app", None)
|
|
223
|
+
|
|
224
|
+
import warnings
|
|
225
|
+
|
|
226
|
+
# A middleware that stores its wrapped app under another name ends the walk.
|
|
227
|
+
# Rare, but the alternative is unexplained 500s on late-registered views.
|
|
228
|
+
warnings.warn(
|
|
229
|
+
"Restly could not reach Starlette's ExceptionMiddleware on the built "
|
|
230
|
+
"middleware stack: a middleware in the chain does not expose its "
|
|
231
|
+
"wrapped app as `.app`. Views registered after the application first "
|
|
232
|
+
"ran will surface IntegrityError as a 500 rather than a 409.",
|
|
233
|
+
RuntimeWarning,
|
|
234
|
+
stacklevel=2,
|
|
235
|
+
)
|
|
192
236
|
|
|
193
237
|
|
|
194
238
|
__all__ = ["integrity_error_handler", "register_default_exception_handlers"]
|