fastapi-restly 0.5.0__py3-none-any.whl

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.
@@ -0,0 +1,206 @@
1
+ """
2
+ Internal OpenAPI post-processing: x-resource-ref annotations.
3
+
4
+ Called automatically by include_view() — no public API.
5
+
6
+ FK columns and SQLAlchemy relationship fields backed by IDSchema/IDRef
7
+ are annotated with ``x-resource-ref: "<resource-name>"`` in the generated spec.
8
+ Full nested-object relationships (plain Pydantic model fields) are left untouched.
9
+ """
10
+
11
+ import inspect
12
+ import types
13
+ import weakref
14
+ from dataclasses import dataclass
15
+ from typing import Any, Union, get_args, get_origin
16
+
17
+ import fastapi
18
+ import pydantic
19
+ from sqlalchemy import inspect as sa_inspect
20
+ from sqlalchemy.orm import DeclarativeBase
21
+
22
+ from ..schemas import IDSchema
23
+
24
+ _PATCHED_ATTR = "_fr_resource_refs_patched"
25
+
26
+
27
+ @dataclass
28
+ class _Entry:
29
+ model: type[DeclarativeBase]
30
+ resource_name: str
31
+ schema: type[pydantic.BaseModel]
32
+ creation_schema: type[pydantic.BaseModel]
33
+ update_schema: type[pydantic.BaseModel]
34
+
35
+
36
+ _registry: weakref.WeakKeyDictionary[
37
+ fastapi.FastAPI | fastapi.APIRouter, list[_Entry]
38
+ ] = weakref.WeakKeyDictionary()
39
+
40
+
41
+ def _register_for_resource_ref(
42
+ parent_router: fastapi.FastAPI | fastapi.APIRouter, view_cls: type
43
+ ) -> None:
44
+ """Register a view's model→resource mapping and ensure the spec is patched.
45
+
46
+ Silently skips views without a SQLAlchemy model (e.g. plain View subclasses).
47
+ """
48
+ model = getattr(view_cls, "model", None)
49
+ if model is None or not (
50
+ isinstance(model, type) and issubclass(model, DeclarativeBase)
51
+ ):
52
+ return
53
+
54
+ resource_name = "".join(
55
+ c.__dict__["prefix"] for c in reversed(view_cls.mro()) if "prefix" in c.__dict__
56
+ ).lstrip("/")
57
+
58
+ entry = _Entry(
59
+ model=model,
60
+ resource_name=resource_name,
61
+ schema=view_cls.schema,
62
+ creation_schema=view_cls.creation_schema,
63
+ update_schema=view_cls.update_schema,
64
+ )
65
+
66
+ entries = _registry.get(parent_router)
67
+ if entries is None:
68
+ entries = []
69
+ _registry[parent_router] = entries
70
+ entries.append(entry)
71
+
72
+ # Only the FastAPI app generates the OpenAPI spec; APIRouter parents have
73
+ # no ``.openapi`` to patch. Views registered on a router lose x-resource-ref
74
+ # annotations until the framework can walk to a root app, which is a
75
+ # separate gap.
76
+ if isinstance(parent_router, fastapi.FastAPI):
77
+ _ensure_patched(parent_router)
78
+
79
+
80
+ def _ensure_patched(app: fastapi.FastAPI) -> None:
81
+ """Wrap app.openapi() once so annotations are injected on first call."""
82
+ if getattr(app, _PATCHED_ATTR, False):
83
+ return
84
+
85
+ original_openapi = app.openapi
86
+
87
+ def patched_openapi() -> dict[str, Any]:
88
+ spec = original_openapi()
89
+ entries = _registry.get(app, [])
90
+ model_to_resource = {e.model: e.resource_name for e in entries}
91
+ _annotate_spec(spec, entries, model_to_resource)
92
+ return spec
93
+
94
+ app.openapi = patched_openapi # type: ignore[method-assign]
95
+ setattr(app, _PATCHED_ATTR, True)
96
+
97
+
98
+ def _is_id_ref_annotation(annotation: Any) -> bool:
99
+ """Return True if annotation is IDSchema[X], IDRef[X], or list/Optional thereof.
100
+
101
+ Returns False for full nested Pydantic model objects — those are not ID references.
102
+ Concrete user-defined subclasses like ``AuthorRead(IDSchema)`` return False;
103
+ only parametrized generics like ``IDSchema[Author]`` or ``IDRef[Author]``
104
+ return True, since those represent model ID references.
105
+ """
106
+ origin = get_origin(annotation)
107
+
108
+ # Unwrap Optional / Union (X | None, Optional[X], Union[X, Y])
109
+ if origin in (Union, types.UnionType):
110
+ return any(
111
+ _is_id_ref_annotation(a)
112
+ for a in get_args(annotation)
113
+ if a is not type(None)
114
+ )
115
+
116
+ # list[X] — check the element type
117
+ if origin is list:
118
+ args = get_args(annotation)
119
+ return bool(args and _is_id_ref_annotation(args[0]))
120
+
121
+ # Check for parametrized IDSchema/IDRef generics.
122
+ # In Pydantic v2, IDSchema[Author] and IDRef[Author] are concrete classes,
123
+ # so inspect.isclass() returns True for them too. We distinguish via
124
+ # __pydantic_generic_metadata__["origin"]:
125
+ # - Parametrized: IDSchema[Author] → origin = IDSchema
126
+ # - Parametrized: IDRef[Author] → origin = IDRef
127
+ # - User-defined subclass: AuthorRead(IDSchema) → origin = None (not a parametrization)
128
+ pydantic_meta = getattr(annotation, "__pydantic_generic_metadata__", {})
129
+ origin_cls = pydantic_meta.get("origin")
130
+ if inspect.isclass(origin_cls):
131
+ try:
132
+ return issubclass(origin_cls, IDSchema)
133
+ except TypeError:
134
+ return False
135
+
136
+ return False
137
+
138
+
139
+ def _field_openapi_key(schema_cls: type[pydantic.BaseModel], field_name: str) -> str:
140
+ """Return the OpenAPI property key for a field, respecting serialization aliases."""
141
+ field_info = schema_cls.model_fields.get(field_name)
142
+ if field_info is None:
143
+ return field_name
144
+ if field_info.serialization_alias:
145
+ return field_info.serialization_alias
146
+ if field_info.alias:
147
+ return field_info.alias
148
+ return field_name
149
+
150
+
151
+ def _compute_refs(
152
+ schema_cls: type[pydantic.BaseModel],
153
+ model_cls: type[DeclarativeBase],
154
+ model_to_resource: dict[type[DeclarativeBase], str],
155
+ ) -> dict[str, str]:
156
+ """Return {openapi_property_key: resource_name} for FK columns and ID-ref relationship fields."""
157
+ result: dict[str, str] = {}
158
+ try:
159
+ mapper = sa_inspect(model_cls)
160
+ except Exception:
161
+ return result
162
+
163
+ for field_name, field_info in schema_cls.model_fields.items():
164
+ resource_name: str | None = None
165
+
166
+ if field_name in mapper.columns:
167
+ fks = list(mapper.columns[field_name].foreign_keys)
168
+ if fks:
169
+ target_table = fks[0].column.table # Table object identity
170
+ for m in model_cls.registry.mappers:
171
+ if m.local_table is target_table:
172
+ resource_name = model_to_resource.get(m.class_)
173
+ break
174
+
175
+ elif field_name in mapper.relationships:
176
+ # Only annotate if the schema field carries ID references, not full nested objects.
177
+ if _is_id_ref_annotation(field_info.annotation):
178
+ target_model = mapper.relationships[field_name].mapper.class_
179
+ resource_name = model_to_resource.get(target_model)
180
+
181
+ if resource_name is not None:
182
+ result[_field_openapi_key(schema_cls, field_name)] = resource_name
183
+
184
+ return result
185
+
186
+
187
+ def _annotate_spec(
188
+ spec: dict[str, Any],
189
+ entries: list[_Entry],
190
+ model_to_resource: dict[type[DeclarativeBase], str],
191
+ ) -> None:
192
+ """Mutate spec in-place, adding x-resource-ref to qualifying properties."""
193
+ schemas = spec.get("components", {}).get("schemas", {})
194
+ if not schemas:
195
+ return
196
+
197
+ for entry in entries:
198
+ refs = _compute_refs(entry.schema, entry.model, model_to_resource)
199
+ if not refs:
200
+ continue
201
+
202
+ for schema_cls in (entry.schema, entry.creation_schema, entry.update_schema):
203
+ props = schemas.get(schema_cls.__name__, {}).get("properties", {})
204
+ for prop_key, resource_name in refs.items():
205
+ if prop_key in props:
206
+ props[prop_key]["x-resource-ref"] = resource_name
@@ -0,0 +1,393 @@
1
+ """
2
+ React Admin compatible views for fastapi-restly.
3
+
4
+ Implements the ra-data-simple-rest wire contract for list:
5
+ - response body: plain JSON array
6
+ - sort: sort=["field","ASC|DESC"]
7
+ - range: range=[start, end] (both inclusive, e.g. [0,24] = 25 items)
8
+ - filter: filter={"field":"value"} or filter={"id":[1,2,3]} for getMany
9
+ - Content-Range: items 0-24/315
10
+ """
11
+
12
+ import json
13
+ from typing import Any, ClassVar, Protocol, Sequence, cast
14
+
15
+ import fastapi
16
+ import pydantic
17
+ import sqlalchemy
18
+ from sqlalchemy import func, select
19
+ from sqlalchemy.orm import DeclarativeBase, RelationshipProperty
20
+
21
+ from ..schemas import BaseSchema
22
+ from ._async import AsyncRestView
23
+ from ._base import _annotate, get, put
24
+ from ._sync import RestView
25
+
26
+ #: Default page size used when the react-admin client does not send a `range`
27
+ #: query parameter. Override per-view via ``default_page_size``.
28
+ DEFAULT_REACT_ADMIN_PAGE_SIZE = 25
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Query parsing helpers (standalone, analogous to query/_impl.py)
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def parse_react_admin_sort(sort_raw: str | None) -> tuple[str, str] | None:
37
+ """
38
+ Parse a react-admin sort query parameter.
39
+
40
+ Expected: '["field","ASC"]' or '["field","DESC"]'
41
+ Returns (field, direction) or None if absent.
42
+ Raises HTTPException 400 on malformed input.
43
+ """
44
+ if not sort_raw:
45
+ return None
46
+ try:
47
+ parsed = json.loads(sort_raw)
48
+ except json.JSONDecodeError:
49
+ raise fastapi.HTTPException(400, "Invalid sort parameter: must be a JSON array")
50
+ if not isinstance(parsed, list) or len(parsed) != 2:
51
+ raise fastapi.HTTPException(
52
+ 400, "Invalid sort parameter: must be [field, direction]"
53
+ )
54
+ field, direction = parsed
55
+ if not isinstance(field, str) or direction not in ("ASC", "DESC"):
56
+ raise fastapi.HTTPException(
57
+ 400, "Invalid sort parameter: direction must be 'ASC' or 'DESC'"
58
+ )
59
+ return field, direction
60
+
61
+
62
+ def parse_react_admin_range(
63
+ range_raw: str | None, default_page_size: int = DEFAULT_REACT_ADMIN_PAGE_SIZE
64
+ ) -> tuple[int, int]:
65
+ """
66
+ Parse a react-admin range query parameter.
67
+
68
+ Expected: '[0,24]' (both inclusive).
69
+ Returns (start, end). Defaults to (0, default_page_size - 1) if absent.
70
+ Raises HTTPException 400 on malformed input.
71
+ """
72
+ if not range_raw:
73
+ return 0, default_page_size - 1
74
+ try:
75
+ parsed = json.loads(range_raw)
76
+ except json.JSONDecodeError:
77
+ raise fastapi.HTTPException(
78
+ 400, "Invalid range parameter: must be a JSON array"
79
+ )
80
+ if not isinstance(parsed, list) or len(parsed) != 2:
81
+ raise fastapi.HTTPException(
82
+ 400, "Invalid range parameter: must be [start, end]"
83
+ )
84
+ start, end = parsed
85
+ if not isinstance(start, int) or not isinstance(end, int):
86
+ raise fastapi.HTTPException(
87
+ 400, "Invalid range parameter: values must be integers"
88
+ )
89
+ return start, end
90
+
91
+
92
+ def parse_react_admin_filter(filter_raw: str | None) -> dict:
93
+ """
94
+ Parse a react-admin filter query parameter.
95
+
96
+ Expected: '{"field":"value"}' or '{"id":[1,2,3]}' for getMany.
97
+ Returns a dict. Defaults to {} if absent.
98
+ Raises HTTPException 400 on malformed input.
99
+ """
100
+ if not filter_raw:
101
+ return {}
102
+ try:
103
+ parsed = json.loads(filter_raw)
104
+ except json.JSONDecodeError:
105
+ raise fastapi.HTTPException(
106
+ 400, "Invalid filter parameter: must be a JSON object"
107
+ )
108
+ if not isinstance(parsed, dict):
109
+ raise fastapi.HTTPException(
110
+ 400, "Invalid filter parameter: must be a JSON object"
111
+ )
112
+ return parsed
113
+
114
+
115
+ def _resolve_column(
116
+ model: type[DeclarativeBase], schema_cls: Any, field_name: str
117
+ ) -> Any:
118
+ """
119
+ Resolve a field name to a SQLAlchemy column.
120
+
121
+ Checks model attributes directly, then falls back to schema alias resolution.
122
+ Raises HTTPException 400 if the field cannot be resolved.
123
+ """
124
+ col = getattr(model, field_name, None)
125
+ if col is not None:
126
+ if hasattr(col, "property") and isinstance(col.property, RelationshipProperty):
127
+ raise fastapi.HTTPException(
128
+ 400, f"Cannot sort or filter by relationship field: {field_name!r}"
129
+ )
130
+ return col
131
+
132
+ if schema_cls is not None:
133
+ for name, field in schema_cls.model_fields.items():
134
+ if field.alias == field_name:
135
+ col = getattr(model, name, None)
136
+ if col is not None:
137
+ return col
138
+
139
+ raise fastapi.HTTPException(400, f"Unknown filter field: {field_name!r}")
140
+
141
+
142
+ def _coerce_value(col: Any, value: Any) -> Any:
143
+ """Coerce a filter value to the column's Python type if needed.
144
+
145
+ Handles cases such as UUID strings that must be converted to uuid.UUID
146
+ objects before being passed to SQLAlchemy's type processor.
147
+ """
148
+ try:
149
+ py_type = col.type.python_type
150
+ except NotImplementedError:
151
+ return value
152
+ if not isinstance(value, py_type):
153
+ try:
154
+ return py_type(value)
155
+ except (ValueError, TypeError):
156
+ raise fastapi.HTTPException(
157
+ 400, f"Invalid filter value for {col.key!r}: {value!r}"
158
+ )
159
+ return value
160
+
161
+
162
+ def _apply_react_admin_filters(
163
+ query: sqlalchemy.Select,
164
+ model: type[DeclarativeBase],
165
+ schema_cls: Any,
166
+ filters: dict,
167
+ ) -> sqlalchemy.Select:
168
+ """Apply a react-admin filter dict to a select query."""
169
+ for key, value in filters.items():
170
+ col = _resolve_column(model, schema_cls, key)
171
+ if isinstance(value, list):
172
+ coerced = [_coerce_value(col, v) for v in value]
173
+ query = query.where(col.in_(coerced))
174
+ else:
175
+ query = query.where(col == _coerce_value(col, value))
176
+ return query
177
+
178
+
179
+ def apply_react_admin_query(
180
+ query: sqlalchemy.Select,
181
+ model: type[DeclarativeBase],
182
+ schema_cls: Any,
183
+ sort: tuple[str, str] | None,
184
+ start: int,
185
+ end: int,
186
+ filters: dict,
187
+ ) -> sqlalchemy.Select:
188
+ """
189
+ Apply filter, sort, and range (limit/offset) to a select query.
190
+
191
+ This is the main query transformation entry point, analogous to
192
+ :func:`fastapi_restly.query.apply_list_params` for the standard REST dialect.
193
+ """
194
+ query = _apply_react_admin_filters(query, model, schema_cls, filters)
195
+
196
+ if sort:
197
+ field, direction = sort
198
+ col = _resolve_column(model, schema_cls, field)
199
+ order_fn = sqlalchemy.desc if direction == "DESC" else sqlalchemy.asc
200
+ query = query.order_by(order_fn(col))
201
+ else:
202
+ id_col = getattr(model, "id", None)
203
+ if id_col is not None:
204
+ query = query.order_by(id_col)
205
+
206
+ query = query.limit(end - start + 1).offset(start)
207
+ return query
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Shared implementation mixin
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ class _ReactAdminViewProtocol(Protocol):
216
+ request: fastapi.Request
217
+ model: ClassVar[type[DeclarativeBase]]
218
+ schema: ClassVar[type[pydantic.BaseModel]]
219
+ update_schema: ClassVar[type[pydantic.BaseModel]]
220
+ id_type: ClassVar[type[Any]]
221
+ default_page_size: ClassVar[int | None]
222
+ listing: ClassVar[Any]
223
+ put: ClassVar[Any]
224
+
225
+ def get_react_admin_range_unit(self) -> str: ...
226
+ def get_relationship_loader_options(self) -> list[Any]: ...
227
+ def to_response_schema(self, obj: Any) -> pydantic.BaseModel: ...
228
+
229
+ @classmethod
230
+ def before_include_view(cls) -> None: ...
231
+
232
+
233
+ class _ReactAdminMixin:
234
+ """
235
+ Shared transport helpers for react-admin views.
236
+
237
+ This is an implementation detail shared by ReactAdminView and
238
+ AsyncReactAdminView. User-facing customization should happen by
239
+ subclassing one of those concrete view classes.
240
+
241
+ Set :attr:`default_page_size` on a subclass to change the implicit page
242
+ size used when the client does not send a ``range`` query parameter.
243
+
244
+ Type annotations on the mixin methods use ``_ReactAdminViewProtocol`` to
245
+ make the expected ``RestView`` surface explicit to static checkers.
246
+ """
247
+
248
+ #: Implicit page size when no ``range`` parameter is sent. Override per-view.
249
+ default_page_size: ClassVar[int | None] = DEFAULT_REACT_ADMIN_PAGE_SIZE
250
+
251
+ def get_react_admin_range_unit(self) -> str:
252
+ """Return the unit string used in the Content-Range header."""
253
+ return "items"
254
+
255
+ def _parse_react_admin_params(
256
+ self,
257
+ ) -> tuple[tuple[str, str] | None, tuple[int, int], dict]:
258
+ """Parse sort, range, and filter from the current request query string."""
259
+ view = cast(_ReactAdminViewProtocol, self)
260
+ params = view.request.query_params
261
+ default_page_size = view.default_page_size or DEFAULT_REACT_ADMIN_PAGE_SIZE
262
+ sort = parse_react_admin_sort(params.get("sort"))
263
+ start, end = parse_react_admin_range(
264
+ params.get("range"), default_page_size=default_page_size
265
+ )
266
+ filters = parse_react_admin_filter(params.get("filter"))
267
+ return sort, (start, end), filters
268
+
269
+ def _serialize_items(self, items: Sequence[Any]) -> list[dict]:
270
+ """Serialize ORM objects to JSON-compatible dicts via the view's response schema."""
271
+ view = cast(_ReactAdminViewProtocol, self)
272
+ return [
273
+ view.to_response_schema(obj).model_dump(mode="json", by_alias=True)
274
+ for obj in items
275
+ ]
276
+
277
+ def _build_react_admin_list_response(
278
+ self,
279
+ serialized_items: list[dict],
280
+ total: int,
281
+ start: int,
282
+ end: int,
283
+ ) -> fastapi.Response:
284
+ """Build a JSON array response with a Content-Range header."""
285
+ view = cast(_ReactAdminViewProtocol, self)
286
+ unit = view.get_react_admin_range_unit()
287
+ last = start + len(serialized_items) - 1 if serialized_items else start
288
+ return fastapi.Response(
289
+ content=json.dumps(serialized_items),
290
+ media_type="application/json",
291
+ headers={
292
+ "Content-Range": f"{unit} {start}-{last}/{total}",
293
+ "Access-Control-Expose-Headers": "Content-Range",
294
+ },
295
+ )
296
+
297
+ def _build_count_query(self, filters: dict) -> sqlalchemy.Select:
298
+ """Count query: filters only, no sort or pagination."""
299
+ view = cast(_ReactAdminViewProtocol, self)
300
+ base = sqlalchemy.select(view.model)
301
+ filtered = _apply_react_admin_filters(base, view.model, view.schema, filters)
302
+ return select(func.count()).select_from(filtered.subquery())
303
+
304
+ def _build_listing_query(
305
+ self,
306
+ sort: tuple[str, str] | None,
307
+ start: int,
308
+ end: int,
309
+ filters: dict,
310
+ ) -> sqlalchemy.Select:
311
+ """List query: filters, sort, and range applied."""
312
+ view = cast(_ReactAdminViewProtocol, self)
313
+ base = sqlalchemy.select(view.model)
314
+ loader_options = view.get_relationship_loader_options()
315
+ if loader_options:
316
+ base = base.options(*loader_options)
317
+ return apply_react_admin_query(
318
+ base, view.model, view.schema, sort, start, end, filters
319
+ )
320
+
321
+ @classmethod
322
+ def before_include_view(cls) -> None:
323
+ cast(Any, super()).before_include_view()
324
+ view_cls = cast(type[_ReactAdminViewProtocol], cls)
325
+ # Override the listing return annotation set by BaseRestView to Response,
326
+ # since we return a raw Response with Content-Range header.
327
+ if hasattr(view_cls, "listing"):
328
+ _annotate(view_cls.listing, return_annotation=fastapi.Response)
329
+ # Annotate the PUT handler with the same schema/types as PATCH.
330
+ if hasattr(view_cls, "put"):
331
+ _annotate(
332
+ view_cls.put,
333
+ return_annotation=view_cls.schema,
334
+ schema_obj=view_cls.update_schema,
335
+ id=view_cls.id_type,
336
+ )
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Concrete view classes
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ class AsyncReactAdminView(_ReactAdminMixin, AsyncRestView):
345
+ """
346
+ AsyncRestView that speaks the ra-data-simple-rest wire contract.
347
+
348
+ Use this instead of AsyncRestView when your frontend is react-admin
349
+ with ra-data-simple-rest.
350
+ """
351
+
352
+ @get("/")
353
+ async def listing(self) -> Any:
354
+ sort, (start, end), filters = self._parse_react_admin_params()
355
+ total = int(await self.session.scalar(self._build_count_query(filters)) or 0)
356
+ items = (
357
+ await self.session.scalars(
358
+ self._build_listing_query(sort, start, end, filters)
359
+ )
360
+ ).all()
361
+ return self._build_react_admin_list_response(
362
+ self._serialize_items(items), total, start, end
363
+ )
364
+
365
+ @put("/{id}")
366
+ async def put(self, id: Any, schema_obj: Any) -> Any:
367
+ obj = await self.perform_update(id, schema_obj)
368
+ return self.to_response_schema(obj)
369
+
370
+
371
+ class ReactAdminView(_ReactAdminMixin, RestView):
372
+ """
373
+ RestView that speaks the ra-data-simple-rest wire contract.
374
+
375
+ Use this instead of RestView when your frontend is react-admin
376
+ with ra-data-simple-rest.
377
+ """
378
+
379
+ @get("/")
380
+ def listing(self) -> Any:
381
+ sort, (start, end), filters = self._parse_react_admin_params()
382
+ total = int(self.session.scalar(self._build_count_query(filters)) or 0)
383
+ items = self.session.scalars(
384
+ self._build_listing_query(sort, start, end, filters)
385
+ ).all()
386
+ return self._build_react_admin_list_response(
387
+ self._serialize_items(items), total, start, end
388
+ )
389
+
390
+ @put("/{id}")
391
+ def put(self, id: Any, schema_obj: Any) -> Any:
392
+ obj = self.perform_update(id, schema_obj)
393
+ return self.to_response_schema(obj)