Coverage for fastapi_restly / views / _base.py: 94%

1196 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-09 18:41 +0000

1""" 

2This module provides a framework for class-based views on SQLAlchemy models. 

3 

4View class: 

5This class is used to create a collection of endpoints that share an 

6APIRouter (created when calling `include_view()`) and dependencies 

7as class attributes. It uses the same mechanics as the class based 

8view decorator from fastapi-utils. 

9(https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/) 

10 

11AsyncRestView: 

12Provides default reading and writing functions on the database using 

13SQLAlchemy models. 

14""" 

15 

16import dataclasses 

17import functools 

18import inspect 

19import re 

20import sys 

21import types 

22import warnings 

23from collections.abc import Mapping, Set 

24from enum import Enum 

25from typing import ( 

26 TYPE_CHECKING, 

27 Annotated, 

28 Any, 

29 Callable, 

30 ClassVar, 

31 Generic, 

32 Iterable, 

33 Iterator, 

34 Protocol, 

35 Sequence, 

36 Union, 

37 cast, 

38 get_args, 

39 get_origin, 

40 get_type_hints, 

41 overload, 

42) 

43from uuid import UUID 

44 

45import fastapi 

46import pydantic 

47from fastapi import BackgroundTasks, Request, Response, WebSocket 

48from fastapi.datastructures import Default 

49from fastapi.exceptions import RequestValidationError 

50from fastapi.openapi.models import APIKeyIn 

51from fastapi.params import Body as _BodyMarker 

52from fastapi.params import Depends as _DependsMarker 

53from fastapi.params import Param as _ParamMarker 

54from fastapi.security.api_key import APIKeyBase 

55from pydantic.fields import FieldInfo 

56from sqlalchemy import JSON as _JSONType 

57from sqlalchemy import ColumnElement, Select 

58from sqlalchemy import inspect as sa_inspect 

59from sqlalchemy import select as sa_select 

60from sqlalchemy.orm import selectinload 

61from sqlalchemy.orm.attributes import del_attribute 

62from starlette.datastructures import QueryParams 

63from typing_extensions import TypeVar 

64 

65from .._exception_handlers import register_default_exception_handlers 

66 

67if sys.version_info >= (3, 14): 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true

68 import annotationlib 

69from ..clauses import ( 

70 UNSCOPED, 

71 Unscoped, 

72 WhereClause, 

73 all_of, 

74 apply_clauses, 

75 where_clause, 

76) 

77from ..clauses._scopes import _default_scope 

78 

79#: A per-read scope: ``None`` for the view's own, a clause that replaces it 

80#: for that read, or ``fr.clauses.UNSCOPED``. 

81ReadScope = WhereClause | Unscoped | None 

82 

83# A per-read narrowing filter: a SQLAlchemy boolean expression, or anything 

84# all_of takes. Private: only the final list handler accepts one, so no 

85# override spells it. 

86_ReadWhere = ColumnElement[bool] | WhereClause | Unscoped | None 

87from .._mapping import is_mapped_class, is_mapped_instance 

88from .._pagination import ( 

89 _DEFAULT_PAGINATION, 

90 DEFAULT_PAGE_SIZE, 

91 NoPagination, 

92 NumberedPagination, 

93 _build_envelope, 

94 _numbered_page_info, 

95 _unpaginated_page_info, 

96) 

97from .._routes import matched_route 

98from ..db._globals import _fr_globals 

99from ..exc import RestlyConfigurationError, RestlyMisuseWarning 

100from ..objects import snapshot as _object_snapshot 

101from ..query import derive_schema_list_params 

102from ..query._impl import _UNKNOWN, _created_with, _query_settings 

103from ..schemas import ( 

104 BaseSchema, 

105 IDSchema, 

106 derive_schema_list_response, 

107 derive_schema_response, 

108) 

109from ..schemas._base import ( 

110 _as_response, 

111 _model_id_type, 

112 _reject_buried_markers, 

113 _response_adapter, 

114 _unwrap_optional_annotation, 

115 create_model_with_optional_fields, 

116 create_model_without_read_only_fields, 

117 get_writable_inputs, 

118 is_readonly_field, 

119 is_reference_field, 

120 reference_origin_and_target, 

121) 

122from ..schemas._generator import derive_schema 

123from ._openapi import _register_for_resource_ref 

124 

125# Unbound: a mapped class need not subclass DeclarativeBase (a SQLModel table, 

126# an imperative mapping). ``BaseRestView`` checks the mapping at class definition. 

127ModelT = TypeVar("ModelT", default=Any) 

128ResponseSchemaT = TypeVar( 

129 "ResponseSchemaT", bound=pydantic.BaseModel, default=BaseSchema 

130) 

131CreateSchemaT = TypeVar( 

132 "CreateSchemaT", bound=pydantic.BaseModel, default=pydantic.BaseModel 

133) 

134UpdateSchemaT = TypeVar( 

135 "UpdateSchemaT", bound=pydantic.BaseModel, default=pydantic.BaseModel 

136) 

137IdT = TypeVar("IdT", default=int) 

138 

139 

140@dataclasses.dataclass(frozen=True) 

141class ListResult(Generic[ModelT]): 

142 """Result returned by ``get_many`` before HTTP response formatting. 

143 

144 ``total_count`` is ``None`` for a list without pagination, which does not 

145 run the count query. ``list_params`` holds the list params that 

146 ``get_many`` received, so :meth:`BaseRestView.to_list_response` can read 

147 the page from them. 

148 """ 

149 

150 objects: Sequence[ModelT] 

151 total_count: int | None = None 

152 list_params: Any = None 

153 

154 

155class ViewRoute(str, Enum): 

156 """Default CRUD route names that can be referenced by view options. 

157 

158 Values are the endpoint method names so ``exclude_routes`` can drop them. 

159 """ 

160 

161 GET_MANY = "get_many_endpoint" 

162 GET_ONE = "get_one_endpoint" 

163 CREATE = "create_endpoint" 

164 UPDATE = "update_endpoint" 

165 DELETE = "delete_endpoint" 

166 

167 

168class ResponseShape(str, Enum): 

169 """The wire shape an endpoint method asks :meth:`BaseRestView.to_response` to 

170 produce. 

171 

172 This is separate from write-action names such as ``"publish"``. Endpoint 

173 methods choose one of these three response shapes; custom actions remain an 

174 open string namespace. 

175 """ 

176 

177 SINGLE = "single" # one serialized object 

178 LIST = "list" # a ListResult -> the list envelope 

179 EMPTY = "empty" # 204 No Content 

180 

181 

182class Action: 

183 """Canonical CRUD action names passed to ``authorize`` / ``before_action_commit`` 

184 / ``after_action_commit``. 

185 

186 This is a constants class, not an ``Enum``: custom actions and mixins add 

187 their own names. Use constants for typo checking at import time. 

188 """ 

189 

190 GET_MANY = "get_many" 

191 GET_ONE = "get_one" 

192 CREATE = "create" 

193 UPDATE = "update" 

194 DELETE = "delete" 

195 

196 

197def _accepts_init_kwarg(model_cls: type, attr_name: str) -> bool: 

198 """Return True if attr_name can be passed as a keyword argument to model_cls.__init__. 

199 

200 Non-dataclass models (DeclarativeBase subclasses using mapped_column) accept all 

201 kwargs. Dataclass-based models may have fields with init=False, in which case 

202 passing the attribute to __init__ raises TypeError. 

203 """ 

204 if not dataclasses.is_dataclass(model_cls): 

205 return True 

206 dc_fields = {f.name: f for f in dataclasses.fields(model_cls)} 

207 return attr_name not in dc_fields or dc_fields[attr_name].init 

208 

209 

210def _requires_init_kwarg(model_cls: type, attr_name: str) -> bool: 

211 if not dataclasses.is_dataclass(model_cls): 

212 return False 

213 dc_fields = {f.name: f for f in dataclasses.fields(model_cls)} 

214 field = dc_fields.get(attr_name) 

215 if field is None or not field.init: 

216 return False 

217 return ( 

218 field.default is dataclasses.MISSING 

219 and field.default_factory is dataclasses.MISSING 

220 ) 

221 

222 

223@dataclasses.dataclass 

224class _CreatePlan: 

225 kwargs: dict[str, Any] 

226 post_assignments: dict[str, Any] 

227 # Many-to-one relationships the input did not supply. 

228 unsupplied_relationships: list[str] = dataclasses.field(default_factory=list) 

229 

230 

231class _HasID(Protocol): 

232 """Anything with an ``id`` attribute. By framework convention, primary 

233 keys are named ``id``; ``IDBase`` formalizes this but isn't required.""" 

234 

235 id: Any 

236 

237 

238def _has_model_attr(model_cls: type[Any], attr_name: str) -> bool: 

239 return hasattr(model_cls, attr_name) 

240 

241 

242def _get_relationship_property(model_cls: type[Any], relation_name: str) -> Any | None: 

243 try: 

244 mapper = sa_inspect(model_cls) 

245 except Exception: 

246 return None 

247 return mapper.relationships.get(relation_name) 

248 

249 

250def _column_attr_name(mapper: Any, column: Any) -> str | None: 

251 """Map a Column object back to its mapped attribute name. 

252 

253 ``Mapper.columns`` is keyed by the mapped attribute name, but a Column's own 

254 ``key`` is the DB column name; the two differ when a column is declared 

255 ``mapped_column("db_name", ...)``. Reference routing works in attribute-name 

256 space (kwargs and ``setattr``), so callers need the attribute name, not the 

257 column key. Compared by identity because ``Column.__eq__`` builds a SQL 

258 expression rather than a boolean. 

259 """ 

260 for attr_name, mapped_column in mapper.columns.items(): 260 ↛ 263line 260 didn't jump to line 263 because the loop on line 260 didn't complete

261 if mapped_column is column: 

262 return attr_name 

263 return None 

264 

265 

266def _get_unambiguous_local_fk_name( 

267 model_cls: type[Any], relation_name: str 

268) -> str | None: 

269 relationship_property = _get_relationship_property(model_cls, relation_name) 

270 if relationship_property is None: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 return None 

272 

273 if getattr(relationship_property.direction, "name", None) != "MANYTOONE": 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 return None 

275 

276 local_columns = list(relationship_property.local_columns) 

277 if len(local_columns) != 1: 

278 column_names = ", ".join(column.key for column in local_columns) or "<none>" 

279 raise ValueError( 

280 f"Cannot infer a single local FK for relationship " 

281 f"{model_cls.__name__}.{relation_name}; found {column_names}. " 

282 "Use an explicit custom handler for this relationship." 

283 ) 

284 # Return the mapped attribute name (what kwargs/setattr need), which differs 

285 # from the column's DB-name ``key`` under ``mapped_column("db_name", ...)``. 

286 try: 

287 mapper = sa_inspect(model_cls) 

288 except Exception: 

289 return local_columns[0].key 

290 return _column_attr_name(mapper, local_columns[0]) or local_columns[0].key 

291 

292 

293def _many_to_one_names(model_cls: type[Any]) -> list[str]: 

294 try: 

295 mapper = sa_inspect(model_cls) 

296 except Exception: 

297 return [] 

298 return [ 

299 relationship_property.key 

300 for relationship_property in mapper.relationships 

301 if getattr(relationship_property.direction, "name", None) == "MANYTOONE" 

302 ] 

303 

304 

305def _relationship_name_for_fk(model_cls: type[Any], fk_attr_name: str) -> str | None: 

306 """Reverse of :func:`_get_unambiguous_local_fk_name`. 

307 

308 Given a scalar FK column's attribute name, return the single many-to-one 

309 relationship that uses it as its local column, or ``None`` when there is no 

310 such relationship or more than one (ambiguous, so no pairing is guessed). 

311 This is how a FK reference field finds its partner relationship without 

312 relying on the ``<relation>_id`` naming convention. 

313 """ 

314 try: 

315 mapper = sa_inspect(model_cls) 

316 except Exception: 

317 return None 

318 fk_column = mapper.columns.get(fk_attr_name) 

319 if fk_column is None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 return None 

321 # Match by Column identity, not by ``.key``: the column's key is the DB name, 

322 # which differs from ``fk_attr_name`` under ``mapped_column("db_name", ...)``. 

323 # (``is`` also avoids ``Column.__eq__``'s SQL-expression overload.) 

324 matches = [ 

325 relationship_property.key 

326 for relationship_property in mapper.relationships 

327 if getattr(relationship_property.direction, "name", None) == "MANYTOONE" 

328 and any(column is fk_column for column in relationship_property.local_columns) 

329 ] 

330 return matches[0] if len(matches) == 1 else None 

331 

332 

333def _is_mapped_column(model_cls: type[Any], field_name: str) -> bool: 

334 """True if ``field_name`` maps to a (scalar) column on ``model_cls``. 

335 

336 This is the target for writing a resolved reference's raw id. Decided by the 

337 mapper rather than the field name, so a FK column works under any name, not 

338 only ``<relation>_id``. 

339 """ 

340 try: 

341 mapper = sa_inspect(model_cls) 

342 except Exception: 

343 return False 

344 return field_name in mapper.columns 

345 

346 

347def _is_json_column(model_cls: type[Any], field_name: str) -> bool: 

348 """True if ``field_name`` maps to a plain ``JSON`` column (``JSONB`` too). 

349 

350 A ``TypeDecorator`` over ``JSON`` is not a ``JSON`` instance and so is not 

351 one: it has its own bind processor, which may well want the object as it 

352 stands. 

353 """ 

354 try: 

355 mapper = sa_inspect(model_cls) 

356 except Exception: 

357 return False 

358 column = mapper.columns.get(field_name) 

359 return column is not None and isinstance(column.type, _JSONType) 

360 

361 

362def _json_ready(model_cls: type[Any], field_name: str, value: Any) -> Any: 

363 """Dump pydantic models on their way into a JSON column. 

364 

365 A schema field typed as a nested model validates to a model instance, and 

366 a JSON column binds through ``json.dumps``, which cannot take one. Restly 

367 owns the schema-to-model translation, so it does the dump here rather than 

368 letting the driver fail at flush with the model in the bind parameters. 

369 """ 

370 if isinstance(value, pydantic.BaseModel): 

371 if not _is_json_column(model_cls, field_name): 

372 return value 

373 _reject_excluded_json_fields(value, f"{model_cls.__name__}.{field_name}") 

374 return value.model_dump(mode="json") 

375 if isinstance(value, list | tuple) and any( 

376 isinstance(item, pydantic.BaseModel) for item in value 

377 ): 

378 if not _is_json_column(model_cls, field_name): 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true

379 return value 

380 _reject_excluded_json_fields(value, f"{model_cls.__name__}.{field_name}") 

381 return [ 

382 item.model_dump(mode="json") 

383 if isinstance(item, pydantic.BaseModel) 

384 else item 

385 for item in value 

386 ] 

387 return value 

388 

389 

390def _reject_excluded_json_fields(value: Any, column: str) -> None: 

391 """Reject exclusions and document values that cannot be inspected safely.""" 

392 pending = [value] 

393 seen: set[int] = set() 

394 while pending: 

395 item = pending.pop() 

396 if dataclasses.is_dataclass(item) or isinstance(item, Iterator): 

397 raise RestlyConfigurationError( 

398 f"{column}: documents containing dataclasses or iterators cannot " 

399 "be stored automatically in a plain JSON column. Use a SQLAlchemy " 

400 "TypeDecorator to define their storage representation." 

401 ) 

402 if isinstance(item, str | bytes | bytearray) or not isinstance( 

403 item, (pydantic.BaseModel, Mapping, Sequence, Set, Enum) 

404 ): 

405 continue 

406 if id(item) in seen: 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true

407 continue 

408 seen.add(id(item)) 

409 if isinstance(item, pydantic.BaseModel): 

410 for name, field in type(item).model_fields.items(): 

411 if field.exclude: 

412 raise RestlyConfigurationError( 

413 f"{column}: {type(item).__name__}.{name} is excluded from " 

414 "serialization. Documents with WriteOnly or " 

415 "Field(exclude=True) fields cannot be stored automatically " 

416 "in a plain JSON column. Use a SQLAlchemy TypeDecorator " 

417 "to define their storage representation." 

418 ) 

419 pending.append(getattr(item, name, None)) 

420 if item.model_extra: 

421 pending.extend(item.model_extra.values()) 

422 elif isinstance(item, Mapping): 

423 pending.extend(item.keys()) 

424 pending.extend(item.values()) 

425 elif isinstance(item, Enum): 

426 pending.append(item.value) 

427 else: 

428 pending.extend(item) 

429 

430 

431def _add_assignment(target: dict[str, Any], field_name: str | None, value: Any) -> None: 

432 if field_name: 432 ↛ exitline 432 didn't return from function '_add_assignment' because the condition on line 432 was always true

433 target[field_name] = value 

434 

435 

436_EXPLICIT_NULL_REF = object() 

437 

438 

439def _reference_identity(value: Any) -> tuple[type[Any] | None, Any] | object | None: 

440 if value is None: 

441 return _EXPLICIT_NULL_REF 

442 if is_mapped_instance(value): 442 ↛ 444line 442 didn't jump to line 444 because the condition on line 442 was always true

443 return type(value), getattr(value, "id", None) 

444 if isinstance(value, IDSchema): 

445 sql_model = value.get_sql_model_annotation() 

446 return sql_model, value.id 

447 return None 

448 

449 

450def _reference_identity_detail(identity: object) -> Any: 

451 if identity is _EXPLICIT_NULL_REF: 

452 return None 

453 if isinstance(identity, tuple) and len(identity) == 2: 453 ↛ 455line 453 didn't jump to line 455 because the condition on line 453 was always true

454 return identity[1] 

455 return identity 

456 

457 

458def validate_resolved_reference_consistency( 

459 model_cls: type[Any], 

460 schema_obj: pydantic.BaseModel, 

461 resolved: dict[str, Any] | None = None, 

462) -> None: 

463 """Validate explicitly supplied FK and relationship fields agree. 

464 

465 IDRef/IDSchema resolution looks model-aware references up as ORM objects 

466 (in ``resolved``) before object construction/update. If the client supplied 

467 both ``author_id`` and ``author`` independently, they must refer to the same 

468 row. ``resolved`` is the ``{field: object}`` mapping from the resolver; a 

469 field absent from it keeps its (unresolved) value on ``schema_obj``. 

470 """ 

471 schema_cls = type(schema_obj) 

472 resolved = resolved or {} 

473 

474 for fk_field in schema_obj.model_fields_set: 

475 # Start from the FK-column side of a reference pair and derive its 

476 # partner relationship from the mapper; relationship-named fields are 

477 # reached as the partner, not iterated here. 

478 if not is_reference_field(schema_cls, fk_field): 

479 continue 

480 if _get_relationship_property(model_cls, fk_field) is not None: 

481 continue 

482 

483 relation_field = _relationship_name_for_fk(model_cls, fk_field) 

484 if ( 

485 relation_field is None 

486 or relation_field not in schema_obj.model_fields_set 

487 or not is_reference_field(schema_cls, relation_field) 

488 ): 

489 continue 

490 

491 fk_identity = _reference_identity( 

492 resolved.get(fk_field, getattr(schema_obj, fk_field, None)) 

493 ) 

494 relation_identity = _reference_identity( 

495 resolved.get(relation_field, getattr(schema_obj, relation_field, None)) 

496 ) 

497 if fk_identity is None or relation_identity is None: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 continue 

499 

500 if fk_identity == relation_identity: 

501 continue 

502 

503 raise fastapi.HTTPException( 

504 status_code=422, 

505 detail=( 

506 f"Conflicting references for {fk_field} and {relation_field}: " 

507 f"{_reference_identity_detail(fk_identity)!r} != " 

508 f"{_reference_identity_detail(relation_identity)!r}" 

509 ), 

510 ) 

511 

512 

513def iter_creatable_fields(schema_obj: pydantic.BaseModel) -> Iterator[tuple[str, Any]]: 

514 """Iterate over (field_name, value) pairs that should be used to construct a new 

515 ORM object from ``schema_obj``. 

516 

517 Fields marked as ``ReadOnly`` are skipped. Unlike :func:`get_writable_inputs`, 

518 this also includes fields that were not explicitly provided, so that 

519 schema-level defaults end up on the new object. 

520 """ 

521 schema_cls = type(schema_obj) 

522 for field_name, value in schema_obj: 

523 if is_readonly_field(schema_cls, field_name): 

524 continue 

525 yield field_name, value 

526 

527 

528def _add_null_reference_to_create_plan( 

529 plan: _CreatePlan, model_cls: type[Any], field_name: str 

530) -> None: 

531 """Write a null reference (explicit ``post=None``, or an omitted field 

532 defaulting to None) into the plan. 

533 

534 Unlike a resolved reference, a null writes only the field's own slot, plus 

535 the partner FK column when the dataclass ``__init__`` requires it. It never 

536 mirrors further, and never overwrites a slot another field already planned: 

537 schemas may declare both names of an FK/relationship pair as reference 

538 fields, and the unset sibling's default None must not clobber the side the 

539 client supplied. (Later fields overwrite a planned None naturally, so the 

540 ``setdefault`` guards are order-independent. A relationship the input did 

541 not supply is left to :func:`build_create_plan`, which passes None only 

542 where ``__init__`` requires it and has it unset after construction.) 

543 """ 

544 relationship = _get_relationship_property(model_cls, field_name) 

545 if relationship is None and not _is_mapped_column(model_cls, field_name): 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 return # unmapped reference name; nothing to write 

547 

548 if _accepts_init_kwarg(model_cls, field_name): 

549 plan.kwargs.setdefault(field_name, None) 

550 elif _has_model_attr(model_cls, field_name): 550 ↛ 553line 550 didn't jump to line 553 because the condition on line 550 was always true

551 plan.post_assignments.setdefault(field_name, None) 

552 

553 if relationship is None: 

554 return 

555 

556 # A required-init partner FK column must be constructed even for a null 

557 # reference, or ``__init__`` rejects the missing kwarg. A composite-FK 

558 # relationship has no single partner column — but a null also has no id to 

559 # route, so there is nothing to infer; skip instead of raising. 

560 try: 

561 fk_name = _get_unambiguous_local_fk_name(model_cls, field_name) 

562 except ValueError: 

563 return 

564 if fk_name and _requires_init_kwarg(model_cls, fk_name): 

565 plan.kwargs.setdefault(fk_name, None) 

566 

567 

568def _add_resolved_reference_to_create_plan( 

569 plan: _CreatePlan, model_cls: type[Any], field_name: str, value: Any 

570) -> None: 

571 # ``cast`` only; ``.id`` is accessed lazily inside the branches so a 

572 # composite-keyed relationship raises its descriptive ValueError (from 

573 # the single-FK inference) before any ``.id`` attribute error. 

574 ref = cast(_HasID, value) 

575 # Route by what the field maps to on the model (mapper introspection), not 

576 # by its name: a relationship gets the ORM object, a scalar FK column gets 

577 # the row's id. The partner attribute is derived from the mapper so this 

578 # works for any column name, not only the ``<relation>_id`` convention. 

579 relationship = _get_relationship_property(model_cls, field_name) 

580 if relationship is not None: 

581 relation_name = field_name 

582 fk_name = _get_unambiguous_local_fk_name(model_cls, relation_name) 

583 

584 # A required-init FK column must be constructed, not post-assigned, or 

585 # the dataclass __init__ rejects the missing kwarg. Pass its id at 

586 # construction: alongside the relationship object when that too is an 

587 # init kwarg (consistent ids), or instead of it when it isn't. 

588 if fk_name and _requires_init_kwarg(model_cls, fk_name): 

589 plan.kwargs[fk_name] = ref.id 

590 if _accepts_init_kwarg(model_cls, relation_name): 

591 plan.kwargs[relation_name] = value 

592 else: 

593 plan.post_assignments[relation_name] = value 

594 return 

595 

596 if _accepts_init_kwarg(model_cls, relation_name): 

597 plan.kwargs[relation_name] = value 

598 _add_assignment(plan.post_assignments, fk_name, ref.id) 

599 return 

600 

601 if fk_name and _accepts_init_kwarg(model_cls, fk_name): 601 ↛ 602line 601 didn't jump to line 602 because the condition on line 601 was never true

602 plan.kwargs[fk_name] = ref.id 

603 plan.post_assignments[relation_name] = value 

604 return 

605 

606 plan.post_assignments[relation_name] = value 

607 _add_assignment(plan.post_assignments, fk_name, ref.id) 

608 return 

609 

610 if not _is_mapped_column(model_cls, field_name): 610 ↛ 614line 610 didn't jump to line 614 because the condition on line 610 was never true

611 # Reference field that maps to neither a relationship nor a column; 

612 # there is nothing to write (matches the relationship branch's drop of 

613 # unmapped names). 

614 return 

615 

616 fk_name = field_name 

617 relation_name = _relationship_name_for_fk(model_cls, fk_name) 

618 

619 if relation_name is None: 

620 if _accepts_init_kwarg(model_cls, fk_name): 620 ↛ 623line 620 didn't jump to line 623 because the condition on line 620 was always true

621 plan.kwargs[fk_name] = ref.id 

622 else: 

623 plan.post_assignments[fk_name] = ref.id 

624 return 

625 

626 # A partner relationship exists; keep the FK column and the relationship 

627 # object consistent, honoring dataclass init requirements on either side. 

628 accepts_relation = _accepts_init_kwarg(model_cls, relation_name) 

629 relation_required = _requires_init_kwarg(model_cls, relation_name) 

630 

631 if ( 

632 _requires_init_kwarg(model_cls, fk_name) 

633 and accepts_relation 

634 and relation_required 

635 ): 

636 plan.kwargs[fk_name] = ref.id 

637 plan.kwargs[relation_name] = value 

638 return 

639 

640 if accepts_relation and relation_required: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true

641 plan.kwargs[relation_name] = value 

642 plan.post_assignments[fk_name] = ref.id 

643 return 

644 

645 if _accepts_init_kwarg(model_cls, fk_name): 

646 plan.kwargs[fk_name] = ref.id 

647 plan.post_assignments[relation_name] = value 

648 return 

649 

650 if accepts_relation: 

651 plan.kwargs[relation_name] = value 

652 plan.post_assignments[fk_name] = ref.id 

653 return 

654 

655 plan.post_assignments[fk_name] = ref.id 

656 plan.post_assignments[relation_name] = value 

657 

658 

659def build_create_plan( 

660 model_cls: type[Any], 

661 schema_obj: pydantic.BaseModel, 

662 resolved: dict[str, Any] | None = None, 

663) -> _CreatePlan: 

664 """Translate ``schema_obj`` fields into kwargs for ``model_cls(**kwargs)``. 

665 

666 Shared by sync and async ``make_new_object``. ``resolved`` is the 

667 ``{field: object_or_list}`` mapping returned by the IDSchema resolver (sync 

668 vs async); a resolved reference field uses that ORM value instead of the 

669 wire-shaped ``IDRef`` still on ``schema_obj``. 

670 """ 

671 schema_cls = type(schema_obj) 

672 resolved = resolved or {} 

673 

674 plan = _CreatePlan(kwargs={}, post_assignments={}) 

675 for field_name, value in iter_creatable_fields(schema_obj): 

676 if field_name in resolved: 

677 value = resolved[field_name] 

678 if isinstance(value, IDSchema) and _is_mapped_column(model_cls, field_name): 

679 if _accepts_init_kwarg(model_cls, field_name): 679 ↛ 682line 679 didn't jump to line 682 because the condition on line 679 was always true

680 plan.kwargs[field_name] = value.id 

681 else: 

682 plan.post_assignments[field_name] = value.id 

683 continue 

684 if value is None and is_reference_field(schema_cls, field_name): 

685 _add_null_reference_to_create_plan(plan, model_cls, field_name) 

686 continue 

687 if is_mapped_instance(value) and is_reference_field(schema_cls, field_name): 

688 _add_resolved_reference_to_create_plan(plan, model_cls, field_name, value) 

689 continue 

690 

691 value = _json_ready(model_cls, field_name, value) 

692 if _accepts_init_kwarg(model_cls, field_name): 

693 plan.kwargs[field_name] = value 

694 elif _has_model_attr(model_cls, field_name): 694 ↛ 675line 694 didn't jump to line 675 because the condition on line 694 was always true

695 plan.post_assignments[field_name] = value 

696 

697 plan.unsupplied_relationships = [ 

698 name 

699 for name in _many_to_one_names(model_cls) 

700 if name not in schema_obj.model_fields_set 

701 or is_readonly_field(schema_cls, name) 

702 ] 

703 for name in plan.unsupplied_relationships: 

704 # __init__ demands the argument; the None is unset after construction. 

705 if _requires_init_kwarg(model_cls, name): 

706 plan.kwargs.setdefault(name, None) 

707 return plan 

708 

709 

710def apply_create_assignments(obj: Any, plan: _CreatePlan) -> None: 

711 """Finish an object built from ``plan.kwargs``. 

712 

713 Sets the attributes the constructor cannot take, then unsets each 

714 many-to-one relationship the input did not supply that holds ``None``. 

715 That ``None`` is a default: the dataclass ``__init__`` assigns 

716 ``relationship(default=None)``, and the plan passes ``None`` for an 

717 omitted reference field or a required relationship argument. SQLAlchemy's 

718 flush would copy it over the foreign key, discarding an id the input did 

719 supply. Unset, the relationship takes no part in the flush, as SQLAlchemy 

720 2.1 treats dataclass defaults. A ``null`` the client sent stays. 

721 """ 

722 for field_name, value in plan.post_assignments.items(): 

723 setattr(obj, field_name, value) 

724 loaded = sa_inspect(obj).dict 

725 for name in plan.unsupplied_relationships: 

726 if name in loaded and loaded[name] is None: 

727 del_attribute(obj, name) 

728 

729 

730def _apply_resolved_reference_update(obj: Any, field_name: str, value: Any) -> None: 

731 ref = cast(_HasID, value) 

732 model_cls = type(obj) 

733 relationship = _get_relationship_property(model_cls, field_name) 

734 if relationship is None: 

735 # Scalar FK column: write the id, and mirror onto the partner 

736 # relationship (derived from the mapper) when there is one. 

737 if not _is_mapped_column(model_cls, field_name): 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true

738 return 

739 setattr(obj, field_name, ref.id) 

740 relation_name = _relationship_name_for_fk(model_cls, field_name) 

741 if relation_name is not None: 

742 setattr(obj, relation_name, value) 

743 return 

744 

745 # Relationship field: assign the object, mirror onto the local FK if any. 

746 setattr(obj, field_name, value) 

747 fk_name = _get_unambiguous_local_fk_name(model_cls, field_name) 

748 if fk_name: 748 ↛ exitline 748 didn't return from function '_apply_resolved_reference_update' because the condition on line 748 was always true

749 setattr(obj, fk_name, ref.id) 

750 

751 

752def apply_update_to_object( 

753 obj: Any, schema_obj: pydantic.BaseModel, resolved: dict[str, Any] | None = None 

754) -> None: 

755 """Apply writable inputs from ``schema_obj`` onto ``obj`` in place. 

756 

757 Shared by sync and async ``update_object``. ``resolved`` is the 

758 ``{field: object_or_list}`` mapping returned by the IDSchema resolver (sync 

759 vs async); a resolved reference field uses that ORM value instead of the 

760 wire-shaped ``IDRef`` still on ``schema_obj``. 

761 """ 

762 schema_cls = type(schema_obj) 

763 resolved = resolved or {} 

764 for field_name, value in get_writable_inputs(schema_obj).items(): 

765 if field_name in resolved: 

766 value = resolved[field_name] 

767 if isinstance(value, IDSchema) and _is_mapped_column(type(obj), field_name): 

768 setattr(obj, field_name, value.id) 

769 continue 

770 if is_mapped_instance(value) and is_reference_field(schema_cls, field_name): 

771 _apply_resolved_reference_update(obj, field_name, value) 

772 continue 

773 setattr(obj, field_name, _json_ready(type(obj), field_name, value)) 

774 

775 

776def _get_nested_schema_annotation(annotation: Any) -> type[pydantic.BaseModel] | None: 

777 annotation = _unwrap_optional_annotation(annotation) 

778 

779 try: 

780 if inspect.isclass(annotation) and issubclass(annotation, pydantic.BaseModel): 

781 return annotation 

782 except TypeError: 

783 pass 

784 

785 origin = get_origin(annotation) 

786 if origin is list: 786 ↛ 791line 786 didn't jump to line 791 because the condition on line 786 was always true

787 args = get_args(annotation) 

788 if args: 788 ↛ 791line 788 didn't jump to line 791 because the condition on line 788 was always true

789 return _get_nested_schema_annotation(args[0]) 

790 

791 return None 

792 

793 

794def _generated_primary_key_fields(model_cls: type[Any]) -> frozenset[str]: 

795 """The primary key attributes the server generates, never the client. 

796 

797 A derived create or update schema leaves these out even when the schema 

798 does not mark them ReadOnly. Generated means an autoincrement column, a 

799 column default, or a dataclass field outside ``__init__``. A key without 

800 any of these is a natural key, and the client supplies it. 

801 """ 

802 mapper = sa_inspect(model_cls) 

803 autoincrement = getattr(mapper.local_table, "autoincrement_column", None) 

804 not_in_init = ( 

805 {f.name for f in dataclasses.fields(model_cls) if not f.init} 

806 if dataclasses.is_dataclass(model_cls) 

807 else set() 

808 ) 

809 generated = set() 

810 for column in mapper.primary_key: 

811 key = mapper.get_property_by_column(column).key 

812 if ( 

813 column is autoincrement 

814 or column.default is not None 

815 or column.server_default is not None 

816 or key in not_in_init 

817 ): 

818 generated.add(key) 

819 return frozenset(generated) 

820 

821 

822def _build_relationship_loader_options( 

823 model_cls: type[Any], 

824 schema_cls: type[pydantic.BaseModel], 

825 seen: set[tuple[type[Any], type[pydantic.BaseModel]]] | None = None, 

826) -> list[Any]: 

827 if seen is None: 

828 seen = set() 

829 

830 visit_key = (model_cls, schema_cls) 

831 if visit_key in seen: 831 ↛ 832line 831 didn't jump to line 832 because the condition on line 831 was never true

832 return [] 

833 seen = seen | {visit_key} 

834 

835 mapper = sa_inspect(model_cls) 

836 options: list[Any] = [] 

837 for field_name, field_info in schema_cls.model_fields.items(): 

838 if field_name not in mapper.relationships: 

839 continue 

840 

841 relationship_prop = mapper.relationships[field_name] 

842 loader = selectinload(getattr(model_cls, field_name)) 

843 nested_schema = _get_nested_schema_annotation(field_info.annotation) 

844 

845 if nested_schema is not None: 845 ↛ 852line 845 didn't jump to line 852 because the condition on line 845 was always true

846 child_options = _build_relationship_loader_options( 

847 relationship_prop.mapper.class_, nested_schema, seen 

848 ) 

849 if child_options: 

850 loader = loader.options(*child_options) 

851 

852 options.append(loader) 

853 

854 return options 

855 

856 

857def _relationship_reload_statement(obj: Any, options: list[Any]) -> Any: 

858 """A primary-key SELECT that fills ``obj``'s unloaded relationships. 

859 

860 Deliberately *not* ``populate_existing``: that would expire every 

861 relationship the options do not name and overwrite the ones they do, so a 

862 value the caller had already put there would be replaced by a fresh read. 

863 Without it the loaders fill only what is unloaded, which is exactly what 

864 serialization would otherwise have lazy-loaded. 

865 """ 

866 state = sa_inspect(obj) 

867 if state.identity is None: 

868 return None 

869 mapper = state.mapper 

870 conditions = [ 

871 column == value 

872 for column, value in zip(mapper.primary_key, state.identity, strict=True) 

873 ] 

874 return sa_select(mapper.class_).where(*conditions).options(*options) 

875 

876 

877def _schema_relationships_are_loaded( 

878 obj: Any, 

879 model_cls: type[Any], 

880 schema_cls: type[pydantic.BaseModel], 

881 seen: set[tuple[int, type[pydantic.BaseModel]]] | None = None, 

882) -> bool: 

883 """Is every relationship ``schema_cls`` reaches already loaded on ``obj``? 

884 

885 Walks the same model/schema pairing as 

886 :func:`_build_relationship_loader_options`, so it answers exactly the 

887 question "would serializing this object need IO?". Reads only the committed 

888 attribute dict, never the descriptors, so asking cannot itself emit a query. 

889 """ 

890 if seen is None: 

891 seen = set() 

892 # Dedup on (instance, schema), not the instance alone: the same object can 

893 # be reached under two response sub-schemas that name different 

894 # relationships, and each pairing must be checked. Mirrors the 

895 # (model, schema) dedup in _build_relationship_loader_options. 

896 key = (id(obj), schema_cls) 

897 if key in seen: 

898 return True 

899 seen.add(key) 

900 

901 state = sa_inspect(obj, raiseerr=False) 

902 if state is None: 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true

903 return True 

904 

905 mapper = sa_inspect(model_cls) 

906 for field_name, field_info in schema_cls.model_fields.items(): 

907 if field_name not in mapper.relationships: 

908 continue 

909 if field_name in state.unloaded: 

910 return False 

911 

912 nested_schema = _get_nested_schema_annotation(field_info.annotation) 

913 if nested_schema is None: 913 ↛ 914line 913 didn't jump to line 914 because the condition on line 913 was never true

914 continue 

915 

916 value = state.dict.get(field_name) 

917 if value is None: 

918 continue 

919 relationship_prop = mapper.relationships[field_name] 

920 related = value if relationship_prop.uselist else [value] 

921 for item in related: 

922 if not _schema_relationships_are_loaded( 

923 item, relationship_prop.mapper.class_, nested_schema, seen 

924 ): 

925 return False 

926 

927 return True 

928 

929 

930class View: 

931 """ 

932 Class-based view primitive for FastAPI. 

933 

934 Group related endpoints on a class, share dependencies and metadata via 

935 class attributes, and let subclasses override individual handlers. Routes 

936 are bound at :func:`include_view` time, not at class-definition time, so 

937 subclassing works the way Python developers expect: override a method on 

938 a subclass and the override is what runs. 

939 

940 Most users will subclass :class:`RestView` or :class:`AsyncRestView`, 

941 which extend ``View`` with CRUD scaffolding. Use ``View`` directly for 

942 grouped non-CRUD endpoints (auth flows, custom RPC routes, etc.). 

943 """ 

944 

945 #: The URL prefix of every route. Each class in the hierarchy that sets one 

946 #: adds a segment, base first. 

947 prefix: ClassVar[str] 

948 tags: ClassVar[Iterable[str | Enum] | None] = None 

949 #: FastAPI dependencies run for every route, without injecting a result. 

950 #: Each class in the hierarchy adds its own, base first, so a subclass 

951 #: cannot drop a base's guard. A subclass that lists a base's entry again 

952 #: runs it where it lists it. 

953 dependencies: ClassVar[Any] = None 

954 #: OpenAPI responses documented on every route. Each class in the hierarchy 

955 #: adds its own, and a subclass's entry for a status code wins. 

956 responses: ClassVar[dict[int | str, dict[str, Any]]] = {} 

957 #: FastAPI route keyword arguments keyed by endpoint method name or 

958 #: ``ViewRoute``. These override decorator metadata without replacing the 

959 #: endpoint method. A subclass inherits this mapping unless it declares 

960 #: its own, which replaces it. 

961 route_options: ClassVar[Mapping[str | ViewRoute, Mapping[str, Any]]] = {} 

962 

963 if TYPE_CHECKING: 

964 # include_view replaces __init__: each injected attribute becomes a 

965 # keyword, so code that builds a view by hand passes them by name. 

966 def __init__(self, **dependencies: Any) -> None: ... 

967 

968 @classmethod 

969 def before_include_view(cls) -> None: 

970 """Run by :func:`include_view` once per class, before its routes are 

971 registered. A no-op here; override to adjust route methods first.""" 

972 

973 

974V = TypeVar("V", bound=type[View]) 

975 

976 

977@overload 

978def include_view( 

979 parent_router: fastapi.APIRouter | fastapi.FastAPI, view_cls: V 

980) -> V: ... 

981@overload 

982def include_view( 

983 parent_router: fastapi.APIRouter | fastapi.FastAPI, 

984) -> Callable[[V], V]: ... 

985 

986 

987def include_view( 

988 parent_router: fastapi.APIRouter | fastapi.FastAPI, view_cls: V | None = None 

989) -> V | Callable[[V], V]: 

990 """ 

991 Add a View class's routes to a FastAPI app or APIRouter. 

992 

993 Prefer the direct call form from your app/router composition layer:: 

994 

995 include_view(app, MyView) 

996 

997 For small apps, it can also be used as a decorator:: 

998 

999 @include_view(app) 

1000 class MyView(AsyncRestView): 

1001 ... 

1002 

1003 Registering a view on several parents mounts its routes on each; calling 

1004 ``include_view`` again with a parent the view is already registered on is 

1005 a no-op. 

1006 """ 

1007 if view_cls is not None: 

1008 _init_view_cls_and_add_to_router(view_cls, parent_router) 

1009 return view_cls 

1010 

1011 def class_decorator(view_cls: V) -> V: 

1012 _init_view_cls_and_add_to_router(view_cls, parent_router) 

1013 return view_cls 

1014 

1015 return class_decorator 

1016 

1017 

1018def route(path: str, **api_route_kwargs: Any) -> Callable[..., Any]: 

1019 """Decorator to mark a View method as an endpoint. 

1020 The path and api_route_kwargs are passed into APIRouter.add_api_route(), see for example: 

1021 https://fastapi.tiangolo.com/reference/apirouter/#fastapi.APIRouter.get 

1022 

1023 Endpoints methods are later added as routes to the FastAPI app using `include_view()` 

1024 """ 

1025 

1026 def store_args_decorator(func: Callable[..., Any]) -> Callable[..., Any]: 

1027 # A new declaration takes precedence over metadata copied by wraps(). 

1028 func.__dict__.pop("_fr_typed_id_route", None) 

1029 # Create a new attribute: '_api_route_args' 

1030 func._api_route_args = (path, api_route_kwargs) # type: ignore[attr-defined] 

1031 return func 

1032 

1033 return store_args_decorator 

1034 

1035 

1036def _typed_id_route(endpoint: Callable[..., Any]) -> Callable[..., Any]: 

1037 """Mark a built-in item route whose copied path follows the view's ID type.""" 

1038 endpoint._fr_typed_id_route = True # type: ignore[attr-defined] 

1039 return endpoint 

1040 

1041 

1042def get(path: str, **api_route_kwargs: Any) -> Callable[..., Any]: 

1043 """Decorator to mark a View method as a GET endpoint. 

1044 

1045 Equivalent to:: 

1046 

1047 @route(path, methods=["GET"], status_code=200, ... ) 

1048 """ 

1049 api_route_kwargs.setdefault("methods", ["GET"]) 

1050 api_route_kwargs.setdefault("status_code", 200) 

1051 return route(path, **api_route_kwargs) 

1052 

1053 

1054def post(path: str, **api_route_kwargs: Any) -> Callable[..., Any]: 

1055 """Decorator to mark a View method as a POST endpoint. 

1056 

1057 Equivalent to:: 

1058 

1059 @route(path, methods=["POST"], status_code=201, ... ) 

1060 """ 

1061 api_route_kwargs.setdefault("methods", ["POST"]) 

1062 api_route_kwargs.setdefault("status_code", 201) 

1063 return route(path, **api_route_kwargs) 

1064 

1065 

1066def put(path: str, **api_route_kwargs: Any) -> Callable[..., Any]: 

1067 """Decorator to mark a View method as a PUT endpoint. 

1068 

1069 Equivalent to:: 

1070 

1071 @route(path, methods=["PUT"], status_code=200, ... ) 

1072 """ 

1073 api_route_kwargs.setdefault("methods", ["PUT"]) 

1074 api_route_kwargs.setdefault("status_code", 200) 

1075 return route(path, **api_route_kwargs) 

1076 

1077 

1078def patch(path: str, **api_route_kwargs: Any) -> Callable[..., Any]: 

1079 """Decorator to mark a View method as a PATCH endpoint. 

1080 

1081 Equivalent to:: 

1082 

1083 @route(path, methods=["PATCH"], status_code=200, ... ) 

1084 """ 

1085 api_route_kwargs.setdefault("methods", ["PATCH"]) 

1086 api_route_kwargs.setdefault("status_code", 200) 

1087 return route(path, **api_route_kwargs) 

1088 

1089 

1090def delete(path: str, **api_route_kwargs: Any) -> Callable[..., Any]: 

1091 """Decorator to mark a View method as a DELETE endpoint. 

1092 

1093 Equivalent to:: 

1094 

1095 @route(path, methods=["DELETE"], status_code=204, ... ) 

1096 """ 

1097 api_route_kwargs.setdefault("methods", ["DELETE"]) 

1098 api_route_kwargs.setdefault("status_code", 204) 

1099 return route(path, **api_route_kwargs) 

1100 

1101 

1102# Callable from a verb override, never overridden. The framework's own view 

1103# classes define them; anything else in the MRO that does is a mistake. 

1104_FINAL_DOMAIN_UTILITIES = ("make_new_object", "update_object", "save_object") 

1105 

1106# Callable from a custom route, never overridden. Every generated CRUD route 

1107# then runs ``authorize`` and, on writes, the commit bracket. 

1108_FINAL_HANDLERS = ( 

1109 "handle_get_many", 

1110 "handle_get_one", 

1111 "handle_create", 

1112 "handle_update", 

1113 "handle_delete", 

1114) 

1115_WRITE_VERBS = ("create", "update", "delete") 

1116 

1117 

1118def _final_handler_message(view_name: str, name: str, origin: str) -> str: 

1119 """What a subclass that defines ``name`` is told to use instead.""" 

1120 verb = name.removeprefix("handle_") 

1121 message = ( 

1122 f"{view_name} defines {name}{origin}, which is a final handler, not " 

1123 f"a seam: it is the tier a custom route calls. Put domain logic in " 

1124 f"the {verb} business method, a gate in authorize, " 

1125 ) 

1126 if verb in _WRITE_VERBS: 

1127 message += "a side effect in before_action_commit or after_action_commit, " 

1128 message += f"and the HTTP contract in {verb}_endpoint." 

1129 if verb in _WRITE_VERBS: 

1130 message += ( 

1131 f" To put several writes in one commit, call {name} inside " 

1132 "shared_write_action_commit()." 

1133 ) 

1134 return message 

1135 

1136 

1137# Replaced by ``pagination``. A view that still sets one is told what to write. 

1138_REPLACED_PAGINATION_SETTINGS = ("paginated", "default_page_size", "max_page_size") 

1139 

1140 

1141def _replaced_pagination_message( 

1142 view_name: str, found: dict[str, tuple[Any, str]] 

1143) -> str: 

1144 """What a view that sets old pagination settings writes instead: one 

1145 suggestion that carries every value it set. ``found`` maps each setting 

1146 to its value and where it came from.""" 

1147 values = {name: value for name, (value, _) in found.items()} 

1148 sizes = { 

1149 name: values[name] 

1150 for name in ("default_page_size", "max_page_size") 

1151 if name in values 

1152 } 

1153 maximum = sizes.get("max_page_size") 

1154 if ( 

1155 "default_page_size" not in sizes 

1156 and isinstance(maximum, int) 

1157 and maximum < DEFAULT_PAGE_SIZE 

1158 ): 

1159 # the default page size of 50 would be above the new maximum 

1160 sizes = {"default_page_size": maximum, **sizes} 

1161 if values.get("paginated") is False: 

1162 instead = "Write 'pagination = None' to return every row." 

1163 elif sizes: 

1164 arguments = ", ".join(f"{name}={value!r}" for name, value in sizes.items()) 

1165 instead = ( 

1166 f"Write 'pagination = fr.NumberedPagination({arguments})', or call " 

1167 f"'.replace({arguments})' on pagination settings that views share." 

1168 ) 

1169 if "paginated" in values: 1169 ↛ 1170line 1169 didn't jump to line 1170 because the condition on line 1169 was never true

1170 instead += " Remove 'paginated': views paginate by default." 

1171 else: 

1172 instead = "Remove it: views paginate by default." 

1173 names = ", ".join(f"{name}{origin}" for name, (_, origin) in found.items()) 

1174 verb = "was" if len(found) == 1 else "were" 

1175 return ( 

1176 f"{view_name} sets {names}, which {verb} replaced by the 'pagination' " 

1177 f"setting. {instead}" 

1178 ) 

1179 

1180 

1181def _check_pagination_setting(cls: type) -> None: 

1182 """Reject the replaced pagination settings, and a ``pagination`` that is 

1183 not a settings instance. Runs when the class is defined and again at 

1184 registration, so a value assigned to the class later is caught too.""" 

1185 # the old settings are no longer read; a definition would be silently 

1186 # dead, and a dead max_page_size is a cap that is gone 

1187 found: dict[str, tuple[Any, str]] = {} 

1188 for klass in cls.__mro__: 

1189 for name in _REPLACED_PAGINATION_SETTINGS: 

1190 if name in vars(klass) and name not in found: 

1191 origin = "" if klass is cls else f" (from {klass.__name__})" 

1192 found[name] = (vars(klass)[name], origin) 

1193 if found: 

1194 raise RestlyConfigurationError( 

1195 _replaced_pagination_message(cls.__name__, found) 

1196 ) 

1197 pagination = getattr(cls, "pagination", None) 

1198 if not ( 

1199 pagination is None or isinstance(pagination, (NumberedPagination, NoPagination)) 

1200 ): 

1201 hint = ( 

1202 f" Create an instance: fr.{pagination.__name__}()." 

1203 if pagination in (NumberedPagination, NoPagination) 

1204 else "" 

1205 ) 

1206 raise RestlyConfigurationError( 

1207 f"{cls.__name__}.pagination must be a fr.NumberedPagination or " 

1208 f"fr.NoPagination instance, or None, got {pagination!r}.{hint}" 

1209 ) 

1210 

1211 

1212# Renamed before 1.0. Restly no longer reads the old names, so a definition 

1213# would be silently dead, or would fail only on a request. 

1214_RENAMED_SETTINGS = {"listing_param_schema": "schema_list_params"} 

1215_RENAMED_METHODS = { 

1216 "to_listing_response": "to_list_response", 

1217 "to_react_admin_listing_response": "to_list_response", 

1218 "to_response_schema": "to_single_response", 

1219 "apply_query_params": "apply_list_params", 

1220} 

1221_RENAMED_METHOD_HINTS = { 

1222 "to_listing_response": ( 

1223 " It takes only the list result: the list params are in " 

1224 "list_result.list_params." 

1225 ), 

1226 "to_react_admin_listing_response": ( 

1227 " The list params are in list_result.list_params." 

1228 ), 

1229 "apply_query_params": " Its arguments are (query, list_params).", 

1230} 

1231 

1232 

1233def _route_parameters(route: Callable[..., Any]) -> Mapping[str, inspect.Parameter]: 

1234 """The parameters of ``route``, without evaluating its annotations. 

1235 

1236 On 3.14 a route may name a class that its module defines further down, 

1237 because annotations are read only when asked for. Evaluating them while 

1238 the view class is defined would raise ``NameError`` for that name. 

1239 """ 

1240 if sys.version_info >= (3, 14): 1240 ↛ 1241line 1240 didn't jump to line 1241 because the condition on line 1240 was never true

1241 return inspect.signature( 

1242 route, annotation_format=annotationlib.Format.FORWARDREF 

1243 ).parameters 

1244 return inspect.signature(route).parameters 

1245 

1246 

1247def _check_renamed_names(cls: type) -> None: 

1248 """Reject a setting, a method or a route parameter that still uses a name 

1249 from before the rename. Runs when the class is defined and again at 

1250 registration, so a value assigned to the class later is caught too.""" 

1251 for klass in cls.__mro__: 

1252 origin = "" if klass is cls else f" (from {klass.__name__})" 

1253 for old, new in _RENAMED_SETTINGS.items(): 

1254 if old in vars(klass): 

1255 raise RestlyConfigurationError( 

1256 f"{cls.__name__} sets {old}{origin}, which was renamed to " 

1257 f"{new}. Rename the setting." 

1258 ) 

1259 for old, new in _RENAMED_METHODS.items(): 

1260 if old in vars(klass): 

1261 raise RestlyConfigurationError( 

1262 f"{cls.__name__} defines {old}{origin}, which was renamed " 

1263 f"to {new}.{_RENAMED_METHOD_HINTS.get(old, '')} Rename the " 

1264 "method." 

1265 ) 

1266 for name, value in vars(klass).items(): 

1267 if not hasattr(value, "_api_route_args"): 

1268 continue 

1269 if "query_params" in _route_parameters(value): 

1270 raise RestlyConfigurationError( 

1271 f"{cls.__name__}.{name}{origin} declares a query_params " 

1272 "parameter, which was renamed to list_params. Rename the " 

1273 "parameter, so the route still takes the filter, sort and " 

1274 "page parameters." 

1275 ) 

1276 

1277 

1278class BaseRestView( 

1279 View, Generic[ModelT, ResponseSchemaT, CreateSchemaT, UpdateSchemaT, IdT] 

1280): 

1281 """ 

1282 Base class for RestView implementations. 

1283 

1284 This class contains the common functionality shared between AsyncRestView 

1285 and RestView, including schema definitions, model configuration, and 

1286 common CRUD operation logic. 

1287 """ 

1288 

1289 responses: ClassVar[dict[int | str, dict[str, Any]]] = { 

1290 404: {"description": "Not found"} 

1291 } 

1292 

1293 schema: ClassVar[type[pydantic.BaseModel]] 

1294 #: The class of everything that goes out: the single responses, the 

1295 #: items of the list response, and the fields the list params filter and 

1296 #: sort on. The relationships it names are the ones the view loads. When 

1297 #: a view sets none, Restly builds it with 

1298 #: :func:`~fastapi_restly.schemas.derive_schema_response`: the view's 

1299 #: schema without its ``WriteOnly`` fields, named ``<Resource>Response``. 

1300 #: A class that a view sets is used as it is. 

1301 schema_response: ClassVar[type[pydantic.BaseModel]] 

1302 # If 'schema_create' is not defined it will be created from 'schema' 

1303 # using `create_model_without_read_only_fields()`. 

1304 schema_create: ClassVar[type[pydantic.BaseModel]] 

1305 schema_update: ClassVar[type[pydantic.BaseModel]] 

1306 model: ClassVar[type[Any]] 

1307 #: The clause every read on this view applies: list, count, and retrieve 

1308 #: (a row outside it is 404). ``None`` (the default) falls back to the 

1309 #: model's declared ``default_scope``; ``fr.clauses.UNSCOPED`` reads 

1310 #: unscoped despite that default. Declaring a scope replaces the default, 

1311 #: it does not stack on it; compose the replacement from the same leaves 

1312 #: (``ItemClauses.trashed`` containing the tenant clause ``visible`` 

1313 #: contains). A rule that must hold under every scope is a session-level 

1314 #: ``with_loader_criteria``, not a view concern. See the Scopes guide. 

1315 scope: ClassVar[WhereClause | Unscoped | None] = None 

1316 #: The type of the ``{id}`` path parameter on the default routes. ``None`` 

1317 #: (the default) takes the Python type of the model's primary key, and a 

1318 #: composite key gets ``int``. Set a type to override it. Built-in item 

1319 #: routes use Starlette's ``int`` or ``uuid`` path converter for those 

1320 #: types. Integer paths accept non-negative digits. An unmatched value 

1321 #: falls through to another route or returns 404, instead of 422. 

1322 id_type: ClassVar[type[Any] | None] = None 

1323 exclude_routes: ClassVar[Iterable[str | ViewRoute]] = () 

1324 #: Extra query-parameter keys to allow on list routes beyond those 

1325 #: derived from the response class. Use this when a view reads a custom 

1326 #: parameter from ``self.request`` (e.g. ``?verbose=true``). Without this, 

1327 #: the strict unknown-key guard rejects the request with 422. A key that the 

1328 #: route declares, on the endpoint method or in a dependency, or an 

1329 #: ``APIKeyQuery`` key, needs no entry. 

1330 extra_query_params: ClassVar[Iterable[str]] = () 

1331 #: How list endpoints paginate. The default :class:`NumberedPagination` 

1332 #: takes ``page`` / ``page_size`` query parameters, runs the count query, 

1333 #: and wraps the list in its ``envelope`` (:class:`PaginatedEnvelope`: 

1334 #: ``data`` plus ``total_count`` / ``page`` / ``page_size`` / 

1335 #: ``total_pages``). A :class:`NoPagination` returns every matching row 

1336 #: with no count, in its ``envelope``; ``None`` is short for 

1337 #: ``NoPagination()``, a plain :class:`Envelope` (``data`` only). A view 

1338 #: inherits it, so a project base view sets it once; a view that differs 

1339 #: changes one setting with ``replace()`` on the shared settings. For a 

1340 #: shape no envelope model can express, such as a header, replace 

1341 #: ``get_many_endpoint`` with a matching ``response_model`` (see 

1342 #: :class:`AsyncReactAdminView`). 

1343 pagination: ClassVar[NumberedPagination | NoPagination | None] = _DEFAULT_PAGINATION 

1344 #: The list params (filter, sort, page) as a pydantic model, generated 

1345 #: from ``schema_response`` and ``model`` by 

1346 #: :func:`~fastapi_restly.query.derive_schema_list_params`. Any route 

1347 #: method on the view that declares a ``list_params`` parameter takes it: 

1348 #: typed for FastAPI and OpenAPI, and guarded against unknown keys like 

1349 #: ``GET /``, so a custom list route (a trash route naming its own scope) 

1350 #: reads the same list params. The route can take other query parameters 

1351 #: beside it. 

1352 schema_list_params: ClassVar[type[pydantic.BaseModel]] 

1353 

1354 request: fastapi.Request 

1355 

1356 def __init_subclass__(cls, **kwargs: Any) -> None: 

1357 super().__init_subclass__(**kwargs) 

1358 # validate at class definition, so a raw expression fails at 

1359 # import instead of on the first read 

1360 scope = cls.__dict__.get("scope") 

1361 if not (scope is None or scope is UNSCOPED or isinstance(scope, WhereClause)): 

1362 raise RestlyConfigurationError( 

1363 f"{cls.__name__}.scope must be a WhereClause, " 

1364 f"fr.clauses.UNSCOPED, or None, got {type(scope).__name__}; " 

1365 "wrap a raw expression with where_clause()" 

1366 ) 

1367 # the annotation accepts any class, so the mapping is checked here 

1368 model = cls.__dict__.get("model") 

1369 if model is not None and not is_mapped_class(model): 

1370 label = getattr(model, "__name__", repr(model)) 

1371 raise RestlyConfigurationError( 

1372 f"{cls.__name__}.model must be a SQLAlchemy mapped class, " 

1373 f"got {label}, which has no mapper" 

1374 ) 

1375 # build_query is removed; a definition would be silently dead code, 

1376 # and dead visibility filtering is a security hole 

1377 for klass in cls.__mro__: 

1378 if "build_query" in vars(klass): 

1379 origin = "" if klass is cls else f" (from {klass.__name__})" 

1380 raise RestlyConfigurationError( 

1381 f"{cls.__name__} defines build_query{origin}, which is " 

1382 "removed and no longer called. Declare visibility as a " 

1383 "clause: default_scope on the model's ClauseNamespace, or " 

1384 "the scope attribute on the view. Reshape a list in " 

1385 "apply_list_params, and relationship loading in " 

1386 "get_relationship_loader_options. See Migrating from " 

1387 "build_query in the Scopes guide." 

1388 ) 

1389 # delete_object is removed; a stale soft-delete override would be 

1390 # silently dead code, and a dead soft delete is a hard delete 

1391 for klass in cls.__mro__: 

1392 if "delete_object" in vars(klass): 

1393 origin = "" if klass is cls else f" (from {klass.__name__})" 

1394 raise RestlyConfigurationError( 

1395 f"{cls.__name__} defines delete_object{origin}, which is " 

1396 "removed and no longer called. Override delete instead: a " 

1397 "soft delete flips a timestamp there. A raw row delete is " 

1398 "fr.objects.delete_object / async_delete_object on " 

1399 "self.session." 

1400 ) 

1401 # the domain utilities are final: a stamp hooked there only covered 

1402 # the verbs, and a definition here would shadow the framework's 

1403 for klass in cls.__mro__: 

1404 if klass.__module__.startswith("fastapi_restly."): 

1405 continue 

1406 for name in _FINAL_DOMAIN_UTILITIES: 

1407 if name in vars(klass): 

1408 origin = "" if klass is cls else f" (from {klass.__name__})" 

1409 raise RestlyConfigurationError( 

1410 f"{cls.__name__} defines {name}{origin}, which is a " 

1411 "final domain utility, not a seam. Stamp a " 

1412 "server-controlled field with a column default on the " 

1413 "model (it then covers every write path); put " 

1414 "payload-derived logic in the create / update verb; " 

1415 "put a per-write side effect in before_action_commit." 

1416 ) 

1417 # the handlers are final: an override re-implements the load, 

1418 # authorize and commit order to reach something another seam gives 

1419 # directly, and a generated route could then skip either 

1420 for klass in cls.__mro__: 

1421 if klass.__module__.startswith("fastapi_restly."): 

1422 continue 

1423 for name in _FINAL_HANDLERS: 

1424 if name in vars(klass): 

1425 origin = "" if klass is cls else f" (from {klass.__name__})" 

1426 raise RestlyConfigurationError( 

1427 _final_handler_message(cls.__name__, name, origin) 

1428 ) 

1429 _check_pagination_setting(cls) 

1430 _check_renamed_names(cls) 

1431 

1432 def _apply_scope(self, query: Select[Any], scope: ReadScope) -> Select[Any]: 

1433 # the one path every read takes, so retrieve, list and count cannot 

1434 # disagree about which rows exist; not an override point 

1435 if scope is None: 

1436 scope = resolve_scope(self) 

1437 else: 

1438 # a per-read scope replaces the resolution rather than joining it 

1439 scope = _checked_scope(scope, type(self).__name__, "a per-read scope") 

1440 return apply_clauses(query, scope) 

1441 

1442 def _narrowed_scope(self, scope: ReadScope, where: _ReadWhere) -> ReadScope: 

1443 # the list handler is final, so it can fold where= into the scope it 

1444 # forwards: get_many keeps one visibility input, and an override 

1445 # keeps its signature 

1446 if where is None: 

1447 return scope 

1448 label = type(self).__name__ 

1449 if scope is None: 

1450 scope = resolve_scope(self) 

1451 else: 

1452 scope = _checked_scope(scope, label, "a per-read scope") 

1453 return all_of(scope, _checked_where(where, label)) 

1454 

1455 def get_relationship_loader_options(self) -> list[Any]: 

1456 """Loader options for the relationships the response class names. 

1457 

1458 Returns recursive ``selectinload(...)`` options derived from 

1459 :attr:`schema_response`, applied on reads (``get_one`` / 

1460 ``get_many``) and on the post-write reload in ``save_object``. 

1461 Override to eager-load relationships the response class does not 

1462 name on both paths; append to 

1463 ``super().get_relationship_loader_options()`` to keep the derived 

1464 loads. See the "Relationship Loading and Async" how-to. 

1465 """ 

1466 return _build_relationship_loader_options( 

1467 self.model, _schema_response(type(self)) 

1468 ) 

1469 

1470 def _get_response_reload_statement(self, obj: Any) -> Any: 

1471 """The SELECT that makes ``obj`` serializable, or ``None`` if it already is. 

1472 

1473 Called by ``save_object`` *after* the flush and refresh, because the 

1474 refresh is itself what leaves relationships unloaded; asking earlier 

1475 would see a value the refresh is about to discard. 

1476 """ 

1477 if _schema_relationships_are_loaded( 

1478 obj, self.model, _schema_response(type(self)) 

1479 ): 

1480 return None 

1481 options = self.get_relationship_loader_options() 

1482 if not options: 1482 ↛ 1483line 1482 didn't jump to line 1483 because the condition on line 1482 was never true

1483 return None 

1484 return _relationship_reload_statement(obj, options) 

1485 

1486 def _reject_unknown_query_params(self) -> None: 

1487 """Reject any query-string key that neither the list params nor the 

1488 route reads. 

1489 

1490 Runs before every route that declares ``list_params`` (see 

1491 :attr:`schema_list_params`). 

1492 

1493 The list params dependency (:func:`_list_params_dependency`) reads 

1494 their keys and ignores any other, which would let typoed 

1495 filters or unsupported operators (e.g. 

1496 ``active__gte=true`` on a boolean column where the schema does not 

1497 emit a range operator) widen the result set without telling the 

1498 caller. We treat unknown keys as a validation error instead, mirroring 

1499 FastAPI's 422 envelope shape so the response is consistent with 

1500 bound-violation errors. 

1501 

1502 No-op when there is no live request, as when code calls a route 

1503 method directly, outside an HTTP request. Then there is no URL to 

1504 check, and the caller is responsible for what it passes. 

1505 """ 

1506 request = getattr(self, "request", None) 

1507 if request is None: 1507 ↛ 1508line 1507 didn't jump to line 1508 because the condition on line 1507 was never true

1508 return 

1509 schema_list_params = getattr(self, "schema_list_params", None) 

1510 if schema_list_params is None: 1510 ↛ 1511line 1510 didn't jump to line 1511 because the condition on line 1510 was never true

1511 return 

1512 list_keys = { 

1513 _query_key(name, field) 

1514 for name, field in schema_list_params.model_fields.items() 

1515 } 

1516 reject_unknown_query_keys(request, list_keys | set(self.extra_query_params)) 

1517 

1518 def to_single_response(self, obj: ModelT | ResponseSchemaT) -> ResponseSchemaT: 

1519 """Serialize one ORM object to the view's :attr:`schema_response`. 

1520 

1521 By default the response class is the view's schema without its 

1522 WriteOnly fields, named like ``UserResponse``. An override may return 

1523 an instance of another model, such as :attr:`schema`. Only the fields 

1524 of the response class go out. Restly copies the values of an instance 

1525 of a subclass of the response class, and of :attr:`schema` when 

1526 Restly derived the response class, without validating them again. It 

1527 validates any other model into the response class from its 

1528 attributes. 

1529 

1530 The view loads only the relationships that the response class names. 

1531 An override that builds :attr:`schema` from the ORM object also reads 

1532 the relationships of its WriteOnly fields. On an async view that fails 

1533 with ``MissingGreenlet``: add them in 

1534 :meth:`get_relationship_loader_options`, or build the response class 

1535 instead. 

1536 

1537 The ORM path below validates through the response class, so a view's 

1538 schema that declares a WriteOnly field the ORM object doesn't carry 

1539 (e.g. ``password`` backed by a ``password_hash`` column) doesn't fail 

1540 response validation. 

1541 """ 

1542 schema_response = _schema_response(type(self)) 

1543 response = _as_response(schema_response, obj) 

1544 if isinstance(response, schema_response): 

1545 return cast(ResponseSchemaT, response) 

1546 

1547 # Build a payload of raw attribute values keyed by schema field name; 

1548 # re-validating it below serializes each field through its own type. The 

1549 # response schema's ``from_attributes`` config resolves nested schemas and 

1550 # reference types (IDRef/IDSchema) straight from the ORM rows, with no 

1551 # view-layer special-casing. Alias rendering happens when FastAPI 

1552 # serializes the response model. 

1553 payload: dict[str, Any] = {} 

1554 for field_name, field_info in schema_response.model_fields.items(): 

1555 if hasattr(obj, field_name): 

1556 payload[field_name] = getattr(obj, field_name) 

1557 elif field_info.alias and hasattr(obj, field_info.alias): 1557 ↛ 1558line 1557 didn't jump to line 1558 because the condition on line 1557 was never true

1558 payload[field_name] = getattr(obj, field_info.alias) 

1559 

1560 adapter = _response_adapter(schema_response) 

1561 return cast( 

1562 ResponseSchemaT, 

1563 adapter.validate_python(payload, by_alias=False, by_name=True), 

1564 ) 

1565 

1566 def _single_response(self, obj: Any) -> pydantic.BaseModel: 

1567 """One object as an instance of the response class. An override of 

1568 :meth:`to_single_response` may return an instance of another model.""" 

1569 return _as_response(_schema_response(type(self)), self.to_single_response(obj)) 

1570 

1571 @staticmethod 

1572 def _to_query_params(list_params: Any) -> QueryParams: 

1573 if isinstance(list_params, QueryParams): 

1574 return list_params 

1575 if isinstance(list_params, pydantic.BaseModel): 

1576 dumped = list_params.model_dump( 

1577 exclude_none=True, by_alias=True, mode="json" 

1578 ) 

1579 return QueryParams({k: str(v) for k, v in dumped.items()}) 

1580 if isinstance(list_params, dict): 1580 ↛ 1582line 1580 didn't jump to line 1582 because the condition on line 1580 was always true

1581 return QueryParams({k: str(v) for k, v in list_params.items()}) 

1582 return QueryParams(list_params) 

1583 

1584 def to_list_response(self, list_result: ListResult[ModelT]) -> Any: 

1585 """Build the list response body: an instance of the envelope model. 

1586 

1587 The view fills its :attr:`pagination`'s ``envelope``: by default a 

1588 :class:`PaginatedEnvelope` (``data`` plus ``total_count`` / ``page`` / 

1589 ``page_size`` / ``total_pages``), or an :class:`Envelope` (``data`` 

1590 only) for a view without pagination. The route's ``response_model`` is 

1591 the same envelope, fixed from :attr:`pagination` at registration. To 

1592 change the shape, set the pagination's ``envelope``. For a 

1593 ``Content-Range`` header, replace ``get_many_endpoint`` with a matching 

1594 ``response_model`` (as :class:`AsyncReactAdminView` does), not by 

1595 overriding this method alone, which would fail response validation. 

1596 

1597 The page and page size come from ``list_result.list_params``. 

1598 """ 

1599 data = [self._single_response(obj) for obj in list_result.objects] 

1600 pagination = self.pagination 

1601 envelope = derive_schema_list_response( 

1602 _schema_response(type(self)), pagination=pagination 

1603 ) 

1604 if not isinstance(pagination, NumberedPagination): 

1605 return _build_envelope(envelope, _unpaginated_page_info(data)) 

1606 params = self._to_query_params(list_result.list_params) 

1607 page_info = _numbered_page_info( 

1608 total_count=list_result.total_count or 0, 

1609 page=int(params.get(pagination.page_query_param) or 1), 

1610 page_size=int( 

1611 params.get(pagination.page_size_query_param) 

1612 or pagination.default_page_size 

1613 ), 

1614 ) 

1615 return _build_envelope(envelope, {"data": data, **page_info}) 

1616 

1617 def to_response( 

1618 self, result: Any, shape: ResponseShape = ResponseShape.SINGLE 

1619 ) -> Any: 

1620 """Endpoint-method response boundary. 

1621 

1622 ``shape`` selects the wire form, and with it what ``result`` is: one 

1623 object for ``SINGLE`` (:meth:`to_single_response`), a 

1624 :class:`ListResult` for ``LIST`` (:meth:`to_list_response`), and 

1625 nothing for ``EMPTY``. A plain Python list is not a valid ``result``. 

1626 ``shape`` is not the write-action name. Override for envelopes or 

1627 shape-wide status behavior; per-endpoint projections belong in the 

1628 endpoint method. 

1629 """ 

1630 if shape is ResponseShape.EMPTY: 

1631 return fastapi.Response(status_code=204) 

1632 if shape is ResponseShape.LIST: 

1633 return self.to_list_response(result) 

1634 return self._single_response(result) 

1635 

1636 def snapshot(self, obj: Any) -> dict[str, Any]: 

1637 """Frozen capture of an object's already-loaded column values, passed as 

1638 ``old`` to ``before_action_commit`` / ``after_action_commit`` for dirty detection. 

1639 Override to change what ``old`` captures (e.g. include a relationship's 

1640 prior state); the default delegates to 

1641 :func:`~fastapi_restly.objects.snapshot`. 

1642 """ 

1643 return _object_snapshot(obj) 

1644 

1645 @classmethod 

1646 def _check_declared_schema_list_params(cls) -> None: 

1647 """Reject declared list params that ``derive_schema_list_params`` made 

1648 for other pagination settings: they read other page parameters than 

1649 the view applies, or none. Hand-written list params are the author's 

1650 to keep in step.""" 

1651 created_with = _created_with(cls.schema_list_params) 

1652 if created_with is _UNKNOWN: 

1653 return 

1654 if _query_settings(created_with) != _query_settings(cls.pagination): 

1655 raise RestlyConfigurationError( 

1656 f"{cls.__name__}.schema_list_params was created for other " 

1657 "pagination settings than the view's. Create it with " 

1658 f"pagination={cls.__name__}.pagination, or leave it out to " 

1659 "have it generated." 

1660 ) 

1661 

1662 @classmethod 

1663 def before_include_view(cls): 

1664 """ 

1665 Apply type annotations needed for FastAPI, before creating an APIRouter from 

1666 this view and registering it. 

1667 

1668 This function can be overridden to further tweak the endpoints before they 

1669 are added to FastAPI. 

1670 """ 

1671 _check_pagination_setting(cls) 

1672 _check_renamed_names(cls) 

1673 

1674 # A declared schema is inherited like any attribute. One that Restly 

1675 # generated for a parent is rebuilt, since the subclass may change 

1676 # what it was derived from. 

1677 if _needs_generating(cls, "schema"): 

1678 if not hasattr(cls, "model"): 1678 ↛ 1679line 1678 didn't jump to line 1679 because the condition on line 1678 was never true

1679 raise ValueError( 

1680 f"'{cls.__name__}.model' must be specified to auto-generate schema" 

1681 ) 

1682 cls.schema = derive_schema(cls.model) 

1683 _mark_generated(cls, "schema") 

1684 

1685 if _needs_generating(cls, "schema_response"): 

1686 cls.schema_response = derive_schema_response(cls.schema) 

1687 _mark_generated(cls, "schema_response") 

1688 

1689 if _needs_generating(cls, "schema_list_params"): 

1690 if not hasattr(cls, "model"): 

1691 raise ValueError( 

1692 f"'{cls.__name__}.model' must be specified: it is needed to " 

1693 "generate the list params." 

1694 ) 

1695 cls.schema_list_params = derive_schema_list_params( 

1696 cls.schema_response, cls.model, pagination=cls.pagination 

1697 ) 

1698 _mark_generated(cls, "schema_list_params") 

1699 else: 

1700 cls._check_declared_schema_list_params() 

1701 if _needs_generating(cls, "schema_create"): 

1702 cls.schema_create = cast( 

1703 type[CreateSchemaT], 

1704 create_model_without_read_only_fields( 

1705 cls.schema, omit=_generated_primary_key_fields(cls.model) 

1706 ), 

1707 ) 

1708 _mark_generated(cls, "schema_create") 

1709 if _needs_generating(cls, "schema_update"): 

1710 cls.schema_update = cast( 

1711 type[UpdateSchemaT], 

1712 create_model_with_optional_fields( 

1713 cls.schema, omit=_generated_primary_key_fields(cls.model) 

1714 ), 

1715 ) 

1716 _mark_generated(cls, "schema_update") 

1717 

1718 response_schema = cls.schema_response 

1719 id_type = _view_id_type(cls) 

1720 if id_type is int: 

1721 item_path = "/{id:int}" 

1722 elif id_type is UUID: 

1723 item_path = "/{id:uuid}" 

1724 else: 

1725 item_path = "/{id}" 

1726 for endpoint in cls.__dict__.values(): 

1727 if getattr(endpoint, "_fr_typed_id_route", False): 

1728 _, route_kwargs = endpoint._api_route_args 

1729 endpoint._api_route_args = (item_path, route_kwargs.copy()) 

1730 

1731 # Only annotate if the methods exist (they will be overridden in subclasses) 

1732 list_response_annotation: Any = derive_schema_list_response( 

1733 response_schema, pagination=cls.pagination 

1734 ) 

1735 

1736 # Every route that declares ``list_params`` takes the view's list 

1737 # params: ``GET /`` and any custom list route alike, guarded the same 

1738 # way. The guard checks keys against ``schema_list_params`` whatever 

1739 # the annotation says, so they replace an explicit annotation too. A 

1740 # dependency reads them rather than a FastAPI query model, which 

1741 # collapses next to any other query parameter (see 

1742 # :func:`_list_params_dependency`). 

1743 list_params = Annotated[ 

1744 cls.schema_list_params, 

1745 fastapi.Depends(_list_params_dependency(cls.schema_list_params)), 

1746 ] 

1747 for name, route in list(cls.__dict__.items()): 

1748 if not hasattr(route, "_api_route_args"): 

1749 continue 

1750 if "list_params" not in _route_parameters(route): 

1751 continue 

1752 if not getattr(route, "_fr_list_guard", False): 

1753 route = _guard_list_params(route) 

1754 setattr(cls, name, route) 

1755 _annotate(route, overwrite=True, list_params=list_params) 

1756 

1757 # The ``*_endpoint`` methods are defined on AsyncRestView/RestView 

1758 # subclasses and may be excluded by ``exclude_routes``, so they aren't 

1759 # visible on BaseRestView. ``getattr`` keeps pyright happy without 

1760 # falsely advertising them on the base class. 

1761 if (ep := getattr(cls, "get_many_endpoint", None)) is not None: 1761 ↛ 1763line 1761 didn't jump to line 1763 because the condition on line 1761 was always true

1762 _annotate(ep, return_annotation=list_response_annotation) 

1763 if (ep := getattr(cls, "get_one_endpoint", None)) is not None: 1763 ↛ 1765line 1763 didn't jump to line 1765 because the condition on line 1763 was always true

1764 _annotate(ep, return_annotation=response_schema, id=id_type) 

1765 if (ep := getattr(cls, "create_endpoint", None)) is not None: 1765 ↛ 1769line 1765 didn't jump to line 1769 because the condition on line 1765 was always true

1766 _annotate( 

1767 ep, return_annotation=response_schema, schema_obj=cls.schema_create 

1768 ) 

1769 if (ep := getattr(cls, "update_endpoint", None)) is not None: 1769 ↛ 1776line 1769 didn't jump to line 1776 because the condition on line 1769 was always true

1770 _annotate( 

1771 ep, 

1772 return_annotation=response_schema, 

1773 schema_obj=cls.schema_update, 

1774 id=id_type, 

1775 ) 

1776 if (ep := getattr(cls, "delete_endpoint", None)) is not None: 1776 ↛ 1778line 1776 didn't jump to line 1778 because the condition on line 1776 was always true

1777 _annotate(ep, return_annotation=fastapi.Response, id=id_type) 

1778 _exclude_routes(cls) 

1779 

1780 

1781#: What registration builds each class of a view from. 

1782_BUILT_FROM = { 

1783 "schema": "model", 

1784 "schema_response": "schema", 

1785 "schema_list_params": "schema_response", 

1786 "schema_create": "schema", 

1787 "schema_update": "schema", 

1788} 

1789 

1790 

1791def _mark_generated(cls: type, name: str) -> None: 

1792 generated = cls.__dict__.get("_fr_generated", frozenset()) 

1793 cls._fr_generated = generated | {name} # type: ignore[attr-defined] 

1794 

1795 

1796def _declared_at(cls: type, name: str) -> int | None: 

1797 """Position in the MRO of the class that declares ``name``, or None. 

1798 

1799 A value that registration generated is not a declaration. 

1800 """ 

1801 for index, klass in enumerate(cls.__mro__): 

1802 if name in klass.__dict__ and name not in klass.__dict__.get( 

1803 "_fr_generated", () 

1804 ): 

1805 return index 

1806 return None 

1807 

1808 

1809def _decided_at(cls: type, name: str) -> int | None: 

1810 """Position in the MRO of the class that decides the value of ``name``. 

1811 

1812 A declared value is decided where it is declared, unless what it is built 

1813 from is decided nearer to ``cls``: then registration rebuilds it. A 

1814 generated value is decided where its source is decided. So a copy that 

1815 registration wrote into a subclass does not count as a change. 

1816 """ 

1817 declared = _declared_at(cls, name) 

1818 source = _BUILT_FROM.get(name) 

1819 if source is None: 

1820 return declared 

1821 source_at = _decided_at(cls, source) 

1822 if declared is None: 

1823 return source_at 

1824 # A declared schema stays, whatever model a subclass sets. 

1825 if name != "schema" and source_at is not None and source_at < declared: 

1826 return source_at 

1827 return declared 

1828 

1829 

1830def _needs_generating(cls: type, name: str) -> bool: 

1831 """Whether registration must build ``name`` for this view class. 

1832 

1833 A declared value is inherited. Registration builds it when no class 

1834 declares it, or when what it is built from is decided nearer to ``cls``, 

1835 as in a subclass that declares a new ``schema``. 

1836 """ 

1837 if name in cls.__dict__.get("_fr_generated", ()): 

1838 return False 

1839 declared = _declared_at(cls, name) 

1840 return declared is None or _decided_at(cls, name) != declared 

1841 

1842 

1843def _schema_response(view_cls: type) -> type[pydantic.BaseModel]: 

1844 """The response class of a view. 

1845 

1846 Registration sets ``schema_response``. A view that is not registered 

1847 yet, or a subclass that changes ``schema``, gets the class that 

1848 registration would set. 

1849 """ 

1850 if _needs_generating(view_cls, "schema_response"): 

1851 return derive_schema_response(view_cls.schema) 

1852 return view_cls.schema_response 

1853 

1854 

1855#: A ``BaseRestView`` at any parameterization, for the signature below. 

1856_AnyRestView = BaseRestView[Any, Any, Any, Any, Any] 

1857 

1858 

1859def resolve_scope(target: type[Any] | _AnyRestView) -> WhereClause | Unscoped: 

1860 """The scope a read applies, resolved down the ladder. 

1861 

1862 Pass a view, class or instance, for the visibility that view's reads 

1863 apply: its declared 

1864 :attr:`scope <fastapi_restly.views.BaseRestView.scope>`, the model's 

1865 ``default_scope`` when the view declares none, and 

1866 ``fr.clauses.UNSCOPED`` when neither does. Pass a mapped model class 

1867 for the model rung alone, which is what every reference check applies. 

1868 The two answers differ wherever a view declares a scope, so code off 

1869 the request path that wants what the API shows passes the view. 

1870 

1871 The result is a clause or ``fr.clauses.UNSCOPED``, never ``None``, so 

1872 it composes without a branch:: 

1873 

1874 query = fr.apply_clauses( 

1875 select(Task).where(Task.project_id == id), 

1876 fr.resolve_scope(TaskView), 

1877 ) 

1878 

1879 Every read resolves here, so a route that applies the result sees the 

1880 same rows as 

1881 :meth:`get_one <fastapi_restly.views.RestView.get_one>` and 

1882 :meth:`get_many <fastapi_restly.views.RestView.get_many>`. It resolves 

1883 the scope and does not apply it: applying stays with the framework, 

1884 and there is no apply-side override point. 

1885 

1886 :param target: a view class or instance, or a mapped model class. 

1887 :raises RestlyConfigurationError: if the declared scope is not a clause. 

1888 :raises TypeError: if ``target`` is neither a view nor a mapped model. 

1889 """ 

1890 label = target.__name__ if isinstance(target, type) else type(target).__name__ 

1891 if is_mapped_class(target): 

1892 return _model_scope(target) 

1893 if not isinstance(target, BaseRestView) and not ( 

1894 isinstance(target, type) and issubclass(target, BaseRestView) 

1895 ): 

1896 raise TypeError( 

1897 "resolve_scope() takes a RestView / AsyncRestView class or instance, " 

1898 f"or a mapped model class, got {label}" 

1899 ) 

1900 declared = target.scope 

1901 if declared is None: 

1902 return _model_scope(target.model) 

1903 if declared is UNSCOPED: 

1904 return UNSCOPED 

1905 # backstop for a post-definition assignment; the declared form is 

1906 # validated in __init_subclass__ 

1907 return _checked_scope(declared, label, "the scope") 

1908 

1909 

1910def _model_scope(model: type[Any]) -> WhereClause | Unscoped: 

1911 declared = _default_scope(model) 

1912 return UNSCOPED if declared is None else declared 

1913 

1914 

1915def _checked_scope(scope: Any, label: str, what: str) -> WhereClause | Unscoped: 

1916 if scope is UNSCOPED or isinstance(scope, WhereClause): 

1917 return scope 

1918 raise RestlyConfigurationError( 

1919 f"{label}: {what} must be a WhereClause or fr.clauses.UNSCOPED, got " 

1920 f"{type(scope).__name__}; wrap a raw expression with where_clause()" 

1921 ) 

1922 

1923 

1924def _checked_where(where: Any, label: str) -> WhereClause | Unscoped: 

1925 if where is UNSCOPED or isinstance(where, WhereClause): 

1926 return where 

1927 if isinstance(where, ColumnElement): 

1928 return where_clause(where) 

1929 if type(where) is bool: 

1930 # ``obj.slug == slug`` on a loaded object is a Python bool, and 

1931 # SQLAlchemy renders it as WHERE true, which narrows nothing 

1932 raise TypeError( 

1933 f"{label}: where= got a bool, not a SQL expression. A comparison " 

1934 "on an instance attribute is a bool; compare the column instead " 

1935 "(Model.slug == slug)." 

1936 ) 

1937 raise TypeError( 

1938 f"{label}: where= must be a SQLAlchemy boolean expression or a clause, " 

1939 f"got {type(where).__name__}" 

1940 ) 

1941 

1942 

1943def _identity_criterion(model_cls: type[Any], id: Any) -> ColumnElement[bool]: 

1944 """The predicate that picks the one row ``get_one`` loads. 

1945 

1946 The primary key is the default: ``get_one(5)`` means ``pk == 5``. A 

1947 SQLAlchemy boolean expression replaces that default, so a natural-key 

1948 route loads through the same scope, loader options and 404, and a 

1949 composite key is addressable at all. The criterion narrows inside the 

1950 scope; only ``scope=`` replaces what the view can see. 

1951 """ 

1952 if isinstance(id, ColumnElement): 

1953 return id 

1954 if isinstance(id, WhereClause): 

1955 raise TypeError( 

1956 f"{model_cls.__name__}: a clause is a scope, not a row identity. " 

1957 "Call it for its expression (ItemClauses.published()), or " 

1958 "pass it as scope= to replace the view scope for this read." 

1959 ) 

1960 if type(id) is bool: 

1961 # ``obj.slug == slug`` on a loaded object is a Python bool, and 

1962 # SQLAlchemy renders it as WHERE true, which matches the whole scope 

1963 raise TypeError( 

1964 f"{model_cls.__name__}: got a bool, not an id or a SQL expression. " 

1965 "A comparison on an instance attribute is a bool; compare the " 

1966 "column instead (Model.slug == slug)." 

1967 ) 

1968 pk_cols = sa_inspect(model_cls).primary_key 

1969 if len(pk_cols) != 1: 

1970 raise NotImplementedError( 

1971 f"{model_cls.__name__} has a composite primary key, so an id does " 

1972 "not name one row; pass a predicate instead, e.g. " 

1973 "get_one(sqlalchemy.and_(Model.a == a, Model.b == b))." 

1974 ) 

1975 return pk_cols[0] == id 

1976 

1977 

1978def _not_found_message(model_cls: type[Any], id: Any) -> str: 

1979 # a predicate is not echoed: the 404 body is no place for SQL 

1980 if isinstance(id, ColumnElement): 

1981 return f"{model_cls.__name__} was not found" 

1982 return f"{model_cls.__name__} with id {id!r} was not found" 

1983 

1984 

1985def reject_unknown_query_keys(request: fastapi.Request, allowed: set[str]) -> None: 

1986 """Raise a 422 naming every query key that is not in ``allowed``. 

1987 

1988 The dialect decides what is allowed (the list params for the default 

1989 one, ``sort``/``range``/``filter`` for react-admin); the envelope is the 

1990 same either way, and mirrors FastAPI's own validation shape. A key that 

1991 the route reads elsewhere is never unknown (see :func:`_route_query_keys`). 

1992 """ 

1993 unknown = set(request.query_params.keys()) - allowed 

1994 if unknown: 

1995 unknown -= _route_query_keys(request) 

1996 if not unknown: 

1997 return 

1998 detail = [ 

1999 { 

2000 "type": "extra_forbidden", 

2001 "loc": ["query", key], 

2002 "msg": f"Unknown query parameter {key!r}", 

2003 "input": request.query_params.get(key), 

2004 } 

2005 for key in sorted(unknown) 

2006 ] 

2007 raise fastapi.HTTPException(status_code=422, detail=detail) 

2008 

2009 

2010def _route_query_keys(request: fastapi.Request) -> set[str]: 

2011 """The query keys that the matched route reads. 

2012 

2013 Every query parameter its dependency tree declares, at any level: the 

2014 app or a router, the view's ``dependencies``, a class attribute, the 

2015 endpoint. Also the key of a query API key scheme such as 

2016 ``APIKeyQuery``, which reads the query string without declaring a 

2017 parameter. The guard walks the tree only for a key that its dialect 

2018 does not allow. 

2019 """ 

2020 dependant = getattr(matched_route(request.scope), "dependant", None) 

2021 pending = [dependant] if dependant is not None else [] 

2022 keys: set[str] = set() 

2023 while pending: 

2024 current = pending.pop() 

2025 for field in current.query_params: 

2026 # the name FastAPI reads the value from 

2027 keys.add(getattr(field, "validation_alias", None) or field.alias) 

2028 call = current.call 

2029 if isinstance(call, APIKeyBase) and call.model.in_ == APIKeyIn.query: 

2030 keys.add(call.model.name) 

2031 pending.extend(current.dependencies) 

2032 return keys 

2033 

2034 

2035def _list_params_dependency( 

2036 params_model: type[pydantic.BaseModel], 

2037) -> Callable[..., pydantic.BaseModel]: 

2038 """A dependency that validates ``params_model`` from the query string. 

2039 

2040 FastAPI splits a query model into its fields only when it is the sole 

2041 query parameter: at runtime per function, in OpenAPI across the route. 

2042 Any other query parameter therefore collapses it into one required 

2043 ``list_params`` value. This reads the fields itself, the way FastAPI 

2044 hands a query model its input: every value of a repeated key for a 

2045 sequence field, the last value otherwise, a default that is not None, 

2046 and any other key as sent. :func:`_list_openapi_parameters` 

2047 documents the fields. 

2048 """ 

2049 fields = [ 

2050 (_query_key(name, field), field, _reads_every_value(field)) 

2051 for name, field in params_model.model_fields.items() 

2052 ] 

2053 field_keys = {key for key, _, _ in fields} 

2054 

2055 def list_params(request: fastapi.Request, first: Any = None) -> pydantic.BaseModel: 

2056 query = request.query_params 

2057 data: dict[str, Any] = {} 

2058 for key, field, every_value in fields: 

2059 if key in query: 

2060 data[key] = query.getlist(key) if every_value else query[key] 

2061 elif not field.is_required(): 2061 ↛ 2058line 2061 didn't jump to line 2058 because the condition on line 2061 was always true

2062 default = field.get_default() 

2063 if default is not None: 

2064 data[key] = default 

2065 for key in query.keys(): 

2066 if key not in field_keys: 

2067 values = query.getlist(key) 

2068 data[key] = values[0] if len(values) == 1 else values 

2069 try: 

2070 return params_model.model_validate(data) 

2071 except pydantic.ValidationError as exc: 

2072 errors = [ 

2073 {**error, "loc": ("query", *error["loc"])} 

2074 for error in exc.errors(include_url=False) 

2075 ] 

2076 raise RequestValidationError(errors) from None 

2077 

2078 parameters = [ 

2079 inspect.Parameter( 

2080 "request", inspect.Parameter.KEYWORD_ONLY, annotation=fastapi.Request 

2081 ) 

2082 ] 

2083 if fields: 

2084 # FastAPI documents a route's 422 only when it reads a parameter of 

2085 # the route itself. It reads the first key here, undocumented and 

2086 # unchecked; the model validates the value with the rest. 

2087 marker = fastapi.Query(alias=fields[0][0], include_in_schema=False) 

2088 parameters.append( 

2089 inspect.Parameter( 

2090 "first", 

2091 inspect.Parameter.KEYWORD_ONLY, 

2092 default=None, 

2093 annotation=Annotated[Any, marker], 

2094 ) 

2095 ) 

2096 list_params.__signature__ = inspect.Signature(parameters) # type: ignore[attr-defined] 

2097 return list_params 

2098 

2099 

2100def _query_key(name: str, field: FieldInfo) -> str: 

2101 """The query key of a list params field: its validation alias, its alias, or 

2102 its name, as FastAPI reads a query model.""" 

2103 if isinstance(field.validation_alias, str): 

2104 return field.validation_alias 

2105 return field.alias or name 

2106 

2107 

2108def _reads_every_value(field: FieldInfo) -> bool: 

2109 """Whether a list params field reads every value of a repeated query key. 

2110 

2111 FastAPI's rule for a query model: a sequence or set type, alone or in a 

2112 union, unless the value is JSON. 

2113 """ 

2114 if any(isinstance(item, pydantic.Json) for item in field.metadata): 2114 ↛ 2115line 2114 didn't jump to line 2115 because the condition on line 2114 was never true

2115 return False 

2116 return _is_sequence_annotation(field.annotation) 

2117 

2118 

2119def _is_sequence_annotation(annotation: Any) -> bool: 

2120 if get_origin(annotation) in (Union, types.UnionType): 

2121 return any(_is_sequence_annotation(arg) for arg in get_args(annotation)) 

2122 return any( 

2123 isinstance(candidate, type) 

2124 and get_origin(candidate) is None 

2125 and issubclass(candidate, (Sequence, Set)) 

2126 and not issubclass(candidate, (str, bytes)) 

2127 for candidate in (annotation, get_origin(annotation)) 

2128 ) 

2129 

2130 

2131def _list_openapi_parameters( 

2132 params_model: type[pydantic.BaseModel], 

2133) -> list[dict[str, Any]]: 

2134 """One OpenAPI query parameter per field of ``params_model``. 

2135 

2136 The entries FastAPI writes for a query model it splits into fields, so 

2137 the list route documents the same whatever else the route reads. Built from 

2138 the model's JSON schema, with references inlined: the document's 

2139 components do not hold the model's definitions. 

2140 """ 

2141 schema = params_model.model_json_schema() 

2142 definitions = schema.get("$defs", {}) 

2143 properties = schema.get("properties", {}) 

2144 required = set(schema.get("required", ())) 

2145 parameters: list[dict[str, Any]] = [] 

2146 for name, field in params_model.model_fields.items(): 

2147 key = _query_key(name, field) 

2148 field_schema = _inline_definitions( 

2149 properties.get(key, properties.get(name, {})), definitions 

2150 ) 

2151 field_schema["title"] = field.title or key.title().replace("_", " ") 

2152 parameter: dict[str, Any] = { 

2153 "name": key, 

2154 "in": "query", 

2155 "required": key in required, 

2156 "schema": field_schema, 

2157 } 

2158 if field.description: 

2159 parameter["description"] = field.description 

2160 if field.deprecated: 2160 ↛ 2161line 2160 didn't jump to line 2161 because the condition on line 2160 was never true

2161 parameter["deprecated"] = True 

2162 parameters.append(parameter) 

2163 return parameters 

2164 

2165 

2166def _inline_definitions(schema: Any, definitions: dict[str, Any]) -> Any: 

2167 """``schema`` with each ``#/$defs/...`` reference replaced by its target.""" 

2168 if isinstance(schema, list): 

2169 return [_inline_definitions(item, definitions) for item in schema] 

2170 if not isinstance(schema, dict): 

2171 return schema 

2172 inlined = { 

2173 key: _inline_definitions(value, definitions) 

2174 for key, value in schema.items() 

2175 if key != "$ref" 

2176 } 

2177 reference = schema.get("$ref") 

2178 if isinstance(reference, str) and reference.startswith("#/$defs/"): 2178 ↛ 2179line 2178 didn't jump to line 2179 because the condition on line 2178 was never true

2179 target = definitions[reference.removeprefix("#/$defs/")] 

2180 return {**_inline_definitions(target, definitions), **inlined} 

2181 return inlined 

2182 

2183 

2184def _guard_list_params(route: Callable) -> Callable: 

2185 """Run the unknown-key guard before a route that takes ``list_params``. 

2186 

2187 A copy, like the parent-endpoint copies: ``functools.wraps`` carries the 

2188 route args and the signature, and the marker keeps a subclass's copy 

2189 from being wrapped twice. 

2190 """ 

2191 if inspect.iscoroutinefunction(route): 

2192 

2193 @functools.wraps(route) 

2194 async def _async_guarded(self, *args, **kwargs): 

2195 self._reject_unknown_query_params() 

2196 return await route(self, *args, **kwargs) 

2197 

2198 guarded: Callable = _async_guarded 

2199 else: 

2200 

2201 @functools.wraps(route) 

2202 def _sync_guarded(self, *args, **kwargs): 

2203 self._reject_unknown_query_params() 

2204 return route(self, *args, **kwargs) 

2205 

2206 guarded = _sync_guarded 

2207 

2208 guarded._fr_list_guard = True # type: ignore[attr-defined] 

2209 return guarded 

2210 

2211 

2212def _exclude_routes(cls: type[BaseRestView[Any, Any, Any, Any, Any]]): 

2213 for route_name in cls.exclude_routes: 

2214 method_name = ( 

2215 route_name.value if isinstance(route_name, ViewRoute) else route_name 

2216 ) 

2217 # @route decorator adds `_api_route_args` to a method to create the route later. 

2218 # By removing it from the method, the method will no longer be added as a route. 

2219 view_func = getattr(cls, method_name, None) 

2220 if view_func is not None and hasattr(view_func, "_api_route_args"): 

2221 del view_func._api_route_args 

2222 continue 

2223 # Not a live route on this class. Tolerate an exclusion that is *already* 

2224 # satisfied: a subclass that inherits ``exclude_routes`` from a parent 

2225 # which already excluded the route never receives a routable copy, so 

2226 # there is nothing to strip. The name is still a genuine route elsewhere 

2227 # in the lineage; only raise when it is no route at all (a typo, or the 

2228 # business method name instead of the ``*_endpoint`` route name). 

2229 if not _is_route_name_in_lineage(cls, method_name): 

2230 raise AttributeError(f"{method_name!r} is not a route on {cls.__name__}") 

2231 

2232 

2233def _is_route_name_in_lineage( 

2234 cls: type[BaseRestView[Any, Any, Any, Any, Any]], method_name: str 

2235) -> bool: 

2236 """True if any class in ``cls``'s MRO defines a routable endpoint of this 

2237 name, so the name is a real route that may merely be already-excluded here. 

2238 """ 

2239 return any( 

2240 hasattr(klass.__dict__.get(method_name), "_api_route_args") 

2241 for klass in cls.mro() 

2242 ) 

2243 

2244 

2245def _init_view_cls_and_add_to_router( 

2246 view_cls: type[View], parent_router: fastapi.APIRouter | fastapi.FastAPI 

2247): 

2248 """ 

2249 FastAPI builds routes from callable signatures and annotations. View classes 

2250 need a small adapter layer before registration; that preparation happens here. 

2251 

2252 FastAPI does a lot with annotations. For example, accepted or returned JSON is 

2253 often described with Pydantic classes like this: 

2254 

2255 def my_endpoint(foo: FooSchema) -> FooSchema: 

2256 

2257 Most of this preparation sets the correct annotations on inherited class 

2258 methods. 

2259 

2260 The class-level preparation (copying parent endpoints, renaming, annotating, 

2261 schema generation, dataclass-style __init__) only runs once per View class — 

2262 subsequent calls to ``include_view()`` reuse the prepared class and only 

2263 construct a fresh APIRouter to mount on the new parent. This makes 

2264 registering the same view on *different* routers safe (e.g. a public app and 

2265 an admin app, or ``/v1`` and ``/v2`` sub-apps). 

2266 

2267 Re-registering the same view on the *same* parent is a no-op (flagged by 

2268 the opt-in misuse lint): each parent tracks the view classes already 

2269 mounted on it, so a duplicate ``include_view()`` call (double import, 

2270 decorator plus explicit call) cannot mount the same routes twice. 

2271 """ 

2272 # A FastAPI app delegates include_router to its .router; track mounted 

2273 # views on the router so app and app.router count as one parent. 

2274 holder = ( 

2275 parent_router.router 

2276 if isinstance(parent_router, fastapi.FastAPI) 

2277 else parent_router 

2278 ) 

2279 mounted: set[type[View]] = getattr(holder, "_fr_mounted_views", None) or set() 

2280 if view_cls in mounted: 

2281 if _fr_globals.warn_on_misuse: 

2282 warnings.warn( 

2283 f"{view_cls.__name__} is already registered on this app/router; " 

2284 f"this include_view() call is a no-op. Duplicate registration " 

2285 f"usually means a double import or the decorator form combined " 

2286 f"with an explicit include_view() call.", 

2287 RestlyMisuseWarning, 

2288 stacklevel=4, 

2289 ) 

2290 return 

2291 _prepare_view_class(view_cls) 

2292 api_router = _init_api_router(view_cls) 

2293 _register_for_resource_ref(parent_router, view_cls) 

2294 parent_router.include_router(api_router) 

2295 # Record only after the mount succeeded: a failed registration must not be 

2296 # marked as mounted, or the next call would silently no-op a view whose 

2297 # routes never made it onto the parent. 

2298 mounted.add(view_cls) 

2299 setattr(holder, "_fr_mounted_views", mounted) 

2300 # Fallback registration for users who skip ``fr.configure(app=...)``. 

2301 # ``register_default_exception_handlers`` is idempotent and only acts on 

2302 # FastAPI apps (it ignores nested APIRouter parents). 

2303 if isinstance(parent_router, fastapi.FastAPI): 

2304 register_default_exception_handlers(parent_router) 

2305 

2306 

2307#: Bare business-method names. A ``@route``-decorated method must not be 

2308#: named like one of these: it would shadow the verb (which the ``handle_<verb>`` 

2309#: handlers call) and collide with ``<verb>_endpoint`` at the same path. 

2310#: Override the business method *without* a decorator for domain logic; use 

2311#: ``<verb>_endpoint`` or a distinct name for a custom route. 

2312_BARE_VERB_NAMES = frozenset({"get_many", "get_one", "create", "update", "delete"}) 

2313 

2314 

2315def _reject_bare_verb_route_names(view_cls: type[View]) -> None: 

2316 for name, value in view_cls.__dict__.items(): 

2317 if name in _BARE_VERB_NAMES and hasattr(value, "_api_route_args"): 

2318 raise TypeError( 

2319 f"{view_cls.__name__}.{name}() is a route method named like the " 

2320 f"business method '{name}'. A route by that name shadows the " 

2321 f"method and collides with the '{name}_endpoint' endpoint " 

2322 f"method. Rename it to '{name}_endpoint' (to replace it) or " 

2323 f"give the custom action its own name." 

2324 ) 

2325 

2326 

2327#: The five endpoint methods defined by RestView / AsyncRestView. 

2328_ENDPOINT_METHOD_NAMES = frozenset( 

2329 { 

2330 "get_many_endpoint", 

2331 "get_one_endpoint", 

2332 "create_endpoint", 

2333 "update_endpoint", 

2334 "delete_endpoint", 

2335 } 

2336) 

2337 

2338 

2339def _warn_on_misuse(view_cls: type[View]) -> None: 

2340 """Opt-in registration-time lint (``fr.configure(warn_on_misuse=True)``). 

2341 

2342 Flags the dominant misuse patterns with the idiomatic fix named in 

2343 each message. Heuristic, best-effort, and advisory: every pattern it flags 

2344 has a legitimate use, so it warns (:class:`RestlyMisuseWarning`) rather 

2345 than rejects. Must run *before* parent endpoints are copied into the 

2346 subclass, while ``__dict__`` still holds only what the user wrote; only 

2347 the registered class is linted, not user-defined intermediate bases. 

2348 """ 

2349 own = view_cls.__dict__ 

2350 name = view_cls.__name__ 

2351 is_crud_view = issubclass(view_cls, BaseRestView) 

2352 

2353 # 1. Endpoint-method override where a business-method override was likely meant. 

2354 if is_crud_view: 

2355 for endpoint in sorted(_ENDPOINT_METHOD_NAMES & own.keys()): 

2356 verb = endpoint.removesuffix("_endpoint") 

2357 warnings.warn( 

2358 f"{name} overrides the endpoint method '{endpoint}'. Override " 

2359 f"an endpoint method only to change the HTTP contract. For " 

2360 f"domain logic override the business method '{verb}'; for a gate " 

2361 f"'authorize'; for the response shape 'to_response'. Call " 

2362 f"'handle_{verb}' from the replacement to keep the bracket.", 

2363 RestlyMisuseWarning, 

2364 stacklevel=5, 

2365 ) 

2366 

2367 # 2. Manual session.commit() in a view method. The framework owns the 

2368 # commit; methods that go through write_action / handle_<verb> are exempt. 

2369 for attr, value in own.items(): 

2370 func = getattr(value, "__func__", value) 

2371 if not isinstance(func, types.FunctionType): 

2372 continue 

2373 try: 

2374 source = inspect.getsource(func) 

2375 except (OSError, TypeError): 

2376 continue 

2377 if ( 

2378 ".commit(" in source 

2379 and "write_action" not in source 

2380 and "handle_" not in source 

2381 ): 

2382 warnings.warn( 

2383 f"{name}.{attr} calls session.commit() directly. The framework " 

2384 f"owns the commit: reuse handle_<verb>(), or bracket the " 

2385 f"mutation with write_action('<action>', ...) so authorize / " 

2386 f"before_action_commit / after_action_commit run.", 

2387 RestlyMisuseWarning, 

2388 stacklevel=5, 

2389 ) 

2390 

2391 # 3. A CRUD route set hand-rolled on a bare View. 

2392 if not is_crud_view: 

2393 http_methods: set[str] = set() 

2394 n_routes = 0 

2395 for value in own.values(): 

2396 route_args = getattr(value, "_api_route_args", None) 

2397 if route_args is None: 

2398 continue 

2399 _path, route_kwargs = route_args 

2400 n_routes += 1 

2401 http_methods.update( 

2402 method.upper() for method in route_kwargs.get("methods", ["GET"]) 

2403 ) 

2404 if ( 

2405 n_routes >= 3 

2406 and {"GET", "POST"} <= http_methods 

2407 and http_methods & {"PATCH", "PUT", "DELETE"} 

2408 ): 

2409 warnings.warn( 

2410 f"{name} hand-rolls a CRUD route set on a bare View. RestView / " 

2411 f"AsyncRestView already define list/create/get/update/delete " 

2412 f"endpoint methods. Subclass one and override the business methods " 

2413 f"(create/update/delete), declare a scope, or override " 

2414 f"authorize for custom behavior.", 

2415 RestlyMisuseWarning, 

2416 stacklevel=5, 

2417 ) 

2418 

2419 # 4. A reference type (IDRef/IDSchema) whose name is a scalar FK column: 

2420 # the ``post_id: IDRef[Post]`` mistake, where ``data.post_id`` becomes a 

2421 # wrapper instead of the plain id. 

2422 _warn_scalar_named_reference_fields(view_cls) 

2423 

2424 

2425def _warn_scalar_named_reference_fields(view_cls: type[View]) -> None: 

2426 """Flag an ``IDRef``/``IDSchema`` field whose name is a scalar foreign-key 

2427 column: ``post_id: IDRef[Post]`` makes ``data.post_id`` a reference wrapper 

2428 instead of the plain id. Steer to ``fr.MustExist[pk, Model]``. Best-effort: 

2429 lints only an explicitly-declared ``schema`` against the view's ``model``, 

2430 and never a relationship-named or list reference (those names aren't columns). 

2431 """ 

2432 schema = view_cls.__dict__.get("schema") 

2433 model = getattr(view_cls, "model", None) 

2434 if schema is None or model is None: 

2435 return 

2436 try: 

2437 mapper = sa_inspect(model) 

2438 except Exception: 

2439 return 

2440 for field_name, field_info in schema.model_fields.items(): 

2441 origin_target = reference_origin_and_target(field_info.annotation) 

2442 if origin_target is None: 

2443 continue 

2444 column = mapper.columns.get(field_name) 

2445 if column is None or not column.foreign_keys: 

2446 continue # a relationship-named or non-FK column is legitimate 

2447 origin, target = origin_target 

2448 ref_name = origin.__name__ 

2449 target_name = target.__name__ if target is not None else "Model" 

2450 try: 

2451 pk_name = column.type.python_type.__name__ 

2452 except Exception: 

2453 pk_name = "int" 

2454 rel = _relationship_name_for_fk(model, field_name) 

2455 rename = ( 

2456 f", or name the field `{rel}` for the {ref_name} relationship" 

2457 if rel 

2458 else "" 

2459 ) 

2460 warnings.warn( 

2461 f"{schema.__name__}.{field_name} is typed `{ref_name}[{target_name}]`, " 

2462 f"but `{field_name}` maps to the scalar foreign-key column " 

2463 f"`{model.__name__}.{field_name}`. An {ref_name} field resolves to an " 

2464 f"ORM object, so `data.{field_name}` is a reference wrapper, not the " 

2465 f"plain id. Use `{field_name}: fr.MustExist[{pk_name}, {target_name}]` " 

2466 f"for a checked scalar FK{rename}.", 

2467 RestlyMisuseWarning, 

2468 stacklevel=6, 

2469 ) 

2470 

2471 

2472def _reject_buried_markers_in_view_schemas(view_cls: type[View]) -> None: 

2473 """Backstop the ``BaseSchema`` import-time check at view registration. 

2474 

2475 ``BaseSchema.__pydantic_init_subclass__`` already rejects a buried 

2476 ReadOnly/WriteOnly marker as the schema class is defined, but a view may use 

2477 a schema (and derived create/update schemas) that does not subclass 

2478 ``BaseSchema``. Re-check the schemas this view actually uses so those are 

2479 covered too. 

2480 """ 

2481 checked: set[type] = set() 

2482 for attr in ("schema", "schema_response", "schema_create", "schema_update"): 

2483 schema = getattr(view_cls, attr, None) 

2484 if schema is None or schema in checked: 

2485 continue 

2486 checked.add(schema) 

2487 _reject_buried_markers(schema) 

2488 

2489 

2490def _prepare_view_class(view_cls: type[View]) -> None: 

2491 """Run the one-time class-level setup for a View. 

2492 

2493 Guarded by the ``_fr_initialised`` marker (stored in ``__dict__`` so it is 

2494 not inherited from a parent class that was registered separately). Calling 

2495 this multiple times is a no-op after the first run. 

2496 """ 

2497 if view_cls.__dict__.get("_fr_initialised", False): 

2498 return 

2499 if _fr_globals.warn_on_misuse: 

2500 _warn_on_misuse(view_cls) 

2501 _copy_all_parent_class_endpoints_into_this_subclass(view_cls) 

2502 _reject_bare_verb_route_names(view_cls) 

2503 _init_all_endpoints(view_cls) 

2504 view_cls.before_include_view() 

2505 _reject_buried_markers_in_view_schemas(view_cls) 

2506 _init_class_based_view(view_cls) 

2507 view_cls._fr_initialised = True # type: ignore[attr-defined] 

2508 

2509 

2510def _copy_all_parent_class_endpoints_into_this_subclass(view_cls: type[View]): 

2511 """ 

2512 Override all methods with a @route decorator of the parent classes of view_cls 

2513 with a new copy directly on view_cls . This allows us to change the 

2514 annotations on these endpoints without affecting the parent endpoints. 

2515 

2516 For example, FooView.get() delegates to AsyncRestView.get() if it is not 

2517 overridden (this is called implicit delegation through method resolution). And if 

2518 we add the annotation that FooView.get() returns FooSchema but do not make a copy 

2519 then AsyncRestView.get() and all other subclasses will get the FooSchema 

2520 annotation as well. 

2521 """ 

2522 for name, endpoint in _get_all_parent_endpoints(view_cls).items(): 

2523 # `name` is the attribute key (e.g. "get_many_endpoint"), which is stable 

2524 # across copies; `endpoint.__name__` may have been mangled by a parent's 

2525 # own registration (e.g. "parentview_get_many_endpoint"), so key off the 

2526 # attribute name throughout. 

2527 if name in view_cls.__dict__: 

2528 # This endpoint is already overridden! 

2529 continue 

2530 

2531 # The original endpoint might be shared between subclasses. 

2532 # So make a copy and put that on the view_cls. 

2533 endpoint_wrapper = _make_copy(endpoint, view_cls) 

2534 if getattr(endpoint, "__module__", "").startswith("fastapi_restly."): 

2535 # The shells carry override-redirect docstrings for help()/source 

2536 # readers. FastAPI reads endpoint.__doc__ as the OpenAPI operation 

2537 # description, so strip the copy: framework guidance must not leak 

2538 # into the user's API docs. User-defined endpoints keep theirs. 

2539 endpoint_wrapper.__doc__ = None 

2540 # Reset the copy's name to the endpoint attribute so downstream renaming 

2541 # produces "<view>_<name>" even when the source was a parent's renamed 

2542 # copy. 

2543 endpoint_wrapper.__name__ = name 

2544 # Set explicit __qualname__ for debugging purposes. 

2545 endpoint_wrapper.__qualname__ = f"{view_cls.__name__}_{name}_wrapper" 

2546 setattr(view_cls, name, endpoint_wrapper) 

2547 

2548 

2549def _make_copy(endpoint: Callable, view_cls: type[View]) -> Callable: 

2550 """ 

2551 Wrap the endpoint in a new function as kind of copy. 

2552 

2553 Fun fact: You cannot do this inside a for loop, because the closure of 'endpoint' 

2554 inside the wrapper works on the variable, not on the value. And for-loops in Python 

2555 do not have their own variable scope. 

2556 

2557 https://eev.ee/blog/2011/04/24/gotcha-python-scoping-closures/ 

2558 """ 

2559 if inspect.iscoroutinefunction(endpoint): 

2560 

2561 @functools.wraps(endpoint) 

2562 async def _async_wrapper(self, *args, **kwargs): 

2563 return await endpoint(self, *args, **kwargs) 

2564 

2565 endpoint_wrapper: Callable = _async_wrapper 

2566 else: 

2567 

2568 @functools.wraps(endpoint) 

2569 def _sync_wrapper(self, *args, **kwargs): 

2570 return endpoint(self, *args, **kwargs) 

2571 

2572 endpoint_wrapper = _sync_wrapper 

2573 

2574 endpoint_wrapper.__annotations__ = endpoint.__annotations__.copy() 

2575 return endpoint_wrapper 

2576 

2577 

2578def _init_all_endpoints(view_cls: type[View]): 

2579 """ 

2580 Ensure every endpoint has a unique name and update the 'self' annotation. 

2581 """ 

2582 for attr in view_cls.__dict__.values(): 

2583 if not hasattr(attr, "_api_route_args"): 

2584 continue 

2585 endpoint = attr 

2586 # Give every endpoint a unique name 

2587 # This will give the FooView.create() endpoint the name "fooview_create" 

2588 endpoint.__name__ = view_cls.__name__.lower() + "_" + endpoint.__name__ 

2589 _annotate_self(view_cls, endpoint) 

2590 

2591 

2592def _view_id_type(view_cls: type) -> Any: 

2593 """The ``{id}`` type: ``id_type`` when set, else the model's primary key's. 

2594 

2595 Resolved per registration and never stored on the class, so a subclass 

2596 with another model derives its own. 

2597 """ 

2598 explicit = getattr(view_cls, "id_type", None) 

2599 if explicit is not None: 

2600 return explicit 

2601 model = getattr(view_cls, "model", None) 

2602 # a composite key has no scalar type; it keeps the int default 

2603 if model is None or len(sa_inspect(model).primary_key) != 1: 

2604 return int 

2605 return _model_id_type(model) or int 

2606 

2607 

2608_UNCHANGED: Any = object() 

2609_ANY_STRINGS = frozenset({"Any", "typing.Any"}) 

2610 

2611 

2612def _annotate( 

2613 func: Callable, 

2614 return_annotation: Any = _UNCHANGED, 

2615 *, 

2616 overwrite: bool = False, 

2617 **param_annotations: Any, 

2618) -> None: 

2619 """Fill the view's concrete types into ``func.__signature__``. 

2620 

2621 The endpoint shells declare ``Any`` because the concrete types only exist 

2622 per view class. A parameter or return is filled only where the author 

2623 wrote no annotation or ``Any``, so a replaced endpoint method keeps its 

2624 own types. ``overwrite`` fills regardless. 

2625 """ 

2626 # Judge what the author wrote, not the signature: functools.wraps copies 

2627 # a registered parent's filled-in signature onto a subclass's copy. 

2628 written = inspect.get_annotations(func) 

2629 

2630 def fillable(name: str) -> bool: 

2631 annotation = written.get(name, Any) 

2632 # under PEP 563 the default signature's ``Any`` is the string "Any" 

2633 return ( 

2634 overwrite 

2635 or annotation is Any 

2636 or (isinstance(annotation, str) and annotation in _ANY_STRINGS) 

2637 ) 

2638 

2639 sig = inspect.signature(func) 

2640 params = [ 

2641 param.replace(annotation=param_annotations[param.name]) 

2642 if param.name in param_annotations and fillable(param.name) 

2643 else param 

2644 for param in sig.parameters.values() 

2645 ] 

2646 if return_annotation is not _UNCHANGED and fillable("return"): 

2647 sig = sig.replace(return_annotation=return_annotation) 

2648 func.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined] 

2649 

2650 

2651def _get_all_parent_endpoints(view_cls: type[View]) -> dict[str, Callable]: 

2652 """Map parent route endpoints by attribute name. 

2653 

2654 Registered intermediate parents copy base endpoints into their ``__dict__``. 

2655 Keying by most-derived attribute name prevents duplicate logical endpoints 

2656 while still respecting overrides. 

2657 """ 

2658 endpoints: dict[str, Callable] = {} 

2659 seen: set[str] = set() 

2660 for cls in view_cls.mro(): 

2661 if cls is view_cls: 

2662 continue 

2663 for name, value in cls.__dict__.items(): 

2664 if name in seen: 

2665 # A more-derived class already defined this name; it shadows the 

2666 # base regardless of whether that override is itself a route. 

2667 continue 

2668 seen.add(name) 

2669 if hasattr(value, "_api_route_args"): 

2670 endpoints[name] = value 

2671 return endpoints 

2672 

2673 

2674def _init_api_router(view_cls: type[View]) -> fastapi.APIRouter: 

2675 for name in view_cls.route_options: 

2676 if not _is_route_name_in_lineage(view_cls, name): 

2677 raise RestlyConfigurationError( 

2678 f"{name!r} is not a route in {view_cls.__name__}.route_options" 

2679 ) 

2680 

2681 # Each level of the class hierarchy adds to these, base first: a subclass 

2682 # cannot drop a guard its base declares. 

2683 levels = [c.__dict__ for c in reversed(view_cls.mro())] 

2684 prefix = "".join(level["prefix"] for level in levels if "prefix" in level) 

2685 dependencies: list[Any] = [] 

2686 responses: dict[int | str, dict[str, Any]] = {} 

2687 for level in levels: 

2688 for dependency in level.get("dependencies") or (): 

2689 # A subclass that spells out ``[own, *Base.dependencies]`` lists 

2690 # the base's entries again. They move to where it lists them, so 

2691 # ``own`` still runs first. 

2692 dependencies = [seen for seen in dependencies if seen is not dependency] 

2693 dependencies.append(dependency) 

2694 responses.update(level.get("responses") or {}) 

2695 tags = _get_router_tags(view_cls, prefix) 

2696 api_router = fastapi.APIRouter( 

2697 prefix=prefix, tags=tags, responses=responses, dependencies=dependencies 

2698 ) 

2699 

2700 # Find all endpoint functions in this class and add them to the router 

2701 list_parameters: list[dict[str, Any]] | None = None 

2702 for name, attr in view_cls.__dict__.items(): 

2703 if not hasattr(attr, "_api_route_args"): 

2704 continue 

2705 endpoint = attr 

2706 path, route_kwargs = endpoint._api_route_args 

2707 defaults = {} 

2708 if issubclass(view_cls, BaseRestView) and name in _CRUD_ROUTE_METADATA: 

2709 route_name, summary = _CRUD_ROUTE_METADATA[name] 

2710 # FastAPI carries placeholders through include_router(), so an 

2711 # explicit generator on the route, router, or app still wins. 

2712 # https://github.com/fastapi/fastapi/blob/0.115.0/fastapi/utils.py 

2713 defaults = { 

2714 "name": route_name, 

2715 "summary": summary, 

2716 "generate_unique_id_function": Default( 

2717 functools.partial( 

2718 _generate_crud_operation_id, 

2719 is_member=name not in {"get_many_endpoint", "create_endpoint"}, 

2720 ) 

2721 ), 

2722 } 

2723 route_kwargs = { 

2724 **defaults, 

2725 **route_kwargs, 

2726 **view_cls.route_options.get(name, {}), 

2727 } 

2728 if getattr(endpoint, "_fr_list_guard", False): 

2729 if list_parameters is None: 

2730 list_parameters = _list_openapi_parameters( 

2731 cast(Any, view_cls).schema_list_params 

2732 ) 

2733 extra = dict(route_kwargs.get("openapi_extra") or {}) 

2734 extra["parameters"] = [*list_parameters, *extra.get("parameters", [])] 

2735 route_kwargs["openapi_extra"] = extra 

2736 _add_api_route(api_router, view_cls, path, endpoint, route_kwargs) 

2737 

2738 return api_router 

2739 

2740 

2741_CRUD_ROUTE_METADATA = { 

2742 "get_many_endpoint": ("get_many", "List"), 

2743 "get_one_endpoint": ("get_one", "Get"), 

2744 "create_endpoint": ("create", "Create"), 

2745 "update_endpoint": ("update", "Update"), 

2746 "delete_endpoint": ("delete", "Delete"), 

2747 "put": ("put", "Update (PUT)"), 

2748} 

2749 

2750 

2751def _generate_crud_operation_id( 

2752 route: fastapi.routing.APIRoute, *, is_member: bool 

2753) -> str: 

2754 """Combine the mounted resource path and action, omitting the member key.""" 

2755 path = route.path_format.rstrip("/") 

2756 if is_member: 

2757 path = re.sub(r"/\{[^}]+\}$", "", path) 

2758 segments = [ 

2759 f"by_{segment[1:-1]}" if segment.startswith("{") else segment 

2760 for segment in path.strip("/").split("/") 

2761 if segment 

2762 ] 

2763 resource = re.sub(r"\W+", "_", "_".join(segments)).strip("_") or "root" 

2764 action = {"get_many": "list", "get_one": "get"}.get(route.name, route.name) 

2765 return re.sub(r"\W+", "_", f"{resource}_{action}").strip("_") 

2766 

2767 

2768def _get_router_tags(view_cls: type[View], prefix: str) -> list[str | Enum]: 

2769 if view_cls.tags is not None: 

2770 return list(view_cls.tags) 

2771 return [_derive_tag_from_prefix(prefix) or view_cls.__name__] 

2772 

2773 

2774def _derive_tag_from_prefix(prefix: str) -> str | None: 

2775 segments = [segment for segment in prefix.strip("/").split("/") if segment] 

2776 if not segments: 

2777 return None 

2778 return segments[-1].replace("-", " ").replace("_", " ").title() 

2779 

2780 

2781def _add_api_route( 

2782 api_router: fastapi.APIRouter, 

2783 view_cls: type[View], 

2784 path: str, 

2785 endpoint: Callable, 

2786 route_kwargs: dict[str, Any], 

2787) -> None: 

2788 if _should_add_collection_route_alias(view_cls, path): 

2789 api_router.add_api_route("", endpoint, **route_kwargs) 

2790 hidden_alias_kwargs = {**route_kwargs, "include_in_schema": False} 

2791 api_router.add_api_route("/", endpoint, **hidden_alias_kwargs) 

2792 return 

2793 

2794 api_router.add_api_route(path, endpoint, **route_kwargs) 

2795 

2796 

2797def _should_add_collection_route_alias(view_cls: type[View], path: str) -> bool: 

2798 return issubclass(view_cls, BaseRestView) and path == "/" 

2799 

2800 

2801def _annotate_self(view_cls: type[View], endpoint: Callable) -> None: 

2802 """ 

2803 Annotate the 'self' argument as 'self=Depends(view_cls)'. That way FastAPI instantiates the 

2804 view_cls before calling the endpoint function and passes it as 'self'. 

2805 Note that it sets endpoint.__signature__ which overrides any other inspection. 

2806 

2807 Note: Copied (MIT license) and adjusted from: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py 

2808 

2809 Fixes the endpoint signature to ensure FastAPI performs dependency injection properly. 

2810 """ 

2811 sig = inspect.signature(endpoint) 

2812 params: list[inspect.Parameter] = list(sig.parameters.values()) 

2813 self_param = params[0] 

2814 new_self_param = self_param.replace(default=fastapi.Depends(view_cls)) 

2815 

2816 new_params = [new_self_param] + [ 

2817 param.replace(kind=inspect.Parameter.KEYWORD_ONLY) for param in params[1:] 

2818 ] 

2819 endpoint.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined] 

2820 

2821 

2822# Bare-typed annotations FastAPI special-cases for parameter injection 

2823# (no ``Depends(...)`` marker required). Treated alongside ``Depends``- 

2824# marked annotations as DI-wired class attributes; everything else is 

2825# left as plain typing. 

2826_FASTAPI_SPECIAL_INJECTABLE: tuple[type, ...] = ( 

2827 Request, 

2828 Response, 

2829 BackgroundTasks, 

2830 WebSocket, 

2831) 

2832 

2833# Markers for the request inputs FastAPI reads into an endpoint parameter: 

2834# ``Path``/``Query``/``Header``/``Cookie`` (``Param``) and ``Body``/``Form``/ 

2835# ``File`` (``Body``). FastAPI fills a class attribute only through ``Depends`` 

2836# or a special type, never through one of these, so the view is rejected. 

2837_REQUEST_PARAMETER_MARKERS: tuple[type, ...] = (_ParamMarker, _BodyMarker) 

2838 

2839 

2840def _init_class_based_view(view_cls: type[View]) -> None: 

2841 """ 

2842 Note: Copied (MIT license) and adjusted from: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py 

2843 

2844 Idempotently modifies the provided `cls`, performing the following modifications: 

2845 * The `__init__` function is updated to set any class-annotated dependencies as instance attributes 

2846 * The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer 

2847 """ 

2848 if getattr(view_cls, "__class_based_view", False): 

2849 return # Already initialized 

2850 old_init: Callable[..., Any] = view_cls.__init__ 

2851 old_signature = inspect.signature(old_init) 

2852 old_parameters = list(old_signature.parameters.values())[1:] # drop `self` 

2853 new_parameters = [ 

2854 x 

2855 for x in old_parameters 

2856 if x.kind 

2857 not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) 

2858 ] 

2859 # Marker-based DI with MRO-aware shadowing: walk the MRO from the 

2860 # base classes upward and pick, for each name, an annotation that 

2861 # either carries a ``Depends(...)`` marker or names one of FastAPI's 

2862 # bare-injectable special types (``Request`` / ``Response`` etc.). 

2863 # A *plain* annotation on a more-derived class (e.g. a mixin 

2864 # declaring ``session: AsyncSession`` for static-typing purposes) 

2865 # does NOT shadow a marker-bearing annotation from a base — the 

2866 # framework prefers wiring fidelity over the most-derived hint. 

2867 # Without this rule, any plain annotation a mixin adds would 

2868 # silently break dependency injection. 

2869 di_annotations: dict[str, Any] = {} 

2870 request_markers: dict[str, Any] = {} 

2871 for cls in reversed(view_cls.__mro__): 

2872 try: 

2873 cls_hints = get_type_hints(cls, include_extras=True) 

2874 except Exception: 

2875 continue 

2876 for name, annotation in cls_hints.items(): 

2877 if get_origin(annotation) is ClassVar: 

2878 continue 

2879 metadata = getattr(annotation, "__metadata__", ()) 

2880 has_depends_marker = any(isinstance(m, _DependsMarker) for m in metadata) 

2881 for marker in metadata: 

2882 if isinstance(marker, _REQUEST_PARAMETER_MARKERS): 

2883 request_markers.setdefault(name, marker) 

2884 underlying = ( 

2885 annotation 

2886 if get_origin(annotation) is not Annotated 

2887 else (get_args(annotation)[0] if get_args(annotation) else annotation) 

2888 ) 

2889 # On Python 3.10, a parameterized generic such as list[str] passes 

2890 # isclass() but makes issubclass() raise; it is never special. 

2891 is_special_type = ( 

2892 inspect.isclass(underlying) 

2893 and get_origin(underlying) is None 

2894 and issubclass(underlying, _FASTAPI_SPECIAL_INJECTABLE) 

2895 ) 

2896 if has_depends_marker or is_special_type: 

2897 # Marker-bearing annotation wins, regardless of MRO position. 

2898 di_annotations[name] = annotation 

2899 # Plain annotations are silently ignored — they neither set 

2900 # nor clear an entry in di_annotations. 

2901 

2902 # FastAPI never fills an attribute with a request-parameter marker: it 

2903 # would keep its class default, with no error and no OpenAPI entry. A 

2904 # Depends marker on the same name wires it, as above. 

2905 for name, marker in request_markers.items(): 

2906 if name not in di_annotations: 2906 ↛ 2905line 2906 didn't jump to line 2905 because the condition on line 2906 was always true

2907 raise RestlyConfigurationError( 

2908 f"{view_cls.__name__}.{name} has a {type(marker).__name__}() " 

2909 "marker, but a view class attribute is filled only through " 

2910 "Depends(...) or Security(...), so FastAPI would never set it. " 

2911 "Declare the parameter on the endpoint method that reads it, " 

2912 f"or annotate {name} with Annotated[..., Depends(...)] and read " 

2913 "the parameter in that dependency." 

2914 ) 

2915 

2916 dependency_names: list[str] = [] 

2917 for name, annotation in di_annotations.items(): 

2918 dependency_names.append(name) 

2919 default_value = getattr(view_cls, name, inspect.Parameter.empty) 

2920 new_parameters.append( 

2921 inspect.Parameter( 

2922 name=name, 

2923 kind=inspect.Parameter.KEYWORD_ONLY, 

2924 default=default_value, 

2925 annotation=annotation, 

2926 ) 

2927 ) 

2928 new_signature = old_signature.replace(parameters=new_parameters) 

2929 

2930 def new_init(self: Any, *args: Any, **kwargs: Any) -> None: 

2931 for dep_name in dependency_names: 

2932 dep_value = kwargs.pop(dep_name) 

2933 setattr(self, dep_name, dep_value) 

2934 old_init(self, *args, **kwargs) 

2935 

2936 setattr(view_cls, "__signature__", new_signature) 

2937 setattr(view_cls, "__init__", new_init) 

2938 setattr(view_cls, "__class_based_view", True)