fastapi-restly 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1294 @@
1
+ """
2
+ This module provides a framework for class-based views on SQLAlchemy models.
3
+
4
+ View class:
5
+ This class is used to create a collection of endpoints that share an
6
+ APIRouter (created when calling `include_view()`) and dependencies
7
+ as class attributes. It uses the same mechanics as the class based
8
+ view decorator from fastapi-utils.
9
+ (https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/)
10
+
11
+ AsyncRestView:
12
+ Provides default reading and writing functions on the database using
13
+ SQLAlchemy models.
14
+ """
15
+
16
+ import dataclasses
17
+ import functools
18
+ import inspect
19
+ import types
20
+ from enum import Enum
21
+ from math import ceil
22
+ from typing import (
23
+ Annotated,
24
+ Any,
25
+ Callable,
26
+ ClassVar,
27
+ Generic,
28
+ Iterable,
29
+ Iterator,
30
+ Protocol,
31
+ Sequence,
32
+ Union,
33
+ cast,
34
+ get_args,
35
+ get_origin,
36
+ get_type_hints,
37
+ overload,
38
+ )
39
+
40
+ import fastapi
41
+ import pydantic
42
+ from fastapi import BackgroundTasks, Request, Response, WebSocket
43
+ from fastapi.params import Depends as _DependsMarker
44
+ from pydantic import create_model
45
+ from sqlalchemy import inspect as sa_inspect
46
+ from sqlalchemy.orm import DeclarativeBase, selectinload
47
+ from starlette.datastructures import QueryParams
48
+ from typing_extensions import TypeVar
49
+
50
+ from .._exception_handlers import register_default_exception_handlers
51
+ from ..query import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, create_list_params_schema
52
+ from ..schemas import BaseSchema, IDRef, IDSchema
53
+ from ..schemas._base import (
54
+ create_model_with_optional_fields,
55
+ create_model_without_read_only_fields,
56
+ get_writable_inputs,
57
+ is_readonly_field,
58
+ is_writeonly_field,
59
+ )
60
+ from ..schemas._generator import auto_generate_schema_for_view
61
+ from ._openapi import _register_for_resource_ref
62
+
63
+ ModelT = TypeVar("ModelT", bound=DeclarativeBase, default=DeclarativeBase)
64
+ SchemaT = TypeVar("SchemaT", bound=pydantic.BaseModel, default=BaseSchema)
65
+ CreateSchemaT = TypeVar(
66
+ "CreateSchemaT", bound=pydantic.BaseModel, default=pydantic.BaseModel
67
+ )
68
+ UpdateSchemaT = TypeVar(
69
+ "UpdateSchemaT", bound=pydantic.BaseModel, default=pydantic.BaseModel
70
+ )
71
+ IdT = TypeVar("IdT", default=int)
72
+
73
+
74
+ @dataclasses.dataclass(frozen=True)
75
+ class ListingResult(Generic[ModelT]):
76
+ """Result returned by ``perform_listing`` before HTTP response formatting."""
77
+
78
+ objects: Sequence[ModelT]
79
+ total_count: int
80
+
81
+
82
+ class ViewRoute(str, Enum):
83
+ """Generated CRUD routes that can be referenced by view options."""
84
+
85
+ LIST = "listing"
86
+ GET = "get"
87
+ CREATE = "create"
88
+ UPDATE = "update"
89
+ DELETE = "delete"
90
+
91
+
92
+ def _accepts_init_kwarg(model_cls: type, attr_name: str) -> bool:
93
+ """Return True if attr_name can be passed as a keyword argument to model_cls.__init__.
94
+
95
+ Non-dataclass models (DeclarativeBase subclasses using mapped_column) accept all
96
+ kwargs. Dataclass-based models may have fields with init=False, in which case
97
+ passing the attribute to __init__ raises TypeError.
98
+ """
99
+ if not dataclasses.is_dataclass(model_cls):
100
+ return True
101
+ dc_fields = {f.name: f for f in dataclasses.fields(model_cls)}
102
+ return attr_name not in dc_fields or dc_fields[attr_name].init
103
+
104
+
105
+ def _requires_init_kwarg(model_cls: type, attr_name: str) -> bool:
106
+ if not dataclasses.is_dataclass(model_cls):
107
+ return False
108
+ dc_fields = {f.name: f for f in dataclasses.fields(model_cls)}
109
+ field = dc_fields.get(attr_name)
110
+ if field is None or not field.init:
111
+ return False
112
+ return (
113
+ field.default is dataclasses.MISSING
114
+ and field.default_factory is dataclasses.MISSING
115
+ )
116
+
117
+
118
+ @dataclasses.dataclass
119
+ class _CreatePlan:
120
+ kwargs: dict[str, Any]
121
+ post_assignments: dict[str, Any]
122
+
123
+
124
+ class _HasID(Protocol):
125
+ """Anything with an ``id`` attribute. By framework convention, primary
126
+ keys are named ``id``; ``IDBase`` formalizes this but isn't required."""
127
+
128
+ id: Any
129
+
130
+
131
+ def _has_model_attr(model_cls: type[DeclarativeBase], attr_name: str) -> bool:
132
+ return hasattr(model_cls, attr_name)
133
+
134
+
135
+ def _get_relationship_property(
136
+ model_cls: type[DeclarativeBase], relation_name: str
137
+ ) -> Any | None:
138
+ try:
139
+ mapper = sa_inspect(model_cls)
140
+ except Exception:
141
+ return None
142
+ return mapper.relationships.get(relation_name)
143
+
144
+
145
+ def _get_unambiguous_local_fk_name(
146
+ model_cls: type[DeclarativeBase], relation_name: str
147
+ ) -> str | None:
148
+ relationship_property = _get_relationship_property(model_cls, relation_name)
149
+ if relationship_property is None:
150
+ return None
151
+
152
+ if getattr(relationship_property.direction, "name", None) != "MANYTOONE":
153
+ return None
154
+
155
+ local_columns = list(relationship_property.local_columns)
156
+ if len(local_columns) != 1:
157
+ column_names = ", ".join(column.key for column in local_columns) or "<none>"
158
+ raise ValueError(
159
+ f"Cannot infer a single local FK for relationship "
160
+ f"{model_cls.__name__}.{relation_name}; found {column_names}. "
161
+ "Use an explicit custom handler for this relationship."
162
+ )
163
+ return local_columns[0].key
164
+
165
+
166
+ def _is_reference_schema_field(
167
+ schema_cls: type[pydantic.BaseModel], field_name: str
168
+ ) -> bool:
169
+ field_info = schema_cls.model_fields.get(field_name)
170
+ if field_info is None:
171
+ return False
172
+ return _is_idschema_reference_annotation(field_info.annotation)
173
+
174
+
175
+ def _add_assignment(target: dict[str, Any], field_name: str | None, value: Any) -> None:
176
+ if field_name:
177
+ target[field_name] = value
178
+
179
+
180
+ _EXPLICIT_NULL_REF = object()
181
+
182
+
183
+ def _reference_identity(value: Any) -> tuple[type[Any] | None, Any] | object | None:
184
+ if value is None:
185
+ return _EXPLICIT_NULL_REF
186
+ if isinstance(value, DeclarativeBase):
187
+ return type(value), getattr(value, "id", None)
188
+ if isinstance(value, IDSchema):
189
+ sql_model = value.get_sql_model_annotation()
190
+ return sql_model, value.id
191
+ return None
192
+
193
+
194
+ def _reference_identity_detail(identity: object) -> Any:
195
+ if identity is _EXPLICIT_NULL_REF:
196
+ return None
197
+ if isinstance(identity, tuple) and len(identity) == 2:
198
+ return identity[1]
199
+ return identity
200
+
201
+
202
+ def validate_resolved_reference_consistency(
203
+ model_cls: type[DeclarativeBase],
204
+ schema_obj: pydantic.BaseModel,
205
+ schema_cls: type[pydantic.BaseModel] | None = None,
206
+ ) -> None:
207
+ """Validate explicitly supplied FK and relationship fields agree.
208
+
209
+ IDRef/IDSchema resolution turns model-aware references into ORM objects before
210
+ object construction/update. If the client supplied both ``author_id`` and
211
+ ``author`` independently, they must refer to the same row.
212
+ """
213
+ if schema_cls is None:
214
+ schema_cls = schema_obj.__class__
215
+
216
+ for fk_field in schema_obj.model_fields_set:
217
+ if not fk_field.endswith("_id") or not _is_reference_schema_field(
218
+ schema_cls, fk_field
219
+ ):
220
+ continue
221
+
222
+ relation_field = fk_field[:-3]
223
+ if (
224
+ relation_field not in schema_obj.model_fields_set
225
+ or not _is_reference_schema_field(schema_cls, relation_field)
226
+ or not _has_model_attr(model_cls, relation_field)
227
+ ):
228
+ continue
229
+
230
+ fk_identity = _reference_identity(getattr(schema_obj, fk_field, None))
231
+ relation_identity = _reference_identity(
232
+ getattr(schema_obj, relation_field, None)
233
+ )
234
+ if fk_identity is None or relation_identity is None:
235
+ continue
236
+
237
+ if fk_identity == relation_identity:
238
+ continue
239
+
240
+ raise fastapi.HTTPException(
241
+ status_code=422,
242
+ detail=(
243
+ f"Conflicting references for {fk_field} and {relation_field}: "
244
+ f"{_reference_identity_detail(fk_identity)!r} != "
245
+ f"{_reference_identity_detail(relation_identity)!r}"
246
+ ),
247
+ )
248
+
249
+
250
+ def iter_creatable_fields(
251
+ schema_obj: pydantic.BaseModel,
252
+ schema_cls: type[pydantic.BaseModel] | None = None,
253
+ ) -> Iterator[tuple[str, Any]]:
254
+ """Iterate over (field_name, value) pairs that should be used to construct a new
255
+ ORM object from ``schema_obj``.
256
+
257
+ Fields marked as ``ReadOnly`` are skipped. Unlike :func:`get_writable_inputs`,
258
+ this also includes fields that were not explicitly provided, so that
259
+ schema-level defaults end up on the new object.
260
+ """
261
+ if schema_cls is None:
262
+ schema_cls = schema_obj.__class__
263
+ for field_name, value in schema_obj:
264
+ if is_readonly_field(schema_cls, field_name):
265
+ continue
266
+ yield field_name, value
267
+
268
+
269
+ def _add_resolved_reference_to_create_plan(
270
+ plan: _CreatePlan,
271
+ model_cls: type[DeclarativeBase],
272
+ field_name: str,
273
+ value: DeclarativeBase,
274
+ ) -> None:
275
+ ref = cast(_HasID, value)
276
+ if field_name.endswith("_id"):
277
+ fk_name = field_name
278
+ relation_name = field_name[:-3]
279
+ accepts_relation = _has_model_attr(
280
+ model_cls, relation_name
281
+ ) and _accepts_init_kwarg(model_cls, relation_name)
282
+
283
+ if (
284
+ _requires_init_kwarg(model_cls, fk_name)
285
+ and accepts_relation
286
+ and _requires_init_kwarg(model_cls, relation_name)
287
+ ):
288
+ plan.kwargs[fk_name] = ref.id
289
+ plan.kwargs[relation_name] = value
290
+ return
291
+
292
+ if accepts_relation and _requires_init_kwarg(model_cls, relation_name):
293
+ plan.kwargs[relation_name] = value
294
+ if _has_model_attr(model_cls, fk_name):
295
+ plan.post_assignments[fk_name] = ref.id
296
+ return
297
+
298
+ if _accepts_init_kwarg(model_cls, fk_name):
299
+ plan.kwargs[fk_name] = ref.id
300
+ if _has_model_attr(model_cls, relation_name):
301
+ plan.post_assignments[relation_name] = value
302
+ return
303
+
304
+ if accepts_relation:
305
+ plan.kwargs[relation_name] = value
306
+ plan.post_assignments[fk_name] = ref.id
307
+ return
308
+
309
+ if _has_model_attr(model_cls, fk_name):
310
+ plan.post_assignments[fk_name] = ref.id
311
+ if _has_model_attr(model_cls, relation_name):
312
+ plan.post_assignments[relation_name] = value
313
+ return
314
+
315
+ relation_name = field_name
316
+ fk_name = _get_unambiguous_local_fk_name(model_cls, relation_name)
317
+
318
+ if _has_model_attr(model_cls, relation_name) and _accepts_init_kwarg(
319
+ model_cls, relation_name
320
+ ):
321
+ plan.kwargs[relation_name] = value
322
+ _add_assignment(plan.post_assignments, fk_name, ref.id)
323
+ return
324
+
325
+ if fk_name and _accepts_init_kwarg(model_cls, fk_name):
326
+ plan.kwargs[fk_name] = ref.id
327
+ if _has_model_attr(model_cls, relation_name):
328
+ plan.post_assignments[relation_name] = value
329
+ return
330
+
331
+ if _has_model_attr(model_cls, relation_name):
332
+ plan.post_assignments[relation_name] = value
333
+ _add_assignment(plan.post_assignments, fk_name, ref.id)
334
+
335
+
336
+ def build_create_plan(
337
+ model_cls: type[DeclarativeBase],
338
+ schema_obj: pydantic.BaseModel,
339
+ schema_cls: type[pydantic.BaseModel] | None = None,
340
+ ) -> _CreatePlan:
341
+ """Translate ``schema_obj`` fields into kwargs for ``model_cls(**kwargs)``.
342
+
343
+ Shared by sync and async ``build_from_schema``. Assumes any nested ``IDSchema``
344
+ references on ``schema_obj`` have already been resolved (sync vs async).
345
+ """
346
+ if schema_cls is None:
347
+ schema_cls = schema_obj.__class__
348
+
349
+ plan = _CreatePlan(kwargs={}, post_assignments={})
350
+ for field_name, value in iter_creatable_fields(schema_obj, schema_cls):
351
+ if isinstance(value, IDSchema) and field_name.endswith("_id"):
352
+ if _accepts_init_kwarg(model_cls, field_name):
353
+ plan.kwargs[field_name] = value.id
354
+ elif _has_model_attr(model_cls, field_name):
355
+ plan.post_assignments[field_name] = value.id
356
+ continue
357
+ if isinstance(value, DeclarativeBase) and _is_reference_schema_field(
358
+ schema_cls, field_name
359
+ ):
360
+ _add_resolved_reference_to_create_plan(plan, model_cls, field_name, value)
361
+ continue
362
+
363
+ if _accepts_init_kwarg(model_cls, field_name):
364
+ plan.kwargs[field_name] = value
365
+ elif _has_model_attr(model_cls, field_name):
366
+ plan.post_assignments[field_name] = value
367
+ return plan
368
+
369
+
370
+ def build_create_kwargs(
371
+ model_cls: type[DeclarativeBase],
372
+ schema_obj: pydantic.BaseModel,
373
+ schema_cls: type[pydantic.BaseModel] | None = None,
374
+ ) -> dict[str, Any]:
375
+ return build_create_plan(model_cls, schema_obj, schema_cls).kwargs
376
+
377
+
378
+ def apply_create_assignments(obj: DeclarativeBase, assignments: dict[str, Any]) -> None:
379
+ for field_name, value in assignments.items():
380
+ setattr(obj, field_name, value)
381
+
382
+
383
+ def _apply_resolved_reference_update(
384
+ obj: DeclarativeBase, field_name: str, value: DeclarativeBase
385
+ ) -> None:
386
+ ref = cast(_HasID, value)
387
+ model_cls = type(obj)
388
+ if field_name.endswith("_id"):
389
+ setattr(obj, field_name, ref.id)
390
+ relation_name = field_name[:-3]
391
+ if hasattr(obj, relation_name):
392
+ setattr(obj, relation_name, value)
393
+ return
394
+
395
+ if hasattr(obj, field_name):
396
+ setattr(obj, field_name, value)
397
+
398
+ fk_name = _get_unambiguous_local_fk_name(model_cls, field_name)
399
+ if fk_name:
400
+ setattr(obj, fk_name, ref.id)
401
+
402
+
403
+ def apply_update_to_object(
404
+ obj: DeclarativeBase,
405
+ schema_obj: pydantic.BaseModel,
406
+ schema_cls: type[pydantic.BaseModel] | None = None,
407
+ ) -> None:
408
+ """Apply writable inputs from ``schema_obj`` onto ``obj`` in place.
409
+
410
+ Shared by sync and async ``apply_schema``. Assumes any nested ``IDSchema``
411
+ references on ``schema_obj`` have already been resolved (sync vs async).
412
+ """
413
+ for field_name, value in get_writable_inputs(schema_obj, schema_cls).items():
414
+ if isinstance(value, IDSchema) and field_name.endswith("_id"):
415
+ setattr(obj, field_name, value.id)
416
+ continue
417
+ if isinstance(value, DeclarativeBase) and _is_reference_schema_field(
418
+ schema_cls or schema_obj.__class__, field_name
419
+ ):
420
+ _apply_resolved_reference_update(obj, field_name, value)
421
+ continue
422
+ setattr(obj, field_name, value)
423
+
424
+
425
+ def _unwrap_optional_annotation(annotation: Any) -> Any:
426
+ origin = get_origin(annotation)
427
+ if origin not in (types.UnionType, Union, None):
428
+ return annotation
429
+
430
+ if origin is None:
431
+ return annotation
432
+
433
+ non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)]
434
+ if len(non_none_args) == 1:
435
+ return non_none_args[0]
436
+ return annotation
437
+
438
+
439
+ def _is_idschema_reference_annotation(annotation: Any) -> bool:
440
+ annotation = _unwrap_optional_annotation(annotation)
441
+ if annotation in (IDSchema, IDRef):
442
+ return True
443
+ if not inspect.isclass(annotation):
444
+ return False
445
+ try:
446
+ if not issubclass(annotation, IDSchema):
447
+ return False
448
+ except TypeError:
449
+ return False
450
+ metadata = getattr(annotation, "__pydantic_generic_metadata__", {})
451
+ return metadata.get("origin") in (IDSchema, IDRef)
452
+
453
+
454
+ def _serialize_idschema_value(annotation: Any, value: Any) -> Any:
455
+ if value is None:
456
+ return None
457
+ id_value = value.id if hasattr(value, "id") else value
458
+ if inspect.isclass(annotation) and issubclass(annotation, IDRef):
459
+ return id_value
460
+ if inspect.isclass(annotation) and issubclass(annotation, IDSchema):
461
+ return annotation.model_construct(id=id_value)
462
+ return {"id": id_value}
463
+
464
+
465
+ def _serialize_response_value(annotation: Any, value: Any) -> Any:
466
+ annotation = _unwrap_optional_annotation(annotation)
467
+
468
+ if _is_idschema_reference_annotation(annotation):
469
+ return _serialize_idschema_value(annotation, value)
470
+
471
+ origin = get_origin(annotation)
472
+ if origin is list:
473
+ item_annotation = get_args(annotation)[0] if get_args(annotation) else Any
474
+ if _is_idschema_reference_annotation(item_annotation) and isinstance(
475
+ value, Sequence
476
+ ):
477
+ return [_serialize_idschema_value(item_annotation, item) for item in value]
478
+
479
+ return value
480
+
481
+
482
+ def _get_nested_schema_annotation(annotation: Any) -> type[pydantic.BaseModel] | None:
483
+ annotation = _unwrap_optional_annotation(annotation)
484
+
485
+ try:
486
+ if inspect.isclass(annotation) and issubclass(annotation, pydantic.BaseModel):
487
+ return annotation
488
+ except TypeError:
489
+ pass
490
+
491
+ origin = get_origin(annotation)
492
+ if origin is list:
493
+ args = get_args(annotation)
494
+ if args:
495
+ return _get_nested_schema_annotation(args[0])
496
+
497
+ return None
498
+
499
+
500
+ class _OmitWriteOnlyMixin(pydantic.BaseModel):
501
+ @classmethod
502
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
503
+ super().__pydantic_init_subclass__(**kwargs)
504
+
505
+ writeonly_fields = [
506
+ name for name in cls.model_fields if is_writeonly_field(cls, name)
507
+ ]
508
+ for name in writeonly_fields:
509
+ del cls.model_fields[name]
510
+
511
+ cls.model_rebuild(force=True)
512
+
513
+
514
+ @functools.cache
515
+ def _create_response_validation_schema(
516
+ schema_cls: type[pydantic.BaseModel],
517
+ ) -> type[pydantic.BaseModel]:
518
+ if not any(
519
+ is_writeonly_field(schema_cls, name) for name in schema_cls.model_fields
520
+ ):
521
+ return schema_cls
522
+
523
+ return type(
524
+ f"Response{schema_cls.__name__}",
525
+ (_OmitWriteOnlyMixin, schema_cls),
526
+ {
527
+ "__module__": schema_cls.__module__,
528
+ "__doc__": (schema_cls.__doc__ or "")
529
+ + "\nWrite-only fields have been removed for response validation.",
530
+ },
531
+ )
532
+
533
+
534
+ def _build_relationship_loader_options(
535
+ model_cls: type[DeclarativeBase],
536
+ schema_cls: type[pydantic.BaseModel],
537
+ seen: set[tuple[type[DeclarativeBase], type[pydantic.BaseModel]]] | None = None,
538
+ ) -> list[Any]:
539
+ if seen is None:
540
+ seen = set()
541
+
542
+ visit_key = (model_cls, schema_cls)
543
+ if visit_key in seen:
544
+ return []
545
+ seen = seen | {visit_key}
546
+
547
+ mapper = sa_inspect(model_cls)
548
+ options: list[Any] = []
549
+ for field_name, field_info in schema_cls.model_fields.items():
550
+ if field_name not in mapper.relationships:
551
+ continue
552
+
553
+ relationship_prop = mapper.relationships[field_name]
554
+ loader = selectinload(getattr(model_cls, field_name))
555
+ nested_schema = _get_nested_schema_annotation(field_info.annotation)
556
+
557
+ if nested_schema is not None:
558
+ child_options = _build_relationship_loader_options(
559
+ relationship_prop.mapper.class_, nested_schema, seen
560
+ )
561
+ if child_options:
562
+ loader = loader.options(*child_options)
563
+
564
+ options.append(loader)
565
+
566
+ return options
567
+
568
+
569
+ class View:
570
+ """
571
+ Class-based view primitive for FastAPI.
572
+
573
+ Group related endpoints on a class, share dependencies and metadata via
574
+ class attributes, and let subclasses override individual handlers. Routes
575
+ are bound at :func:`include_view` time, not at class-definition time, so
576
+ subclassing works the way Python developers expect: override a method on
577
+ a subclass and the override is what runs.
578
+
579
+ Most users will subclass :class:`RestView` or :class:`AsyncRestView`,
580
+ which extend ``View`` with CRUD scaffolding. Use ``View`` directly for
581
+ grouped non-CRUD endpoints (auth flows, custom RPC routes, etc.).
582
+ """
583
+
584
+ prefix: ClassVar[str]
585
+ tags: ClassVar[Any] = None
586
+ dependencies: ClassVar[Any] = None
587
+ responses: ClassVar[dict[int | str, dict[str, Any]]] = {}
588
+
589
+ @classmethod
590
+ def before_include_view(cls):
591
+ pass
592
+
593
+
594
+ V = TypeVar("V", bound=type[View])
595
+
596
+
597
+ @overload
598
+ def include_view(
599
+ parent_router: fastapi.APIRouter | fastapi.FastAPI, view_cls: V
600
+ ) -> V: ...
601
+ @overload
602
+ def include_view(
603
+ parent_router: fastapi.APIRouter | fastapi.FastAPI,
604
+ ) -> Callable[[V], V]: ...
605
+
606
+
607
+ def include_view(
608
+ parent_router: fastapi.APIRouter | fastapi.FastAPI, view_cls: V | None = None
609
+ ) -> V | Callable[[V], V]:
610
+ """
611
+ Add a View class's routes to a FastAPI app or APIRouter.
612
+
613
+ Prefer the direct call form from your app/router composition layer::
614
+
615
+ include_view(app, MyView)
616
+
617
+ For small apps, it can also be used as a decorator::
618
+
619
+ @include_view(app)
620
+ class MyView(AsyncRestView):
621
+ ...
622
+ """
623
+ if view_cls is not None:
624
+ _init_view_cls_and_add_to_router(view_cls, parent_router)
625
+ return view_cls
626
+
627
+ def class_decorator(view_cls: V) -> V:
628
+ _init_view_cls_and_add_to_router(view_cls, parent_router)
629
+ return view_cls
630
+
631
+ return class_decorator
632
+
633
+
634
+ def route(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
635
+ """Decorator to mark a View method as an endpoint.
636
+ The path and api_route_kwargs are passed into APIRouter.add_api_route(), see for example:
637
+ https://fastapi.tiangolo.com/reference/apirouter/#fastapi.APIRouter.get
638
+
639
+ Endpoints methods are later added as routes to the FastAPI app using `include_view()`
640
+ """
641
+
642
+ def store_args_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
643
+ # Create a new attribute: '_api_route_args'
644
+ func._api_route_args = (path, api_route_kwargs) # type: ignore[attr-defined]
645
+ return func
646
+
647
+ return store_args_decorator
648
+
649
+
650
+ def get(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
651
+ """Decorator to mark a View method as a GET endpoint.
652
+
653
+ Equivalent to::
654
+
655
+ @route(path, methods=["GET"], status_code=200, ... )
656
+ """
657
+ api_route_kwargs.setdefault("methods", ["GET"])
658
+ api_route_kwargs.setdefault("status_code", 200)
659
+ return route(path, **api_route_kwargs)
660
+
661
+
662
+ def post(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
663
+ """Decorator to mark a View method as a POST endpoint.
664
+
665
+ Equivalent to::
666
+
667
+ @route(path, methods=["POST"], status_code=201, ... )
668
+ """
669
+ api_route_kwargs.setdefault("methods", ["POST"])
670
+ api_route_kwargs.setdefault("status_code", 201)
671
+ return route(path, **api_route_kwargs)
672
+
673
+
674
+ def put(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
675
+ """Decorator to mark a View method as a PUT endpoint.
676
+
677
+ Equivalent to::
678
+
679
+ @route(path, methods=["PUT"], status_code=200, ... )
680
+ """
681
+ api_route_kwargs.setdefault("methods", ["PUT"])
682
+ api_route_kwargs.setdefault("status_code", 200)
683
+ return route(path, **api_route_kwargs)
684
+
685
+
686
+ def patch(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
687
+ """Decorator to mark a View method as a PATCH endpoint.
688
+
689
+ Equivalent to::
690
+
691
+ @route(path, methods=["PATCH"], status_code=200, ... )
692
+ """
693
+ api_route_kwargs.setdefault("methods", ["PATCH"])
694
+ api_route_kwargs.setdefault("status_code", 200)
695
+ return route(path, **api_route_kwargs)
696
+
697
+
698
+ def delete(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
699
+ """Decorator to mark a View method as a DELETE endpoint.
700
+
701
+ Equivalent to::
702
+
703
+ @route(path, methods=["DELETE"], status_code=204, ... )
704
+ """
705
+ api_route_kwargs.setdefault("methods", ["DELETE"])
706
+ api_route_kwargs.setdefault("status_code", 204)
707
+ return route(path, **api_route_kwargs)
708
+
709
+
710
+ class BaseRestView(View, Generic[ModelT, SchemaT, CreateSchemaT, UpdateSchemaT, IdT]):
711
+ """
712
+ Base class for RestView implementations.
713
+
714
+ This class contains the common functionality shared between AsyncRestView
715
+ and RestView, including schema definitions, model configuration, and
716
+ common CRUD operation logic.
717
+ """
718
+
719
+ responses: ClassVar[dict[int | str, dict[str, Any]]] = {
720
+ 404: {"description": "Not found"}
721
+ }
722
+
723
+ schema: ClassVar[type[pydantic.BaseModel]]
724
+ # If 'creation_schema' is not defined it will be created from 'schema'
725
+ # using `create_model_without_read_only_fields()`.
726
+ creation_schema: ClassVar[type[pydantic.BaseModel]]
727
+ update_schema: ClassVar[type[pydantic.BaseModel]]
728
+ model: ClassVar[type[DeclarativeBase]]
729
+ id_type: ClassVar[type[Any]] = int
730
+ include_pagination_metadata: ClassVar[bool] = (
731
+ False # Set True to include count/total in list responses
732
+ )
733
+ exclude_routes: ClassVar[Iterable[str | ViewRoute]] = ()
734
+ #: Extra query-parameter keys to allow on the listing endpoint in addition
735
+ #: to those derived from the response schema. Use this when a view
736
+ #: intentionally consumes a custom query parameter (e.g. an
737
+ #: ``?include_deleted=true`` escape hatch on a soft-delete mixin) that
738
+ #: isn't a filter on a schema field. Without this, the strict
739
+ #: unknown-key guard would reject the request with 422.
740
+ extra_query_params: ClassVar[Iterable[str]] = ()
741
+ #: Default ``page_size`` for list endpoints. ``None`` means "no implicit
742
+ #: cap" (the framework default). Override per-view.
743
+ default_page_size: ClassVar[int | None] = DEFAULT_PAGE_SIZE
744
+ #: Maximum ``page_size`` accepted on list endpoints. Above this returns 422.
745
+ max_page_size: ClassVar[int] = MAX_PAGE_SIZE
746
+ listing_param_schema: ClassVar[type[pydantic.BaseModel]]
747
+ pagination_response_schema: ClassVar[type[pydantic.BaseModel]]
748
+
749
+ request: fastapi.Request
750
+
751
+ def get_relationship_loader_options(self) -> list[Any]:
752
+ return _build_relationship_loader_options(self.model, self.schema)
753
+
754
+ def _reject_unknown_query_params(self) -> None:
755
+ """Reject any query-string key that isn't part of ``listing_param_schema``.
756
+
757
+ FastAPI flattens ``Annotated[listing_param_schema, Query()]`` into named
758
+ query parameters; unknown keys are silently ignored at that layer,
759
+ which would let typoed filters or unsupported operators (e.g.
760
+ ``active__gte=true`` on a boolean column where the schema does not
761
+ emit a range operator) widen the result set without telling the
762
+ caller. We treat unknown keys as a validation error instead, mirroring
763
+ FastAPI's 422 envelope shape so the response is consistent with
764
+ bound-violation errors.
765
+
766
+ No-op when there's no live request (programmatic ``view.listing(...)``
767
+ calls outside an HTTP request) — there's no URL surface to validate
768
+ and the in-process caller is responsible for what they pass.
769
+ """
770
+ request = getattr(self, "request", None)
771
+ if request is None:
772
+ return
773
+ listing_schema = getattr(self, "listing_param_schema", None)
774
+ if listing_schema is None:
775
+ return
776
+ allowed = set(listing_schema.model_fields) | set(self.extra_query_params)
777
+ sent = set(request.query_params.keys())
778
+ unknown = sent - allowed
779
+ if not unknown:
780
+ return
781
+ detail = [
782
+ {
783
+ "type": "extra_forbidden",
784
+ "loc": ["query", key],
785
+ "msg": f"Unknown query parameter {key!r}",
786
+ "input": request.query_params.get(key),
787
+ }
788
+ for key in sorted(unknown)
789
+ ]
790
+ raise fastapi.HTTPException(status_code=422, detail=detail)
791
+
792
+ def to_response_schema(self, obj: ModelT | SchemaT) -> SchemaT:
793
+ """Serialize an ORM object to the configured response schema."""
794
+ if isinstance(obj, self.schema):
795
+ return cast(SchemaT, obj)
796
+
797
+ # Build a payload using canonical field names. Alias rendering happens
798
+ # when FastAPI serializes the response model.
799
+ payload: dict[str, Any] = {}
800
+ for field_name, field_info in self.schema.model_fields.items():
801
+ if is_writeonly_field(self.schema, field_name):
802
+ continue
803
+ if hasattr(obj, field_name):
804
+ value = getattr(obj, field_name)
805
+ payload[field_name] = _serialize_response_value(
806
+ field_info.annotation, value
807
+ )
808
+ elif field_info.alias and hasattr(obj, field_info.alias):
809
+ payload[field_name] = getattr(obj, field_info.alias)
810
+
811
+ response_schema = _create_response_validation_schema(self.schema)
812
+ return cast(
813
+ SchemaT,
814
+ response_schema.model_validate(payload, by_alias=False, by_name=True),
815
+ )
816
+
817
+ @staticmethod
818
+ def _to_query_params(query_params: Any) -> QueryParams:
819
+ if isinstance(query_params, QueryParams):
820
+ return query_params
821
+ if isinstance(query_params, pydantic.BaseModel):
822
+ dumped = query_params.model_dump(
823
+ exclude_none=True, by_alias=True, mode="json"
824
+ )
825
+ return QueryParams({k: str(v) for k, v in dumped.items()})
826
+ if isinstance(query_params, dict):
827
+ return QueryParams({k: str(v) for k, v in query_params.items()})
828
+ return QueryParams(query_params)
829
+
830
+ @classmethod
831
+ def _create_pagination_response_schema(
832
+ cls, response_schema: type[pydantic.BaseModel]
833
+ ) -> type[pydantic.BaseModel]:
834
+ return create_model(
835
+ f"{cls.__name__}PaginatedResponse",
836
+ items=(Sequence[response_schema], ...),
837
+ total=(int, ...),
838
+ page=(int | None, None),
839
+ page_size=(int | None, None),
840
+ total_pages=(int | None, None),
841
+ )
842
+
843
+ def to_paginated_listing_response(
844
+ self, query_params: Any, listing_result: ListingResult[Any]
845
+ ) -> dict[str, Any]:
846
+ params = self._to_query_params(query_params)
847
+ payload: dict[str, Any] = {
848
+ "items": [self.to_response_schema(obj) for obj in listing_result.objects],
849
+ "total": listing_result.total_count,
850
+ "page": None,
851
+ "page_size": None,
852
+ "total_pages": None,
853
+ }
854
+ page_size_raw = params.get("page_size")
855
+ if page_size_raw is None and self.default_page_size is None:
856
+ # No implicit cap and the client did not ask for one. Leave
857
+ # page/page_size/total_pages as None.
858
+ return payload
859
+ page = int(params.get("page", "1"))
860
+ if page_size_raw is not None:
861
+ page_size = int(page_size_raw)
862
+ else:
863
+ # The early return above guarantees default_page_size is non-None here.
864
+ page_size = cast(int, self.default_page_size)
865
+ payload["page"] = page
866
+ payload["page_size"] = page_size
867
+ payload["total_pages"] = (
868
+ ceil(listing_result.total_count / page_size) if page_size > 0 else 0
869
+ )
870
+ return payload
871
+
872
+ def to_listing_response(
873
+ self, query_params: Any, listing_result: ListingResult[ModelT]
874
+ ) -> Any:
875
+ if not self.include_pagination_metadata:
876
+ return [self.to_response_schema(obj) for obj in listing_result.objects]
877
+
878
+ return self.to_paginated_listing_response(query_params, listing_result)
879
+
880
+ @classmethod
881
+ def before_include_view(cls):
882
+ """
883
+ Apply type annotations needed for FastAPI, before creating an APIRouter from
884
+ this view and registering it.
885
+
886
+ This function can be overridden to further tweak the endpoints before they
887
+ are added to FastAPI.
888
+ """
889
+ # Auto-generate schema if none is provided. Each of these guards
890
+ # checks ``cls.__dict__`` — not ``hasattr`` — so a subclass that
891
+ # changes ``schema``/``default_page_size``/``max_page_size`` regenerates
892
+ # the derived schemas instead of silently inheriting the parent's.
893
+ if "schema" not in cls.__dict__:
894
+ if not hasattr(cls, "model"):
895
+ raise ValueError(
896
+ f"'{cls.__name__}.model' must be specified to auto-generate schema"
897
+ )
898
+ cls.schema = cast(
899
+ type[SchemaT], auto_generate_schema_for_view(cls, cls.model)
900
+ )
901
+
902
+ if "listing_param_schema" not in cls.__dict__:
903
+ cls.listing_param_schema = create_list_params_schema(
904
+ cls.schema,
905
+ default_page_size=cls.default_page_size,
906
+ max_page_size=cls.max_page_size,
907
+ )
908
+ if "creation_schema" not in cls.__dict__:
909
+ cls.creation_schema = cast(
910
+ type[CreateSchemaT], create_model_without_read_only_fields(cls.schema)
911
+ )
912
+ if "update_schema" not in cls.__dict__:
913
+ cls.update_schema = cast(
914
+ type[UpdateSchemaT], create_model_with_optional_fields(cls.schema)
915
+ )
916
+
917
+ response_schema = cls.schema
918
+
919
+ # Only annotate if the methods exist (they will be overridden in subclasses)
920
+ listing_response_annotation: Any = Sequence[response_schema]
921
+ if cls.include_pagination_metadata:
922
+ cls.pagination_response_schema = cls._create_pagination_response_schema(
923
+ response_schema
924
+ )
925
+ listing_response_annotation = cls.pagination_response_schema
926
+
927
+ # ``listing``/``get``/``create``/``update``/``delete`` are defined on
928
+ # AsyncRestView/RestView subclasses and may be excluded by ``exclude_routes``,
929
+ # so they aren't visible on BaseRestView. ``getattr`` keeps pyright happy
930
+ # without falsely advertising them on the base class.
931
+ if (listing := getattr(cls, "listing", None)) is not None:
932
+ _annotate(
933
+ listing,
934
+ return_annotation=listing_response_annotation,
935
+ query_params=Annotated[cls.listing_param_schema, fastapi.Query()],
936
+ )
937
+ if (get := getattr(cls, "get", None)) is not None:
938
+ _annotate(get, return_annotation=response_schema, id=cls.id_type)
939
+ if (create := getattr(cls, "create", None)) is not None:
940
+ _annotate(
941
+ create,
942
+ return_annotation=response_schema,
943
+ schema_obj=cls.creation_schema,
944
+ )
945
+ if (update := getattr(cls, "update", None)) is not None:
946
+ _annotate(
947
+ update,
948
+ return_annotation=response_schema,
949
+ schema_obj=cls.update_schema,
950
+ id=cls.id_type,
951
+ )
952
+ if (delete := getattr(cls, "delete", None)) is not None:
953
+ _annotate(delete, return_annotation=fastapi.Response, id=cls.id_type)
954
+ _exclude_routes(cls)
955
+
956
+
957
+ def _exclude_routes(cls: type[BaseRestView[Any, Any, Any, Any, Any]]):
958
+ for route_name in cls.exclude_routes:
959
+ method_name = (
960
+ route_name.value if isinstance(route_name, ViewRoute) else route_name
961
+ )
962
+ # @route decorator adds `_api_route_args` to a method to create the route later.
963
+ # By removing it from the method, the method will no longer be added as a route.
964
+ try:
965
+ view_func = getattr(cls, method_name)
966
+ except AttributeError:
967
+ raise AttributeError(f"{method_name!r} is not a route on {cls.__name__}")
968
+ if not hasattr(view_func, "_api_route_args"):
969
+ raise AttributeError(f"{method_name!r} is not a route on {cls.__name__}")
970
+ del view_func._api_route_args
971
+
972
+
973
+ def _init_view_cls_and_add_to_router(
974
+ view_cls: type[View], parent_router: fastapi.APIRouter | fastapi.FastAPI
975
+ ):
976
+ """
977
+ To make View classes work in FastAPI some hacks are needed. Those hacks are
978
+ applied here.
979
+
980
+ FastAPI does a lot with annotations. For example, accepted or returned JSON is
981
+ often described with Pydantic classes like this:
982
+
983
+ def my_endpoint(foo: FooRead) -> FooRead:
984
+
985
+ Most of the hacks here are to set the correct annotations on (inherited) class
986
+ methods.
987
+
988
+ The class-level preparation (copying parent endpoints, renaming, annotating,
989
+ schema generation, dataclass-style __init__) only runs once per View class —
990
+ subsequent calls to ``include_view()`` reuse the prepared class and only
991
+ construct a fresh APIRouter to mount on the new parent. This makes
992
+ registering the same view on multiple routers safe.
993
+ """
994
+ _prepare_view_class(view_cls)
995
+ api_router = _init_api_router(view_cls)
996
+ _register_for_resource_ref(parent_router, view_cls)
997
+ parent_router.include_router(api_router)
998
+ # Fallback registration for users who skip ``fr.configure(app=...)``.
999
+ # ``register_default_exception_handlers`` is idempotent and only acts on
1000
+ # FastAPI apps (it ignores nested APIRouter parents).
1001
+ if isinstance(parent_router, fastapi.FastAPI):
1002
+ register_default_exception_handlers(parent_router)
1003
+
1004
+
1005
+ def _prepare_view_class(view_cls: type[View]) -> None:
1006
+ """Run the one-time class-level setup for a View.
1007
+
1008
+ Guarded by the ``_fr_initialised`` marker (stored in ``__dict__`` so it is
1009
+ not inherited from a parent class that was registered separately). Calling
1010
+ this multiple times is a no-op after the first run.
1011
+ """
1012
+ if view_cls.__dict__.get("_fr_initialised", False):
1013
+ return
1014
+ _copy_all_parent_class_endpoints_into_this_subclass(view_cls)
1015
+ _init_all_endpoints(view_cls)
1016
+ view_cls.before_include_view()
1017
+ _init_class_based_view(view_cls)
1018
+ view_cls._fr_initialised = True # type: ignore[attr-defined]
1019
+
1020
+
1021
+ def _copy_all_parent_class_endpoints_into_this_subclass(view_cls: type[View]):
1022
+ """
1023
+ Override all methods with a @route decorator of the parent classes of view_cls
1024
+ with a new copy directly on view_cls . This allows us to change the
1025
+ annotations on these endpoints without affecting the parent endpoints.
1026
+
1027
+ For example, FooView.get() delegates to AsyncRestView.get() if it is not
1028
+ overridden (this is called implicit delegation through method resolution). And if
1029
+ we add the annotation that FooView.get() returns FooRead but do not make a copy
1030
+ then AsyncRestView.get() and all other subclasses will get the FooRead
1031
+ annotation as well.
1032
+ """
1033
+ for endpoint in _get_all_parent_endpoints(view_cls):
1034
+ # Use `cls.__dict__` to check what attributes are directly on the class.
1035
+ # This way we side-step the method resolution.
1036
+ if endpoint.__name__ in view_cls.__dict__:
1037
+ # This endpoint is already overridden!
1038
+ continue
1039
+
1040
+ # The original endpoint might be shared between subclasses.
1041
+ # So make a copy and put that on the view_cls.
1042
+ endpoint_wrapper = _make_copy(endpoint, view_cls)
1043
+ # Set explicit __qualname__ for debugging purposes.
1044
+ endpoint_wrapper.__qualname__ = (
1045
+ f"{view_cls.__name__}_{endpoint.__qualname__}_wrapper"
1046
+ )
1047
+ setattr(view_cls, endpoint.__name__, endpoint_wrapper)
1048
+
1049
+
1050
+ def _make_copy(endpoint: Callable, view_cls: type[View]) -> Callable:
1051
+ """
1052
+ Wrap the endpoint in a new function as kind of copy.
1053
+
1054
+ Fun fact: You cannot do this inside a for loop, because the closure of 'endpoint'
1055
+ inside the wrapper works on the variable, not on the value. And for-loops in Python
1056
+ do not have their own variable scope.
1057
+
1058
+ https://eev.ee/blog/2011/04/24/gotcha-python-scoping-closures/
1059
+ """
1060
+ if inspect.iscoroutinefunction(endpoint):
1061
+
1062
+ @functools.wraps(endpoint)
1063
+ async def _async_wrapper(self, *args, **kwargs):
1064
+ return await endpoint(self, *args, **kwargs)
1065
+
1066
+ endpoint_wrapper: Callable = _async_wrapper
1067
+ else:
1068
+
1069
+ @functools.wraps(endpoint)
1070
+ def _sync_wrapper(self, *args, **kwargs):
1071
+ return endpoint(self, *args, **kwargs)
1072
+
1073
+ endpoint_wrapper = _sync_wrapper
1074
+
1075
+ endpoint_wrapper.__annotations__ = endpoint.__annotations__.copy()
1076
+ return endpoint_wrapper
1077
+
1078
+
1079
+ def _init_all_endpoints(view_cls: type[View]):
1080
+ """
1081
+ Ensure every endpoint has a unique name and update the 'self' annotation.
1082
+ """
1083
+ for attr in view_cls.__dict__.values():
1084
+ if not hasattr(attr, "_api_route_args"):
1085
+ continue
1086
+ endpoint = attr
1087
+ # Give every endpoint a unique name
1088
+ # This will give the FooView.create() endpoint the name "fooview_create"
1089
+ endpoint.__name__ = view_cls.__name__.lower() + "_" + endpoint.__name__
1090
+ _annotate_self(view_cls, endpoint)
1091
+
1092
+
1093
+ def _annotate(func: Callable, return_annotation: Any = None, **param_annotations):
1094
+ """
1095
+ Annotate a function by setting func.__signature__ explicitly.
1096
+ """
1097
+ sig = inspect.signature(func)
1098
+ new_params = []
1099
+ for param in sig.parameters.values():
1100
+ if param.name in param_annotations:
1101
+ annotation = param_annotations[param.name]
1102
+ new_param = param.replace(annotation=annotation)
1103
+ new_params.append(new_param)
1104
+ else:
1105
+ new_params.append(param)
1106
+ func.__signature__ = sig.replace( # type: ignore[attr-defined]
1107
+ parameters=new_params, return_annotation=return_annotation
1108
+ )
1109
+
1110
+
1111
+ def _get_all_parent_endpoints(view_cls: type[View]) -> list[Callable]:
1112
+ endpoints = []
1113
+ for cls in view_cls.mro():
1114
+ if cls is view_cls:
1115
+ continue
1116
+ for name, value in cls.__dict__.items():
1117
+ if hasattr(value, "_api_route_args"):
1118
+ endpoints.append(value)
1119
+ return endpoints
1120
+
1121
+
1122
+ def _init_api_router(view_cls: type[View]) -> fastapi.APIRouter:
1123
+ # Concatenate prefixes defined at each level of the class hierarchy (base → derived).
1124
+ prefix = "".join(
1125
+ c.__dict__["prefix"] for c in reversed(view_cls.mro()) if "prefix" in c.__dict__
1126
+ )
1127
+ tags = _get_router_tags(view_cls, prefix)
1128
+ api_router = fastapi.APIRouter(
1129
+ prefix=prefix,
1130
+ tags=tags,
1131
+ responses=view_cls.responses,
1132
+ dependencies=view_cls.dependencies,
1133
+ )
1134
+
1135
+ # Find all endpoint functions in this class and add them to the router
1136
+ for attr in view_cls.__dict__.values():
1137
+ if not hasattr(attr, "_api_route_args"):
1138
+ continue
1139
+ endpoint = attr
1140
+ path, route_kwargs = endpoint._api_route_args
1141
+ _add_api_route(api_router, view_cls, path, endpoint, route_kwargs)
1142
+
1143
+ return api_router
1144
+
1145
+
1146
+ def _get_router_tags(view_cls: type[View], prefix: str) -> list[str | Enum]:
1147
+ if view_cls.tags is not None:
1148
+ return list(view_cls.tags)
1149
+ return [_derive_tag_from_prefix(prefix) or view_cls.__name__]
1150
+
1151
+
1152
+ def _derive_tag_from_prefix(prefix: str) -> str | None:
1153
+ segments = [segment for segment in prefix.strip("/").split("/") if segment]
1154
+ if not segments:
1155
+ return None
1156
+ return segments[-1].replace("-", " ").replace("_", " ").title()
1157
+
1158
+
1159
+ def _add_api_route(
1160
+ api_router: fastapi.APIRouter,
1161
+ view_cls: type[View],
1162
+ path: str,
1163
+ endpoint: Callable,
1164
+ route_kwargs: dict[str, Any],
1165
+ ) -> None:
1166
+ if _should_add_collection_route_alias(view_cls, path, endpoint):
1167
+ api_router.add_api_route("", endpoint, **route_kwargs)
1168
+ hidden_alias_kwargs = {**route_kwargs, "include_in_schema": False}
1169
+ api_router.add_api_route("/", endpoint, **hidden_alias_kwargs)
1170
+ return
1171
+
1172
+ api_router.add_api_route(path, endpoint, **route_kwargs)
1173
+
1174
+
1175
+ def _should_add_collection_route_alias(
1176
+ view_cls: type[View], path: str, endpoint: Callable
1177
+ ) -> bool:
1178
+ if not issubclass(view_cls, BaseRestView):
1179
+ return False
1180
+ if path != "/":
1181
+ return False
1182
+ return endpoint.__name__.endswith(("_listing", "_create"))
1183
+
1184
+
1185
+ def _annotate_self(view_cls: type[View], endpoint: Callable) -> None:
1186
+ """
1187
+ Annotate the 'self' argument as 'self=Depends(view_cls)'. That way FastAPI instantiates the
1188
+ view_cls before calling the endpoint function and passes it as 'self'.
1189
+ Note that it sets endpoint.__signature__ which overrides any other inspection.
1190
+
1191
+ Note: Copied (MIT license) and adjusted from: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py
1192
+
1193
+ Fixes the endpoint signature to ensure FastAPI performs dependency injection properly.
1194
+ """
1195
+ sig = inspect.signature(endpoint)
1196
+ params: list[inspect.Parameter] = list(sig.parameters.values())
1197
+ self_param = params[0]
1198
+ new_self_param = self_param.replace(default=fastapi.Depends(view_cls))
1199
+
1200
+ new_params = [new_self_param] + [
1201
+ param.replace(kind=inspect.Parameter.KEYWORD_ONLY) for param in params[1:]
1202
+ ]
1203
+ endpoint.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined]
1204
+
1205
+
1206
+ # Bare-typed annotations FastAPI special-cases for parameter injection
1207
+ # (no ``Depends(...)`` marker required). Treated alongside ``Depends``-
1208
+ # marked annotations as DI-wired class attributes; everything else is
1209
+ # left as plain typing.
1210
+ _FASTAPI_SPECIAL_INJECTABLE: tuple[type, ...] = (
1211
+ Request,
1212
+ Response,
1213
+ BackgroundTasks,
1214
+ WebSocket,
1215
+ )
1216
+
1217
+
1218
+ def _init_class_based_view(view_cls: type[View]) -> None:
1219
+ """
1220
+ Note: Copied (MIT license) and adjusted from: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py
1221
+
1222
+ Idempotently modifies the provided `cls`, performing the following modifications:
1223
+ * The `__init__` function is updated to set any class-annotated dependencies as instance attributes
1224
+ * The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer
1225
+ """
1226
+ if getattr(view_cls, "__class_based_view", False):
1227
+ return # Already initialized
1228
+ old_init: Callable[..., Any] = view_cls.__init__
1229
+ old_signature = inspect.signature(old_init)
1230
+ old_parameters = list(old_signature.parameters.values())[1:] # drop `self`
1231
+ new_parameters = [
1232
+ x
1233
+ for x in old_parameters
1234
+ if x.kind
1235
+ not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
1236
+ ]
1237
+ # Marker-based DI with MRO-aware shadowing: walk the MRO from the
1238
+ # base classes upward and pick, for each name, an annotation that
1239
+ # either carries a ``Depends(...)`` marker or names one of FastAPI's
1240
+ # bare-injectable special types (``Request`` / ``Response`` etc.).
1241
+ # A *plain* annotation on a more-derived class (e.g. a mixin
1242
+ # declaring ``session: AsyncSession`` for static-typing purposes)
1243
+ # does NOT shadow a marker-bearing annotation from a base — the
1244
+ # framework prefers wiring fidelity over the most-derived hint.
1245
+ # Without this rule, any plain annotation a mixin adds would
1246
+ # silently break dependency injection.
1247
+ di_annotations: dict[str, Any] = {}
1248
+ for cls in reversed(view_cls.__mro__):
1249
+ try:
1250
+ cls_hints = get_type_hints(cls, include_extras=True)
1251
+ except Exception:
1252
+ continue
1253
+ for name, annotation in cls_hints.items():
1254
+ if get_origin(annotation) is ClassVar:
1255
+ continue
1256
+ metadata = getattr(annotation, "__metadata__", ())
1257
+ has_depends_marker = any(isinstance(m, _DependsMarker) for m in metadata)
1258
+ underlying = (
1259
+ annotation
1260
+ if get_origin(annotation) is not Annotated
1261
+ else (get_args(annotation)[0] if get_args(annotation) else annotation)
1262
+ )
1263
+ is_special_type = inspect.isclass(underlying) and issubclass(
1264
+ underlying, _FASTAPI_SPECIAL_INJECTABLE
1265
+ )
1266
+ if has_depends_marker or is_special_type:
1267
+ # Marker-bearing annotation wins, regardless of MRO position.
1268
+ di_annotations[name] = annotation
1269
+ # Plain annotations are silently ignored — they neither set
1270
+ # nor clear an entry in di_annotations.
1271
+
1272
+ dependency_names: list[str] = []
1273
+ for name, annotation in di_annotations.items():
1274
+ dependency_names.append(name)
1275
+ default_value = getattr(view_cls, name, inspect.Parameter.empty)
1276
+ new_parameters.append(
1277
+ inspect.Parameter(
1278
+ name=name,
1279
+ kind=inspect.Parameter.KEYWORD_ONLY,
1280
+ default=default_value,
1281
+ annotation=annotation,
1282
+ )
1283
+ )
1284
+ new_signature = old_signature.replace(parameters=new_parameters)
1285
+
1286
+ def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
1287
+ for dep_name in dependency_names:
1288
+ dep_value = kwargs.pop(dep_name)
1289
+ setattr(self, dep_name, dep_value)
1290
+ old_init(self, *args, **kwargs)
1291
+
1292
+ setattr(view_cls, "__signature__", new_signature)
1293
+ setattr(view_cls, "__init__", new_init)
1294
+ setattr(view_cls, "__class_based_view", True)