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

1""" 

2React Admin compatible views for fastapi-restly. 

3 

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""" 

11 

12import json 

13from collections.abc import Iterable 

14from dataclasses import dataclass 

15from typing import Any, ClassVar, Protocol, Sequence, cast 

16 

17import fastapi 

18import pydantic 

19import sqlalchemy 

20from sqlalchemy.orm import RelationshipProperty 

21 

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 

38 

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 

43 

44 

45@dataclass(frozen=True) 

46class _ReactAdminListParams: 

47 sort: tuple[str, str] | None 

48 start: int 

49 end: int 

50 filters: dict 

51 

52 

53class _ReactAdminQueryParams(pydantic.BaseModel): 

54 """The list params of a react-admin view: the raw strings of its three 

55 query keys.""" 

56 

57 sort: str | None = None 

58 range: str | None = None 

59 filter: str | None = None 

60 

61 

62# --------------------------------------------------------------------------- 

63# Query parsing helpers (standalone, analogous to query/_impl.py) 

64# --------------------------------------------------------------------------- 

65 

66 

67def parse_react_admin_sort(sort_raw: str | None) -> tuple[str, str] | None: 

68 """ 

69 Parse a react-admin sort query parameter. 

70 

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 

91 

92 

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. 

98 

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 

121 

122 

123def parse_react_admin_filter(filter_raw: str | None) -> dict: 

124 """ 

125 Parse a react-admin filter query parameter. 

126 

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 

144 

145 

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. 

149 

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. 

156 

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 

167 

168 if resolved_name is None: 

169 raise BadQueryParam(f"Unknown filter field: {field_name!r}") 

170 

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 

179 

180 

181def _coerce_value(col: Any, value: Any) -> Any: 

182 """Coerce a filter value to the column's Python type if needed. 

183 

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 

199 

200 

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 

213 

214 

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. 

226 

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) 

231 

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) 

240 

241 query = query.limit(end - start + 1).offset(start) 

242 return query 

243 

244 

245# --------------------------------------------------------------------------- 

246# Shared implementation mixin 

247# --------------------------------------------------------------------------- 

248 

249 

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] 

260 

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: ... 

264 

265 @classmethod 

266 def before_include_view(cls) -> None: ... 

267 

268 

269class _ReactAdminMixin: 

270 """ 

271 Shared transport helpers for react-admin views. 

272 

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. 

276 

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. 

283 

284 Type annotations on the mixin methods use ``_ReactAdminViewProtocol`` to 

285 make the expected ``RestView`` surface explicit to static checkers. 

286 """ 

287 

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 ) 

294 

295 def get_react_admin_range_unit(self) -> str: 

296 """Return the unit string used in the Content-Range header.""" 

297 return "items" 

298 

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 

304 

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 ) 

311 

312 def _reject_unknown_query_params(self) -> None: 

313 """Reject any query key outside the react-admin contract. 

314 

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 ) 

327 

328 def _parse_react_admin_params(self) -> _ReactAdminListParams: 

329 """Parse sort, range, and filter from the current request query string. 

330 

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) 

338 

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() 

344 

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 

353 

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) 

390 

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 ) 

406 

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 ] 

417 

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 ) 

433 

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 ) 

444 

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 ) 

493 

494 

495# --------------------------------------------------------------------------- 

496# Concrete view classes 

497# --------------------------------------------------------------------------- 

498 

499 

500class AsyncReactAdminView(_ReactAdminMixin, AsyncRestView): 

501 """ 

502 AsyncRestView that speaks the ra-data-simple-rest wire contract. 

503 

504 Use this instead of AsyncRestView when your frontend is react-admin 

505 with ra-data-simple-rest. 

506 """ 

507 

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) 

512 

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) 

518 

519 

520class ReactAdminView(_ReactAdminMixin, RestView): 

521 """ 

522 RestView that speaks the ra-data-simple-rest wire contract. 

523 

524 Use this instead of RestView when your frontend is react-admin 

525 with ra-data-simple-rest. 

526 """ 

527 

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) 

532 

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)