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.
Files changed (47) hide show
  1. {fastapi_restly-0.7.0/fastapi_restly.egg-info → fastapi_restly-0.9.0}/PKG-INFO +74 -36
  2. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/README.md +71 -34
  3. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/__init__.py +15 -1
  4. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/_exception_handlers.py +51 -7
  5. fastapi_restly-0.9.0/fastapi_restly/_pytest_fixtures.py +849 -0
  6. fastapi_restly-0.9.0/fastapi_restly/_test_setup.py +643 -0
  7. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/__init__.py +0 -5
  8. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/_globals.py +12 -0
  9. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/_proxy.py +14 -0
  10. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/db/_session.py +94 -94
  11. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/exc.py +6 -4
  12. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/models/_base.py +12 -2
  13. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/objects.py +6 -0
  14. fastapi_restly-0.9.0/fastapi_restly/pytest_fixtures.py +36 -0
  15. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/query/_impl.py +135 -76
  16. fastapi_restly-0.9.0/fastapi_restly/query/_shared.py +6 -0
  17. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/schemas/__init__.py +2 -0
  18. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/schemas/_base.py +361 -48
  19. fastapi_restly-0.9.0/fastapi_restly/testing/__init__.py +17 -0
  20. fastapi_restly-0.9.0/fastapi_restly/testing/_client.py +239 -0
  21. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/__init__.py +7 -3
  22. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_async.py +39 -21
  23. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_base.py +507 -219
  24. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_react_admin.py +16 -5
  25. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_sync.py +35 -18
  26. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0/fastapi_restly.egg-info}/PKG-INFO +74 -36
  27. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/SOURCES.txt +1 -1
  28. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/requires.txt +2 -1
  29. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/pyproject.toml +21 -7
  30. fastapi_restly-0.7.0/fastapi_restly/_pytest_fixtures.py +0 -268
  31. fastapi_restly-0.7.0/fastapi_restly/pytest_fixtures.py +0 -24
  32. fastapi_restly-0.7.0/fastapi_restly/query/_shared.py +0 -22
  33. fastapi_restly-0.7.0/fastapi_restly/testing/__init__.py +0 -20
  34. fastapi_restly-0.7.0/fastapi_restly/testing/_client.py +0 -98
  35. fastapi_restly-0.7.0/fastapi_restly/testing/_fixtures.py +0 -20
  36. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/LICENSE +0 -0
  37. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/MANIFEST.in +0 -0
  38. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/models/__init__.py +0 -0
  39. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/py.typed +0 -0
  40. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/query/__init__.py +0 -0
  41. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/schemas/_generator.py +0 -0
  42. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_lifecycle.py +0 -0
  43. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly/views/_openapi.py +0 -0
  44. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/dependency_links.txt +0 -0
  45. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/entry_points.txt +0 -0
  46. {fastapi_restly-0.7.0 → fastapi_restly-0.9.0}/fastapi_restly.egg-info/top_level.txt +0 -0
  47. {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.7.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
- # FastAPI-Restly
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
- [![CI](https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml)
47
- [![Python](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)](https://github.com/rjprins/fastapi-restly/blob/main/pyproject.toml)
48
- [![License](https://img.shields.io/github/license/rjprins/fastapi-restly)](https://github.com/rjprins/fastapi-restly/blob/main/LICENSE)
49
- [![Coverage](https://www.fastapi-restly.org/coverage/badge.svg)](https://www.fastapi-restly.org/coverage/)
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
- <img src="https://raw.githubusercontent.com/rjprins/fastapi-restly/main/docs/_static/restly-cat-white-bg.png" alt="FastAPI-Restly logo" width="200">
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
- **Build maintainable REST APIs on FastAPI, SQLAlchemy 2.0, and Pydantic v2 — with real class-based views.**
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 resources, or `AsyncRestView` / `RestView` for generated CRUD.
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 `IDRef[...]`.
86
- - **React Admin ready**: `AsyncReactAdminView` speaks `ra-data-simple-rest`.
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
- (To make it fully runnable, add dev table creation — see
96
- [Getting Started](https://www.fastapi-restly.org/getting_started.html).)
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 four lines:
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&age__gte=21
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 opt-in: omitting `page_size` returns every matching row. For
251
- public endpoints, set `default_page_size` and `max_page_size` on the view:
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 relationships on create and update with `fr.IDRef[...]`.
276
- Restly passes either the foreign key (`customer_id`) or the related object (`Customer`) to SQLAlchemy, depending on the model constructor.
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.IDRef[Customer]
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 [react-admin](https://marmelab.com/react-admin/)
319
- backend compatible with [`ra-data-simple-rest`](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest):
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
- ### Pagination metadata
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 UserView(fr.AsyncRestView):
348
- prefix = "/users"
349
- model = User
350
- include_pagination_metadata = True
351
- # Response: {"items": [...], "total": N, "page": 1, "page_size": 100, "total_pages": N, ...}
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. The testing extra auto-loads them as a pytest plugin.
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 skip the assertion and inspect the response yourself.
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
- # FastAPI-Restly
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
- [![CI](https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/rjprins/fastapi-restly/actions/workflows/ci.yml)
4
- [![Python](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)](https://github.com/rjprins/fastapi-restly/blob/main/pyproject.toml)
5
- [![License](https://img.shields.io/github/license/rjprins/fastapi-restly)](https://github.com/rjprins/fastapi-restly/blob/main/LICENSE)
6
- [![Coverage](https://www.fastapi-restly.org/coverage/badge.svg)](https://www.fastapi-restly.org/coverage/)
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
- <img src="https://raw.githubusercontent.com/rjprins/fastapi-restly/main/docs/_static/restly-cat-white-bg.png" alt="FastAPI-Restly logo" width="200">
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
- **Build maintainable REST APIs on FastAPI, SQLAlchemy 2.0, and Pydantic v2 — with real class-based views.**
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 resources, or `AsyncRestView` / `RestView` for generated CRUD.
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 `IDRef[...]`.
43
- - **React Admin ready**: `AsyncReactAdminView` speaks `ra-data-simple-rest`.
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
- (To make it fully runnable, add dev table creation — see
53
- [Getting Started](https://www.fastapi-restly.org/getting_started.html).)
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 four lines:
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&age__gte=21
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 opt-in: omitting `page_size` returns every matching row. For
208
- public endpoints, set `default_page_size` and `max_page_size` on the view:
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 relationships on create and update with `fr.IDRef[...]`.
233
- Restly passes either the foreign key (`customer_id`) or the related object (`Customer`) to SQLAlchemy, depending on the model constructor.
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.IDRef[Customer]
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 [react-admin](https://marmelab.com/react-admin/)
276
- backend compatible with [`ra-data-simple-rest`](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest):
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
- ### Pagination metadata
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 UserView(fr.AsyncRestView):
305
- prefix = "/users"
306
- model = User
307
- include_pagination_metadata = True
308
- # Response: {"items": [...], "total": N, "page": 1, "page_size": 100, "total_pages": N, ...}
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. The testing extra auto-loads them as a pytest plugin.
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 skip the assertion and inspect the response yourself.
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 _PackageNotFoundError: # pragma: no cover - only possible from an unpackaged tree
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 psycopg's ``orig.pgcode``) and the SQLite
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
- Looks at ``orig.pgcode`` (set by psycopg / psycopg2 / asyncpg-via-psycopg)
65
- and, when available, ``orig.diag.constraint_name`` /
66
- ``orig.diag.column_name`` to enrich the message.
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.pgcode`` (SQLSTATE class 23).
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"]