Coverage for fastapi_restly / views / _react_admin.py: 96%
213 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
1"""
2React Admin compatible views for fastapi-restly.
4Implements 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"""
12import json
13from collections.abc import Iterable
14from dataclasses import dataclass
15from typing import Any, ClassVar, Protocol, Sequence, cast
17import fastapi
18import pydantic
19import sqlalchemy
20from sqlalchemy.orm import RelationshipProperty
22from .._pagination import _DEFAULT_PAGINATION, NoPagination, NumberedPagination
23from ..exc import BadQueryParam, RestlyConfigurationError
24from ..query._shared import _append_pk_tiebreak
25from ._async import AsyncRestView
26from ._base import (
27 ListResult,
28 ResponseShape,
29 _annotate,
30 _schema_response,
31 _typed_id_route,
32 _view_id_type,
33 get,
34 put,
35 reject_unknown_query_keys,
36)
37from ._sync import RestView
39#: Default page size used when the react-admin client does not send a `range`
40#: query parameter. Override per view with
41#: ``pagination = fr.NumberedPagination(default_page_size=...)``.
42DEFAULT_REACT_ADMIN_PAGE_SIZE = 25
45@dataclass(frozen=True)
46class _ReactAdminListParams:
47 sort: tuple[str, str] | None
48 start: int
49 end: int
50 filters: dict
53class _ReactAdminQueryParams(pydantic.BaseModel):
54 """The list params of a react-admin view: the raw strings of its three
55 query keys."""
57 sort: str | None = None
58 range: str | None = None
59 filter: str | None = None
62# ---------------------------------------------------------------------------
63# Query parsing helpers (standalone, analogous to query/_impl.py)
64# ---------------------------------------------------------------------------
67def parse_react_admin_sort(sort_raw: str | None) -> tuple[str, str] | None:
68 """
69 Parse a react-admin sort query parameter.
71 Expected: '["field","ASC"]' or '["field","DESC"]'
72 Returns (field, direction) or None if absent.
73 Raises HTTPException 400 on malformed input.
74 """
75 if not sort_raw:
76 return None
77 try:
78 parsed = json.loads(sort_raw)
79 except json.JSONDecodeError:
80 raise BadQueryParam("Invalid sort parameter: must be a JSON array")
81 if not isinstance(parsed, list) or len(parsed) != 2:
82 raise fastapi.HTTPException(
83 400, "Invalid sort parameter: must be [field, direction]"
84 )
85 field, direction = parsed
86 if not isinstance(field, str) or direction not in ("ASC", "DESC"):
87 raise fastapi.HTTPException(
88 400, "Invalid sort parameter: direction must be 'ASC' or 'DESC'"
89 )
90 return field, direction
93def parse_react_admin_range(
94 range_raw: str | None, default_page_size: int = DEFAULT_REACT_ADMIN_PAGE_SIZE
95) -> tuple[int, int]:
96 """
97 Parse a react-admin range query parameter.
99 Expected: '[0,24]' (both inclusive).
100 Returns (start, end). Defaults to (0, default_page_size - 1) if absent.
101 Raises HTTPException 400 on malformed input.
102 """
103 if not range_raw:
104 return 0, default_page_size - 1
105 try:
106 parsed = json.loads(range_raw)
107 except json.JSONDecodeError:
108 raise fastapi.HTTPException(
109 400, "Invalid range parameter: must be a JSON array"
110 )
111 if not isinstance(parsed, list) or len(parsed) != 2:
112 raise fastapi.HTTPException(
113 400, "Invalid range parameter: must be [start, end]"
114 )
115 start, end = parsed
116 if not isinstance(start, int) or not isinstance(end, int):
117 raise fastapi.HTTPException(
118 400, "Invalid range parameter: values must be integers"
119 )
120 return start, end
123def parse_react_admin_filter(filter_raw: str | None) -> dict:
124 """
125 Parse a react-admin filter query parameter.
127 Expected: '{"field":"value"}' or '{"id":[1,2,3]}' for getMany.
128 Returns a dict. Defaults to {} if absent.
129 Raises HTTPException 400 on malformed input.
130 """
131 if not filter_raw:
132 return {}
133 try:
134 parsed = json.loads(filter_raw)
135 except json.JSONDecodeError:
136 raise fastapi.HTTPException(
137 400, "Invalid filter parameter: must be a JSON object"
138 )
139 if not isinstance(parsed, dict):
140 raise fastapi.HTTPException(
141 400, "Invalid filter parameter: must be a JSON object"
142 )
143 return parsed
146def _resolve_column(model: type[Any], schema_cls: Any, field_name: str) -> Any:
147 """
148 Resolve a PUBLIC schema field name (or alias) to its SQLAlchemy column.
150 Strict: only fields that the response class sends may be filtered or
151 sorted. A column that exists on the model but is omitted from the response
152 class (or never sent: WriteOnly or ``exclude=True``) is rejected, so the
153 list endpoint cannot be used as an oracle to filter/sort on -- and thereby
154 probe -- hidden data. Mirrors the standard REST dialect's schema-driven
155 resolution.
157 Raises HTTPException 400 if the field is not a public, filterable schema field.
158 """
159 resolved_name: str | None = None
160 if schema_cls is not None: 160 ↛ 168line 160 didn't jump to line 168 because the condition on line 160 was always true
161 for name, field in schema_cls.model_fields.items():
162 if field.exclude is True:
163 continue
164 if name == field_name or field.alias == field_name:
165 resolved_name = name
166 break
168 if resolved_name is None:
169 raise BadQueryParam(f"Unknown filter field: {field_name!r}")
171 col = getattr(model, resolved_name, None)
172 if col is None: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 raise BadQueryParam(f"Unknown filter field: {field_name!r}")
174 if hasattr(col, "property") and isinstance(col.property, RelationshipProperty):
175 raise fastapi.HTTPException(
176 400, f"Cannot sort or filter by relationship field: {field_name!r}"
177 )
178 return col
181def _coerce_value(col: Any, value: Any) -> Any:
182 """Coerce a filter value to the column's Python type if needed.
184 Handles cases such as UUID strings that must be converted to uuid.UUID
185 objects before being passed to SQLAlchemy's type processor.
186 """
187 try:
188 py_type = col.type.python_type
189 except NotImplementedError:
190 return value
191 if not isinstance(value, py_type):
192 try:
193 return py_type(value)
194 except (ValueError, TypeError):
195 raise fastapi.HTTPException(
196 400, f"Invalid filter value for {col.key!r}: {value!r}"
197 )
198 return value
201def _apply_react_admin_filters(
202 query: sqlalchemy.Select, model: type[Any], schema_cls: Any, filters: dict
203) -> sqlalchemy.Select:
204 """Apply a react-admin filter dict to a select query."""
205 for key, value in filters.items():
206 col = _resolve_column(model, schema_cls, key)
207 if isinstance(value, list):
208 coerced = [_coerce_value(col, v) for v in value]
209 query = query.where(col.in_(coerced))
210 else:
211 query = query.where(col == _coerce_value(col, value))
212 return query
215def apply_react_admin_query(
216 query: sqlalchemy.Select,
217 model: type[Any],
218 schema_cls: Any,
219 sort: tuple[str, str] | None,
220 start: int,
221 end: int,
222 filters: dict,
223) -> sqlalchemy.Select:
224 """
225 Apply filter, sort, and range (limit/offset) to a select query.
227 This is the main query transformation entry point, analogous to
228 :func:`fastapi_restly.query.apply_list_params` for the standard REST dialect.
229 """
230 query = _apply_react_admin_filters(query, model, schema_cls, filters)
232 sorted_on = []
233 if sort:
234 field, direction = sort
235 col = _resolve_column(model, schema_cls, field)
236 order_fn = sqlalchemy.desc if direction == "DESC" else sqlalchemy.asc
237 query = query.order_by(order_fn(col))
238 sorted_on.append(col)
239 query = _append_pk_tiebreak(query, model, sorted_on)
241 query = query.limit(end - start + 1).offset(start)
242 return query
245# ---------------------------------------------------------------------------
246# Shared implementation mixin
247# ---------------------------------------------------------------------------
250class _ReactAdminViewProtocol(Protocol):
251 request: fastapi.Request
252 model: ClassVar[type[Any]]
253 schema_response: ClassVar[type[pydantic.BaseModel]]
254 schema_update: ClassVar[type[pydantic.BaseModel]]
255 schema_list_params: ClassVar[type[pydantic.BaseModel]]
256 pagination: ClassVar[NumberedPagination | NoPagination | None]
257 extra_query_params: ClassVar[Iterable[str]]
258 get_many_endpoint: ClassVar[Any]
259 put: ClassVar[Any]
261 def get_react_admin_range_unit(self) -> str: ...
262 def get_relationship_loader_options(self) -> list[Any]: ...
263 def _single_response(self, obj: Any) -> pydantic.BaseModel: ...
265 @classmethod
266 def before_include_view(cls) -> None: ...
269class _ReactAdminMixin:
270 """
271 Shared transport helpers for react-admin views.
273 This is an implementation detail shared by ReactAdminView and
274 AsyncReactAdminView. User-facing customization should happen by
275 subclassing one of those concrete view classes.
277 Set ``pagination = fr.NumberedPagination(default_page_size=...)`` on a
278 subclass to change the implicit page size used when the client does not
279 send a ``range`` query parameter. Its ``max_page_size`` caps the rows one
280 ``range`` asks for. The other pagination settings do not apply:
281 react-admin pages with ``range`` and reports the total in the
282 ``Content-Range`` header.
284 Type annotations on the mixin methods use ``_ReactAdminViewProtocol`` to
285 make the expected ``RestView`` surface explicit to static checkers.
286 """
288 #: Only ``default_page_size`` and ``max_page_size`` apply: the implicit
289 #: page size when no ``range`` parameter is sent, and the most rows one
290 #: ``range`` may ask for. Override per view.
291 pagination: ClassVar[NumberedPagination | NoPagination | None] = NumberedPagination(
292 default_page_size=DEFAULT_REACT_ADMIN_PAGE_SIZE
293 )
295 def get_react_admin_range_unit(self) -> str:
296 """Return the unit string used in the Content-Range header."""
297 return "items"
299 @classmethod
300 def _check_declared_schema_list_params(cls) -> None:
301 # react-admin reads its own sort/range/filter grammar, not the
302 # pagination's page parameters
303 return None
305 #: The whole react-admin query contract. The keys of the default list
306 #: params (``page``, ``page_size``, per-field filters) are not part of
307 #: this dialect, so they are unknown keys here like any other typo.
308 react_admin_query_params: ClassVar[frozenset[str]] = frozenset(
309 {"sort", "range", "filter"}
310 )
312 def _reject_unknown_query_params(self) -> None:
313 """Reject any query key outside the react-admin contract.
315 Overrides the default list params guard: this dialect takes three
316 JSON-encoded keys, not the schema-derived grammar, so the inherited
317 allow-list would wave through ``?page=2`` while still missing a typo.
318 ``extra_query_params`` widens this the same way it widens the default.
319 """
320 view = cast(_ReactAdminViewProtocol, self)
321 request = getattr(view, "request", None)
322 if request is None: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true
323 return
324 reject_unknown_query_keys(
325 request, set(self.react_admin_query_params) | set(view.extra_query_params)
326 )
328 def _parse_react_admin_params(self) -> _ReactAdminListParams:
329 """Parse sort, range, and filter from the current request query string.
331 The unknown-key guard runs here rather than through the registration
332 wrapper: the react-admin list endpoint declares no ``list_params``
333 argument, so nothing else would run it.
334 """
335 view = cast(_ReactAdminViewProtocol, self)
336 self._reject_unknown_query_params()
337 return self._coerce_react_admin_params(view.request.query_params)
339 def _coerce_react_admin_params(self, params: Any) -> _ReactAdminListParams:
340 if isinstance(params, _ReactAdminListParams):
341 return params
342 if isinstance(params, pydantic.BaseModel):
343 params = params.model_dump()
345 view = cast(_ReactAdminViewProtocol, self)
346 # ``pagination`` is required on react-admin (see before_include_view),
347 # and NumberedPagination checks default_page_size when it is created.
348 pagination = cast(NumberedPagination, view.pagination)
349 default_page_size = pagination.default_page_size
350 sort_raw = params.get("sort") if hasattr(params, "get") else None
351 range_raw = params.get("range") if hasattr(params, "get") else None
352 filter_raw = params.get("filter") if hasattr(params, "get") else None
354 sort = (
355 tuple(sort_raw)
356 if isinstance(sort_raw, list | tuple)
357 else parse_react_admin_sort(sort_raw)
358 )
359 if isinstance(range_raw, list | tuple):
360 if len(range_raw) != 2:
361 raise fastapi.HTTPException(
362 400, "Invalid range parameter: must be [start, end]"
363 )
364 start, end = range_raw
365 if not isinstance(start, int) or not isinstance(end, int): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 raise fastapi.HTTPException(
367 400, "Invalid range parameter: values must be integers"
368 )
369 else:
370 start, end = parse_react_admin_range(
371 range_raw, default_page_size=default_page_size
372 )
373 filters = (
374 filter_raw
375 if isinstance(filter_raw, dict)
376 else parse_react_admin_filter(filter_raw)
377 )
378 if start < 0 or end < start:
379 raise fastapi.HTTPException(
380 400,
381 "Invalid range parameter: must be [start, end] with 0 <= start <= end",
382 )
383 if end - start + 1 > pagination.max_page_size:
384 raise fastapi.HTTPException(
385 400,
386 "Invalid range parameter: a range can ask for at most "
387 f"{pagination.max_page_size} rows",
388 )
389 return _ReactAdminListParams(sort=sort, start=start, end=end, filters=filters)
391 def apply_list_params(
392 self, query: sqlalchemy.Select, list_params: Any
393 ) -> sqlalchemy.Select:
394 """Apply the react-admin list grammar to the scoped base query."""
395 view = cast(_ReactAdminViewProtocol, self)
396 params = self._coerce_react_admin_params(list_params)
397 return apply_react_admin_query(
398 query,
399 view.model,
400 _schema_response(type(self)),
401 params.sort,
402 params.start,
403 params.end,
404 params.filters,
405 )
407 def _serialize_items(self, items: Sequence[Any]) -> list[dict]:
408 """Serialize ORM objects to JSON-compatible dicts via the view's
409 response class, as the other routes do: an instance of the view's
410 schema from a ``to_single_response`` override goes out without its
411 WriteOnly fields."""
412 view = cast(_ReactAdminViewProtocol, self)
413 return [
414 view._single_response(obj).model_dump(mode="json", by_alias=True)
415 for obj in items
416 ]
418 def _build_react_admin_list_response(
419 self, serialized_items: list[dict], total: int, start: int, end: int
420 ) -> fastapi.Response:
421 """Build a JSON array response with a Content-Range header."""
422 view = cast(_ReactAdminViewProtocol, self)
423 unit = view.get_react_admin_range_unit()
424 last = start + len(serialized_items) - 1 if serialized_items else start
425 return fastapi.Response(
426 content=json.dumps(serialized_items),
427 media_type="application/json",
428 headers={
429 "Content-Range": f"{unit} {start}-{last}/{total}",
430 "Access-Control-Expose-Headers": "Content-Range",
431 },
432 )
434 def to_list_response(self, list_result: ListResult[Any]) -> fastapi.Response:
435 """Build the react-admin list response: a JSON array with a
436 ``Content-Range`` header."""
437 params = self._coerce_react_admin_params(list_result.list_params)
438 return self._build_react_admin_list_response(
439 self._serialize_items(list_result.objects),
440 list_result.total_count or 0,
441 params.start,
442 params.end,
443 )
445 @classmethod
446 def before_include_view(cls) -> None:
447 view_cls = cast(type[_ReactAdminViewProtocol], cls)
448 # React-admin reports its total through the Content-Range header, which
449 # needs the count query -- gated on ``pagination``. Disabling it would
450 # report a total of 0 and silently break the client's paging.
451 pagination = view_cls.pagination
452 if not isinstance(pagination, NumberedPagination):
453 raise RestlyConfigurationError(
454 f"{cls.__name__}: a react-admin view cannot disable pagination; "
455 "'pagination' must stay a fr.NumberedPagination so the "
456 "Content-Range total is counted."
457 )
458 # react-admin reads the two page sizes; any other setting would be
459 # silently ignored
460 ignored = [
461 name
462 for name in (
463 "max_page",
464 "page_query_param",
465 "page_size_query_param",
466 "envelope",
467 )
468 if getattr(pagination, name) != getattr(_DEFAULT_PAGINATION, name)
469 ]
470 if ignored:
471 raise RestlyConfigurationError(
472 f"{cls.__name__}: a react-admin view uses only "
473 "pagination.default_page_size and max_page_size. It pages with "
474 "the 'range' parameter and reports the total in the "
475 "Content-Range header, so these settings do not apply: "
476 f"{', '.join(ignored)}."
477 )
478 if "schema_list_params" not in cls.__dict__: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true
479 view_cls.schema_list_params = _ReactAdminQueryParams
480 cast(Any, super()).before_include_view()
481 # Override the list return annotation set by BaseRestView to Response,
482 # since we return a raw Response with Content-Range header.
483 if hasattr(view_cls, "get_many_endpoint"): 483 ↛ 486line 483 didn't jump to line 486 because the condition on line 483 was always true
484 _annotate(view_cls.get_many_endpoint, return_annotation=fastapi.Response)
485 # Annotate the PUT handler with the same schema/types as PATCH.
486 if hasattr(view_cls, "put"): 486 ↛ exitline 486 didn't return from function 'before_include_view' because the condition on line 486 was always true
487 _annotate(
488 view_cls.put,
489 return_annotation=view_cls.schema_response,
490 schema_obj=view_cls.schema_update,
491 id=_view_id_type(view_cls),
492 )
495# ---------------------------------------------------------------------------
496# Concrete view classes
497# ---------------------------------------------------------------------------
500class AsyncReactAdminView(_ReactAdminMixin, AsyncRestView):
501 """
502 AsyncRestView that speaks the ra-data-simple-rest wire contract.
504 Use this instead of AsyncRestView when your frontend is react-admin
505 with ra-data-simple-rest.
506 """
508 @get("/")
509 async def get_many_endpoint(self) -> Any:
510 result = await self.handle_get_many(self._parse_react_admin_params())
511 return self.to_response(result, ResponseShape.LIST)
513 @_typed_id_route
514 @put("/{id}")
515 async def put(self, id: Any, schema_obj: Any) -> Any:
516 obj = await self.handle_update(id, schema_obj)
517 return self.to_response(obj)
520class ReactAdminView(_ReactAdminMixin, RestView):
521 """
522 RestView that speaks the ra-data-simple-rest wire contract.
524 Use this instead of RestView when your frontend is react-admin
525 with ra-data-simple-rest.
526 """
528 @get("/")
529 def get_many_endpoint(self) -> Any:
530 result = self.handle_get_many(self._parse_react_admin_params())
531 return self.to_response(result, ResponseShape.LIST)
533 @_typed_id_route
534 @put("/{id}")
535 def put(self, id: Any, schema_obj: Any) -> Any:
536 obj = self.handle_update(id, schema_obj)
537 return self.to_response(obj)