fastapi-crud-generator 0.0.1__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,9 @@
1
+ __version__ = "0.0.1"
2
+
3
+ from fastapi_crud_generator.config import CRUDConfigDict, NestedConfig, RouterKwargs
4
+ from fastapi_crud_generator.crud_generator import (
5
+ CRUDCollection,
6
+ CRUDCollectionBase,
7
+ )
8
+ from fastapi_crud_generator.schemas import PaginatorPage, Message
9
+ from fastapi_crud_generator.orm.base import ORMAdapterBase
@@ -0,0 +1,50 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, TypedDict
4
+
5
+ from fastapi.params import Depends
6
+
7
+
8
+ class RouterKwargs(TypedDict, total=False):
9
+ """Typed subset of ``APIRouter.include_router`` keyword arguments."""
10
+
11
+ tags: list[str]
12
+ dependencies: Sequence[Depends]
13
+ deprecated: bool
14
+ include_in_schema: bool
15
+ responses: dict[int | str, dict[str, Any]]
16
+
17
+
18
+ @dataclass
19
+ class NestedConfig:
20
+ """Configuration for a nested collection.
21
+
22
+ Passed as ``config`` to ``add_nested_collection``.
23
+
24
+ ``router_kwargs`` accepts any ``APIRouter.include_router`` parameters
25
+ (tags, dependencies, deprecated, include_in_schema, responses).
26
+ """
27
+
28
+ router_kwargs: RouterKwargs = field(default_factory=dict)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CRUDConfigDict:
33
+ """Configuration for automatic CRUD schema generation.
34
+
35
+ Place on a SQLModel table class as ``crud_config`` to control
36
+ how the ORM adapter generates schemas and routes.
37
+
38
+ Attributes:
39
+ base_fields: Fields shared across schemas; used as a fallback
40
+ when public_fields, create_fields or update_fields are not set.
41
+ public_fields: Fields exposed in the public (GET) schema.
42
+ create_fields: Fields accepted in the create (POST) schema.
43
+ update_fields: Fields accepted in the update (PATCH) schema.
44
+
45
+ """
46
+
47
+ base_fields: set[str] = field(default_factory=set)
48
+ public_fields: set[str] = field(default_factory=set)
49
+ create_fields: set[str] = field(default_factory=set)
50
+ update_fields: set[str] = field(default_factory=set)
@@ -0,0 +1,587 @@
1
+ import inspect
2
+ import re
3
+ from abc import ABC
4
+ from collections.abc import Callable
5
+ from functools import wraps
6
+ from typing import Annotated, Any, get_type_hints
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from fastapi.routing import APIRoute
10
+ from pydantic import BaseModel, Field, create_model
11
+
12
+ from fastapi_crud_generator.config import NestedConfig
13
+ from fastapi_crud_generator.deps import (
14
+ ConstantDependency,
15
+ CreateSchemaDependency,
16
+ FilterSchemaDependency,
17
+ IncludeSchemaDependency,
18
+ PaginatorDependency,
19
+ ParentPKFieldsDependency,
20
+ PKFieldsDependency,
21
+ PublicListSchemaDependency,
22
+ PublicSchemaDependency,
23
+ ReplaceSignatureDependency,
24
+ ReplaceWithAnnotationDependency,
25
+ SortSchemaDependency,
26
+ UpdateSchemaDependency,
27
+ )
28
+ from fastapi_crud_generator.orm.base import ORMAdapterBase
29
+ from fastapi_crud_generator.paginator import PaginatorBase
30
+ from fastapi_crud_generator.schemas import (
31
+ NotFoundError,
32
+ PaginatorPage,
33
+ ParentNotFoundError,
34
+ )
35
+ from fastapi_crud_generator.strategies import RouterStrategy, TopLevelStrategy
36
+ from fastapi_crud_generator.utils import (
37
+ create_filter_model,
38
+ create_sort_schema,
39
+ )
40
+
41
+ _DEFAULT_STRATEGY: RouterStrategy = TopLevelStrategy()
42
+
43
+
44
+ def _init_list_param(
45
+ param: list | None,
46
+ class_default: list | None,
47
+ ) -> list:
48
+ """Return param if provided, else fall back to class_default or []."""
49
+ return param if param is not None else (class_default or [])
50
+
51
+
52
+ class CRUDCollectionBase(ABC):
53
+ """Base class for CRUD route collections.
54
+
55
+ Subclass this (or ``CRUDCollection``) to create a set of CRUD endpoints
56
+ for a given ORM adapter. Override handler methods or class attributes to
57
+ customise behaviour without touching routing or dependency wiring.
58
+ """
59
+
60
+ orm_adapter: ORMAdapterBase | None = None
61
+
62
+ public_schema: BaseModel = None
63
+ public_list_schema: BaseModel = None
64
+
65
+ update_schema: BaseModel = None
66
+ update_out_schema = Any
67
+
68
+ create_schema: BaseModel = None
69
+ create_out_schema = Any
70
+
71
+ pk_fields: type[BaseModel] | None = None
72
+
73
+ base_fields: set[str] | None = None
74
+ public_fields: set[str] | None = None
75
+ create_fields: set[str] | None = None
76
+ update_fields: set[str] | None = None
77
+
78
+ filter_schema: BaseModel = None
79
+ sort_schema: BaseModel = None
80
+ # include related data
81
+ include_schema: BaseModel = None
82
+
83
+ dependency_overrides: dict[
84
+ type[ReplaceSignatureDependency],
85
+ ReplaceSignatureDependency | Any,
86
+ ] | None = None
87
+
88
+ dependencies: list[Depends] | None = None
89
+ get_one_dependencies: list[Depends] | None = None
90
+ get_many_dependencies: list[Depends] | None = None
91
+ create_dependencies: list[Depends] | None = None
92
+ update_dependencies: list[Depends] | None = None
93
+ delete_dependencies: list[Depends] | None = None
94
+
95
+ disable_get_one: bool = False
96
+ disable_get_many: bool = False
97
+ disable_create: bool = False
98
+ disable_update: bool = False
99
+ disable_delete: bool = False
100
+
101
+ router_kwargs: dict | None = None
102
+
103
+
104
+ def __init__(
105
+ self,
106
+ orm_adapter: ORMAdapterBase = None,
107
+ public_schema: BaseModel | None = None,
108
+ public_list_schema: BaseModel | None = None,
109
+ update_schema: BaseModel | None = None,
110
+ update_out_schema: BaseModel | None = None,
111
+ create_schema: BaseModel | None = None,
112
+ create_out_schema: BaseModel | None = None,
113
+ base_fields: set[str] | None = None,
114
+ public_fields: set[str] | None = None,
115
+ create_fields: set[str] | None = None,
116
+ update_fields: set[str] | None = None,
117
+
118
+ filter_schema: BaseModel | None = None,
119
+ sort_schema: BaseModel | None = None,
120
+ include_schema: BaseModel | None = None,
121
+
122
+ pk_fields: type[BaseModel] | None = None,
123
+
124
+ dependencies: list[Depends] | None = None,
125
+ get_one_dependencies: list[Depends] | None = None,
126
+ get_many_dependencies: list[Depends] | None = None,
127
+ create_dependencies: list[Depends] | None = None,
128
+ update_dependencies: list[Depends] | None = None,
129
+ delete_dependencies: list[Depends] | None = None,
130
+
131
+ disable_get_one: bool | None = None,
132
+ disable_get_many: bool | None = None,
133
+ disable_create: bool | None = None,
134
+ disable_update: bool | None = None,
135
+ disable_delete: bool | None = None,
136
+
137
+ dependency_overrides: dict[
138
+ type[ReplaceSignatureDependency],
139
+ ReplaceSignatureDependency | Any,
140
+ ] | None = None,
141
+
142
+ **router_kwargs: dict,
143
+
144
+ ):
145
+ """Initialise the collection, resolving schemas and dependencies."""
146
+ self.orm_adapter = orm_adapter if orm_adapter else self.orm_adapter
147
+ self.verify_orm_adapter()
148
+
149
+ self.base_fields = base_fields or self.base_fields
150
+ self.public_fields = public_fields or self.public_fields
151
+ self.create_fields = create_fields or self.create_fields
152
+ self.update_fields = update_fields or self.update_fields
153
+
154
+ self.public_schema = (
155
+ public_schema
156
+ or self.public_schema
157
+ or self.orm_adapter.generate_public_schema(
158
+ self.public_fields, self.base_fields,
159
+ )
160
+ )
161
+ self.create_schema = create_schema or self.create_schema
162
+ self.update_schema = (
163
+ update_schema
164
+ or self.update_schema
165
+ or self.orm_adapter.generate_update_schema(
166
+ self.update_fields, self.base_fields,
167
+ )
168
+ )
169
+ self.update_out_schema = update_out_schema or self.update_out_schema
170
+ self.create_out_schema = create_out_schema or self.create_out_schema
171
+ self.public_list_schema = (
172
+ public_list_schema or
173
+ self.public_list_schema or
174
+ PaginatorPage[self.public_schema]
175
+ )
176
+ self.pk_fields = (
177
+ pk_fields or self.pk_fields
178
+ or self.apply_pk_aliases(
179
+ self.orm_adapter.generate_pk_schema(),
180
+ )
181
+ )
182
+ self.filter_schema = filter_schema or self.filter_schema
183
+ if self.filter_schema is None:
184
+ self.filter_schema = create_filter_model(self.public_schema)
185
+ self.sort_schema = sort_schema or self.sort_schema
186
+ if self.sort_schema is None:
187
+ self.sort_schema = create_sort_schema(self.public_schema)
188
+ self.include_schema = include_schema or self.include_schema
189
+ if self.include_schema is None:
190
+ self.include_schema = self.orm_adapter.generate_include_schema()
191
+
192
+ self.dependencies = _init_list_param(
193
+ dependencies, self.dependencies)
194
+ self.get_one_dependencies = _init_list_param(
195
+ get_one_dependencies, self.get_one_dependencies)
196
+ self.get_many_dependencies = _init_list_param(
197
+ get_many_dependencies, self.get_many_dependencies)
198
+ self.create_dependencies = _init_list_param(
199
+ create_dependencies, self.create_dependencies)
200
+ self.update_dependencies = _init_list_param(
201
+ update_dependencies, self.update_dependencies)
202
+ self.delete_dependencies = _init_list_param(
203
+ delete_dependencies, self.delete_dependencies)
204
+
205
+ self.disable_get_one = (disable_get_one if disable_get_one is not None
206
+ else self.disable_get_one)
207
+ self.disable_get_many = (disable_get_many
208
+ if disable_get_many is not None
209
+ else self.disable_get_many)
210
+ self.disable_create = (disable_create if disable_create is not None
211
+ else self.disable_create)
212
+ self.disable_update = (disable_update if disable_update is not None
213
+ else self.disable_update)
214
+ self.disable_delete = (disable_delete if disable_delete is not None
215
+ else self.disable_delete)
216
+
217
+ self.dependency_overrides = (
218
+ dependency_overrides if dependency_overrides is not None
219
+ else (self.dependency_overrides or {})
220
+ )
221
+ self.router_kwargs = (
222
+ router_kwargs if router_kwargs
223
+ else dict(self.router_kwargs or {})
224
+ )
225
+ self.router_kwargs["dependencies"] = self.dependencies
226
+ self._nested_collections: list[
227
+ tuple[str, CRUDCollectionBase]
228
+ ] = []
229
+
230
+
231
+ def apply_pk_aliases(
232
+ self, pk_schema: type[BaseModel],
233
+ ) -> type[BaseModel]:
234
+ """Return pk_schema with model-name-prefixed aliases on plain fields.
235
+
236
+ Fields whose names contain no underscore get an alias
237
+ ``{model_name}_{field}`` (e.g. ``id`` → ``user_id``).
238
+ Override to customise aliasing behaviour.
239
+ """
240
+ model_name = self.orm_adapter.get_model_name()
241
+ if not model_name:
242
+ return pk_schema
243
+ field_defs = {}
244
+ for name, info in pk_schema.model_fields.items():
245
+ alias = name if "_" in name else f"{model_name}_{name}"
246
+ field_defs[name] = (
247
+ info.annotation, Field(validation_alias=alias),
248
+ )
249
+ return create_model(pk_schema.__name__, **field_defs)
250
+
251
+ def verify_orm_adapter(self) -> None:
252
+ """Raise if orm_adapter is not set."""
253
+ if not self.orm_adapter:
254
+ msg = (
255
+ f"{type(self).__module__}.{type(self).__name__}."
256
+ f"orm_adapter must be set"
257
+ )
258
+ raise ValueError(msg)
259
+
260
+ def get_resolved_overrides(
261
+ self,
262
+ strategy: RouterStrategy | None = None,
263
+ ) -> dict[type[ReplaceSignatureDependency], ReplaceSignatureDependency]:
264
+ """Merge default overrides with user-supplied dependency_overrides."""
265
+ strategy = strategy or _DEFAULT_STRATEGY
266
+ create_schema = self.create_schema or strategy.get_create_schema(
267
+ self.orm_adapter, self.create_fields, self.base_fields,
268
+ )
269
+ defaults = {
270
+ CreateSchemaDependency: CreateSchemaDependency(create_schema),
271
+ UpdateSchemaDependency: UpdateSchemaDependency(self.update_schema),
272
+ PublicSchemaDependency: PublicSchemaDependency(self.public_schema),
273
+ PublicListSchemaDependency: PublicListSchemaDependency(
274
+ self.public_list_schema),
275
+ FilterSchemaDependency: FilterSchemaDependency(self.filter_schema),
276
+ SortSchemaDependency: SortSchemaDependency(self.sort_schema),
277
+ IncludeSchemaDependency: IncludeSchemaDependency(
278
+ self.include_schema),
279
+ PKFieldsDependency: PKFieldsDependency(self.pk_fields),
280
+ PaginatorDependency: PaginatorDependency(
281
+ self.orm_adapter.paginator_class,
282
+ ),
283
+ ParentPKFieldsDependency: ConstantDependency([]),
284
+ }
285
+ for key, val in self.dependency_overrides.items():
286
+ defaults[key] = (
287
+ val if isinstance(val, ReplaceSignatureDependency)
288
+ else ReplaceWithAnnotationDependency(val)
289
+ )
290
+ return defaults
291
+
292
+ def override_dependencies( # noqa: C901
293
+ self,
294
+ original_func: Callable,
295
+ overrides: dict | None = None,
296
+ ) -> Callable:
297
+ """Replace marker annotations in a handler's signature with real DI."""
298
+ sig = inspect.signature(original_func)
299
+
300
+ parameters = list(sig.parameters.values())
301
+ new_parameters = []
302
+ seen_names: set[str] = set()
303
+
304
+ if overrides is None:
305
+ overrides = self.get_resolved_overrides()
306
+
307
+ overrided_params: dict[str, ReplaceSignatureDependency] = {}
308
+
309
+ for param in parameters:
310
+ if (getattr(param.annotation, "__metadata__", None) is not None
311
+ and len(param.annotation.__metadata__) > 0
312
+ ):
313
+ annotation = param.annotation.__metadata__[0]
314
+ else:
315
+ annotation = param.annotation
316
+
317
+ if (
318
+ inspect.isclass(annotation)
319
+ and issubclass(annotation, (
320
+ ReplaceSignatureDependency
321
+ ))
322
+ ):
323
+ if annotation not in overrides:
324
+ msg = (
325
+ f"You need to add {annotation.__name__} "
326
+ f"to {type(self).__name__}'s dependency_overrides"
327
+ )
328
+ raise ValueError(msg)
329
+ override = overrides[annotation]
330
+ fresh_params = [
331
+ p for p in override.get_new_params(param)
332
+ if p.name not in seen_names
333
+ ]
334
+ seen_names.update(p.name for p in fresh_params)
335
+ new_parameters.extend(fresh_params)
336
+ overrided_params[param.name] = override
337
+ elif param.name not in seen_names:
338
+ seen_names.add(param.name)
339
+ new_parameters.append(param)
340
+
341
+ new_sig = inspect.Signature(parameters=new_parameters)
342
+ handler_params = set(sig.parameters)
343
+
344
+ @wraps(original_func)
345
+ async def wrapper(**kwargs):
346
+ raw_kwargs = dict(kwargs)
347
+ for name, override in overrided_params.items():
348
+ kwargs[name] = override.pack_to_originals(name, **raw_kwargs)
349
+ kwargs = {k: v for k, v in kwargs.items() if k in handler_params}
350
+ return await original_func(**kwargs)
351
+
352
+ wrapper.__signature__ = new_sig
353
+
354
+ # Remove old annotations
355
+ if overrided_params:
356
+ wrapper.__annotations__ = {
357
+ **get_type_hints(original_func),
358
+ **{ p.name: p.annotation for p in new_parameters },
359
+ }
360
+ all_items = list(wrapper.__annotations__.keys())
361
+ for item_name in all_items:
362
+ if item_name not in new_parameters:
363
+ wrapper.__annotations__.pop(item_name, None)
364
+
365
+ return wrapper
366
+
367
+ async def get_one_handler(
368
+ self,
369
+ pk_field_values: Annotated[BaseModel, PKFieldsDependency],
370
+ include_data: Annotated[BaseModel, IncludeSchemaDependency],
371
+ parent_refs: Annotated[list, ParentPKFieldsDependency],
372
+ ) -> object:
373
+ """Return a single object or call get_one_not_found if missing."""
374
+ result = await self.orm_adapter.get_one(
375
+ pk_field_values, include_data, parent_refs=parent_refs,
376
+ )
377
+ if result is None:
378
+ return await self.get_one_not_found(pk_field_values, include_data)
379
+ return result
380
+
381
+ async def get_one_not_found(
382
+ self, _pk_field_values: BaseModel, _include_data: BaseModel,
383
+ ) -> object:
384
+ """Raise 404 by default; override to customise not-found behavior."""
385
+ raise HTTPException(status_code=404, detail="Not found")
386
+
387
+ async def get_many_handler(
388
+ self,
389
+ paginator: Annotated[PaginatorBase, PaginatorDependency],
390
+ filter_data: Annotated[BaseModel, FilterSchemaDependency],
391
+ sort_data: Annotated[BaseModel, SortSchemaDependency],
392
+ include_data: Annotated[BaseModel, IncludeSchemaDependency],
393
+ parent_refs: Annotated[list, ParentPKFieldsDependency],
394
+ ) -> object:
395
+ """Handle GET / — return a paginated, filtered, sorted list."""
396
+ return await self.orm_adapter.get_many(
397
+ filter_data, sort_data, include_data, paginator,
398
+ parent_refs=parent_refs,
399
+ )
400
+
401
+ async def create_one_handler(
402
+ self,
403
+ create_data: Annotated[BaseModel, CreateSchemaDependency],
404
+ parent_refs: Annotated[list, ParentPKFieldsDependency],
405
+ ) -> object:
406
+ """Handle POST / — persist a new object and return it."""
407
+ try:
408
+ return await self.orm_adapter.create_one(
409
+ create_data, parent_refs=parent_refs,
410
+ )
411
+ except ParentNotFoundError as exc:
412
+ raise HTTPException(
413
+ status_code=404, detail="Parent not found",
414
+ ) from exc
415
+
416
+ async def update_one_handler(
417
+ self,
418
+ pk_field_values: Annotated[BaseModel, PKFieldsDependency],
419
+ update_data: Annotated[BaseModel, UpdateSchemaDependency],
420
+ parent_refs: Annotated[list, ParentPKFieldsDependency],
421
+ ) -> None:
422
+ """Handle PATCH /{pk} — partially update an existing object."""
423
+ try:
424
+ await self.orm_adapter.update_one(
425
+ pk_field_values, update_data, parent_refs=parent_refs,
426
+ )
427
+ except NotFoundError as exc:
428
+ raise HTTPException(status_code=404, detail="Not found") from exc
429
+
430
+ async def delete_one_handler(
431
+ self,
432
+ pk_field_values: Annotated[BaseModel, PKFieldsDependency],
433
+ parent_refs: Annotated[list, ParentPKFieldsDependency],
434
+ ) -> object:
435
+ """Handle DELETE /{pk} — delete an object by primary key."""
436
+ result = await self.orm_adapter.delete_one(
437
+ pk_field_values, parent_refs=parent_refs,
438
+ )
439
+ if result is None:
440
+ raise HTTPException(status_code=404, detail="Not found")
441
+ return result
442
+
443
+ def generate_unique_id(self, route: APIRoute) -> str:
444
+ """Build a unique OpenAPI operation ID from name, path and method."""
445
+ if not route.methods:
446
+ msg = f"Route {route.name!r} has no HTTP methods defined"
447
+ raise ValueError(msg)
448
+ operation_id = re.sub(r"\W", "_", f"{route.name}{route.path_format}")
449
+ method = next(iter(route.methods)).lower()
450
+ return f"{operation_id}_{method}_{type(self.public_schema).__name__}"
451
+
452
+ @property
453
+ def id_path(self) -> str:
454
+ """URL path segment for single-object endpoints, e.g. /{user_id}."""
455
+ return "/" + "/".join(
456
+ f"{{{f.validation_alias or n}}}"
457
+ for n, f in self.pk_fields.model_fields.items()
458
+ )
459
+
460
+ def add_get_many_route(
461
+ self, router: APIRouter, strategy: RouterStrategy | None = None,
462
+ ) -> None:
463
+ """Register GET / on the router."""
464
+ strategy = strategy or _DEFAULT_STRATEGY
465
+ overrides = strategy.get_overrides(self.get_resolved_overrides(strategy))
466
+ if self.public_schema and not self.disable_get_many:
467
+ router.add_api_route(
468
+ "",
469
+ self.override_dependencies(self.get_many_handler, overrides),
470
+ response_model=self.public_list_schema,
471
+ dependencies=self.get_many_dependencies,
472
+ )
473
+
474
+ def add_get_one_route(
475
+ self, router: APIRouter, strategy: RouterStrategy | None = None,
476
+ ) -> None:
477
+ """Register GET /{pk} on the router."""
478
+ strategy = strategy or _DEFAULT_STRATEGY
479
+ overrides = strategy.get_overrides(self.get_resolved_overrides(strategy))
480
+ if self.public_schema and not self.disable_get_one:
481
+ router.add_api_route(
482
+ strategy.compute_id_path(self.pk_fields),
483
+ self.override_dependencies(self.get_one_handler, overrides),
484
+ response_model=self.public_schema,
485
+ generate_unique_id_function=self.generate_unique_id,
486
+ dependencies=self.get_one_dependencies,
487
+ )
488
+
489
+ def add_create_one_route(
490
+ self, router: APIRouter, strategy: RouterStrategy | None = None,
491
+ ) -> None:
492
+ """Register POST / on the router."""
493
+ strategy = strategy or _DEFAULT_STRATEGY
494
+ overrides = strategy.get_overrides(self.get_resolved_overrides(strategy))
495
+ if not self.disable_create:
496
+ router.add_api_route(
497
+ "",
498
+ self.override_dependencies(
499
+ self.create_one_handler, overrides,
500
+ ),
501
+ methods=["POST"],
502
+ response_model=self.create_out_schema,
503
+ dependencies=self.create_dependencies,
504
+ responses={404: {"description": "Parent not found"}},
505
+ )
506
+
507
+ def add_update_one_route(
508
+ self, router: APIRouter, strategy: RouterStrategy | None = None,
509
+ ) -> None:
510
+ """Register PATCH /{pk} on the router."""
511
+ strategy = strategy or _DEFAULT_STRATEGY
512
+ overrides = strategy.get_overrides(self.get_resolved_overrides(strategy))
513
+ if self.update_schema and not self.disable_update:
514
+ router.add_api_route(
515
+ strategy.compute_id_path(self.pk_fields),
516
+ self.override_dependencies(
517
+ self.update_one_handler, overrides,
518
+ ),
519
+ methods=["PATCH"],
520
+ response_model=self.update_out_schema,
521
+ dependencies=self.update_dependencies,
522
+ )
523
+
524
+ def add_delete_one_route(
525
+ self, router: APIRouter, strategy: RouterStrategy | None = None,
526
+ ) -> None:
527
+ """Register DELETE /{pk} on the router."""
528
+ strategy = strategy or _DEFAULT_STRATEGY
529
+ overrides = strategy.get_overrides(self.get_resolved_overrides(strategy))
530
+ if not self.disable_delete:
531
+ router.add_api_route(
532
+ strategy.compute_id_path(self.pk_fields),
533
+ self.override_dependencies(
534
+ self.delete_one_handler, overrides,
535
+ ),
536
+ methods=["DELETE"],
537
+ response_model=self.public_schema,
538
+ dependencies=self.delete_dependencies,
539
+ )
540
+
541
+ def add_nested_collection(
542
+ self,
543
+ path: str,
544
+ child: "CRUDCollectionBase",
545
+ config: NestedConfig | None = None,
546
+ ) -> None:
547
+ """Register child as a nested collection under this collection."""
548
+ self._nested_collections.append(
549
+ (path, child, config or NestedConfig()),
550
+ )
551
+
552
+ def get_router(
553
+ self,
554
+ strategy: RouterStrategy | None = None,
555
+ **router_kwargs: object,
556
+ ) -> APIRouter:
557
+ """Build and return an APIRouter with all enabled CRUD routes."""
558
+ strategy = strategy or _DEFAULT_STRATEGY
559
+ self.verify_orm_adapter()
560
+ effective_kwargs = {**self.router_kwargs, **router_kwargs}
561
+ router = APIRouter(**effective_kwargs)
562
+
563
+ self.add_get_many_route(router, strategy)
564
+ self.add_get_one_route(router, strategy)
565
+ self.add_create_one_route(router, strategy)
566
+ self.add_update_one_route(router, strategy)
567
+ self.add_delete_one_route(router, strategy)
568
+
569
+ if self._nested_collections:
570
+ overrides = strategy.get_overrides(self.get_resolved_overrides(strategy))
571
+ child_strategy = strategy.get_child_strategy(
572
+ pk_dep=overrides[PKFieldsDependency],
573
+ model=self.orm_adapter.model,
574
+ )
575
+ id_path = strategy.compute_id_path(self.pk_fields)
576
+ for path, child, cfg in self._nested_collections:
577
+ prefix = id_path.rstrip("/") + "/" + path.lstrip("/")
578
+ router.include_router(
579
+ child.get_router(strategy=child_strategy),
580
+ prefix=prefix,
581
+ **cfg.router_kwargs,
582
+ )
583
+
584
+ return router
585
+
586
+ class CRUDCollection(CRUDCollectionBase):
587
+ """Concrete CRUD collection — subclass and set orm_adapter to use."""