fastapi-basekit 0.1.25__tar.gz → 0.2.0__tar.gz

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.
Files changed (59) hide show
  1. fastapi_basekit-0.2.0/PKG-INFO +774 -0
  2. fastapi_basekit-0.2.0/README.md +739 -0
  3. fastapi_basekit-0.2.0/fastapi_basekit/aio/__init__.py +1 -0
  4. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/service/base.py +14 -12
  5. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/__init__.py +9 -0
  6. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/controller/__init__.py +3 -0
  7. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/controller/base.py +107 -0
  8. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/repository/__init__.py +3 -0
  9. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/repository/base.py +587 -0
  10. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/service/__init__.py +3 -0
  11. fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/service/base.py +173 -0
  12. fastapi_basekit-0.2.0/fastapi_basekit.egg-info/PKG-INFO +774 -0
  13. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/SOURCES.txt +9 -1
  14. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/requires.txt +4 -0
  15. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/pyproject.toml +6 -2
  16. fastapi_basekit-0.2.0/tests/test_sqlalchemy_base_service_order.py +62 -0
  17. fastapi_basekit-0.1.25/PKG-INFO +0 -791
  18. fastapi_basekit-0.1.25/README.md +0 -759
  19. fastapi_basekit-0.1.25/fastapi_basekit/aio/__init__.py +0 -1
  20. fastapi_basekit-0.1.25/fastapi_basekit.egg-info/PKG-INFO +0 -791
  21. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/LICENSE +0 -0
  22. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/__init__.py +0 -0
  23. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  24. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  25. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
  26. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  27. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/repository/base.py +0 -0
  28. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  29. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/service/base.py +0 -0
  30. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/controller/__init__.py +0 -0
  31. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/controller/base.py +0 -0
  32. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  33. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/permissions/base.py +0 -0
  34. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  35. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  36. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -0
  37. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  38. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/repository/base.py +0 -0
  39. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  40. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/exceptions/__init__.py +0 -0
  41. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  42. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/exceptions/handler.py +0 -0
  43. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/__init__.py +0 -0
  44. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/base.py +0 -0
  45. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/jwt.py +0 -0
  46. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/schema.py +0 -0
  47. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/servicios/__init__.py +0 -0
  48. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  49. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  50. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  51. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/top_level.txt +0 -0
  52. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/setup.cfg +0 -0
  53. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_api_exceptions.py +0 -0
  54. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_base_response.py +0 -0
  55. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_base_service.py +0 -0
  56. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_controller_auto_permissions.py +0 -0
  57. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_crud_beanie_controller.py +0 -0
  58. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_crud_controller.py +0 -0
  59. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.0}/tests/test_jwt_service.py +0 -0
@@ -0,0 +1,774 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastapi-basekit
3
+ Version: 0.2.0
4
+ Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
+ Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mundobien2025/fastapi-basekit
8
+ Project-URL: Repository, https://github.com/mundobien2025/fastapi-basekit
9
+ Project-URL: Issues, https://github.com/mundobien2025/fastapi-basekit/issues
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: fastapi>=0.116.1
19
+ Requires-Dist: pydantic<3,>=2.11.7
20
+ Requires-Dist: fastapi-restful[all]>=0.6.0
21
+ Provides-Extra: beanie
22
+ Requires-Dist: beanie>=1.24.0; extra == "beanie"
23
+ Requires-Dist: motor>=3.3.0; extra == "beanie"
24
+ Provides-Extra: sqlalchemy
25
+ Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "sqlalchemy"
26
+ Requires-Dist: psycopg2>=2.9.0; extra == "sqlalchemy"
27
+ Provides-Extra: sqlmodel
28
+ Requires-Dist: sqlmodel>=0.0.37; extra == "sqlmodel"
29
+ Provides-Extra: all
30
+ Requires-Dist: beanie>=1.24.0; extra == "all"
31
+ Requires-Dist: motor>=3.3.0; extra == "all"
32
+ Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "all"
33
+ Requires-Dist: psycopg2>=2.9.0; extra == "all"
34
+ Requires-Dist: sqlmodel>=0.0.37; extra == "all"
35
+
36
+ # FastAPI BaseKit
37
+
38
+ ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=flat-square&logo=fastapi)
39
+ ![Python](https://img.shields.io/badge/python-3.11+-blue?style=flat-square&logo=python)
40
+ ![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-red?style=flat-square)
41
+ ![SQLModel](https://img.shields.io/badge/SQLModel-0.0.21+-orange?style=flat-square)
42
+ ![MongoDB](https://img.shields.io/badge/MongoDB-Beanie-47A248?style=flat-square&logo=mongodb)
43
+ ![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)
44
+
45
+ Clases base para construir APIs REST con FastAPI de forma rápida: repositorios, servicios y controllers con CRUD, paginación, búsqueda, filtros y ordenamiento ya resueltos.
46
+
47
+ Soporta **SQLAlchemy**, **SQLModel** y **Beanie (MongoDB)**.
48
+
49
+ ---
50
+
51
+ ## Instalación
52
+
53
+ ```bash
54
+ # Solo lo base (sin ORM)
55
+ pip install fastapi-basekit
56
+
57
+ # SQLAlchemy (PostgreSQL / MySQL / SQLite)
58
+ pip install fastapi-basekit[sqlalchemy]
59
+
60
+ # SQLModel
61
+ pip install fastapi-basekit[sqlmodel]
62
+
63
+ # Beanie (MongoDB)
64
+ pip install fastapi-basekit[beanie]
65
+
66
+ # Todo
67
+ pip install fastapi-basekit[all]
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Inicio rápido — SQLAlchemy
73
+
74
+ El patrón real usado en producción: class-based views con `@cbv` de `fastapi-restful`.
75
+
76
+ ### 1. Modelo
77
+
78
+ ```python
79
+ # app/models/auth.py
80
+ from sqlalchemy import String, Enum as SAEnum
81
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
82
+ from .base import BaseModel # tu Base con id, created_at, etc.
83
+ from .enums import UserStatusEnum
84
+
85
+ class Users(BaseModel):
86
+ __tablename__ = "users"
87
+
88
+ email: Mapped[str] = mapped_column(String(320), unique=True)
89
+ full_name: Mapped[str | None] = mapped_column(String(255))
90
+ document: Mapped[str | None] = mapped_column(String(64))
91
+ phone: Mapped[str | None] = mapped_column(String(50))
92
+ status: Mapped[UserStatusEnum] = mapped_column(SAEnum(UserStatusEnum))
93
+
94
+ # Relación many-to-many
95
+ user_roles: Mapped[list["UserRoles"]] = relationship(back_populates="user")
96
+ ```
97
+
98
+ ### 2. Schemas
99
+
100
+ Puedes tener múltiples schemas por recurso — el controller decide cuál usar por acción.
101
+
102
+ ```python
103
+ # app/schemas/user.py
104
+ from pydantic import BaseModel, ConfigDict
105
+ from uuid import UUID
106
+
107
+ class UserListResponseSchema(BaseModel):
108
+ model_config = ConfigDict(from_attributes=True)
109
+ id: UUID
110
+ email: str
111
+ full_name: str | None
112
+ role_name: str | None # columna extra del queryset enriquecido
113
+
114
+ class UserResponseSchema(BaseModel):
115
+ model_config = ConfigDict(from_attributes=True)
116
+ id: UUID
117
+ email: str
118
+ full_name: str | None
119
+ document: str | None
120
+ phone: str | None
121
+ status: str
122
+ ```
123
+
124
+ ### 3. Repository
125
+
126
+ En el 90% de los casos el repositorio solo necesita declarar el modelo. Todo el CRUD ya está implementado en `BaseRepository`.
127
+
128
+ ```python
129
+ # app/repositories/user.py
130
+ from fastapi_basekit.aio.sqlalchemy.repository.base import BaseRepository
131
+ from app.models.auth import Users
132
+
133
+ class UserRepository(BaseRepository):
134
+ model = Users
135
+ ```
136
+
137
+ Sobreescribe `build_list_queryset` solo si necesitas un query base distinto al `select(model)` por defecto — por ejemplo para agregar columnas calculadas que el schema pueda consumir directamente:
138
+
139
+ ```python
140
+ from sqlalchemy import select, func
141
+ from fastapi_basekit.aio.sqlalchemy.repository.base import BaseRepository
142
+ from app.models.auth import Roles, UserRoles
143
+
144
+ class RoleRepository(BaseRepository):
145
+ model = Roles
146
+
147
+ def build_list_queryset(self, **kwargs):
148
+ """Agrega el conteo de usuarios por rol como columna extra."""
149
+ member_count = (
150
+ select(func.count(UserRoles.user_id))
151
+ .where(UserRoles.role_id == Roles.id)
152
+ .scalar_subquery()
153
+ .label("member_count")
154
+ )
155
+ return select(Roles, member_count)
156
+ ```
157
+
158
+ El schema recibe `member_count` directamente (no hace falta nada más):
159
+
160
+ ```python
161
+ class RoleResponseSchema(BaseModel):
162
+ model_config = ConfigDict(from_attributes=True)
163
+ id: UUID
164
+ name: str
165
+ code: str
166
+ member_count: int
167
+ ```
168
+
169
+ ### Métodos disponibles en BaseRepository
170
+
171
+ ```python
172
+ # Por ID
173
+ user = await repo.get(user_id)
174
+ user = await repo.get_with_joins(user_id, joins=["user_roles", "company"])
175
+
176
+ # Por campo
177
+ user = await repo.get_by_field("email", "john@example.com")
178
+ user = await repo.get_by_field_with_joins("email", "john@example.com", joins=["user_roles"])
179
+
180
+ # Por múltiples filtros
181
+ users = await repo.get_by_filters({"status": "active", "company_id": company_id})
182
+ users = await repo.get_by_filters({"status": ["active", "pending"]}) # IN
183
+ users = await repo.get_by_filters({"status": "active"}, use_or=False)
184
+
185
+ # Con joins + filtros
186
+ user = await repo.get_by_filters_with_joins(
187
+ {"email": "john@example.com"}, joins=["user_roles"], one=True
188
+ )
189
+
190
+ # Filtros en relaciones con sintaxis __
191
+ admins = await repo.get_by_filters({"user_roles__role__code": "admin"})
192
+
193
+ # CRUD
194
+ created = await repo.create({"email": "new@example.com", "full_name": "Jane"})
195
+ updated = await repo.update(user_id, {"full_name": "Jane Doe"})
196
+ deleted = await repo.delete(user_id)
197
+ ```
198
+
199
+ ### 4. Service
200
+
201
+ El servicio usa los métodos del repositorio en sus métodos de negocio. No necesita escribir SQL.
202
+
203
+ ```python
204
+ # app/services/user.py
205
+ from fastapi import Request, Depends
206
+ from fastapi_basekit.aio.sqlalchemy.service.base import BaseService
207
+ from fastapi_basekit.exceptions.api_exceptions import NotFoundException, DatabaseIntegrityException
208
+ from sqlalchemy.ext.asyncio import AsyncSession
209
+ from app.config.database import get_db
210
+ from app.repositories.user import UserRepository
211
+
212
+ class UserService(BaseService):
213
+ repository: UserRepository
214
+
215
+ # Búsqueda textual sobre estos campos (ILIKE %term%)
216
+ search_fields = ["full_name", "email", "document", "phone"]
217
+
218
+ # Verifica duplicados en estos campos al crear
219
+ duplicate_check_fields = ["email"]
220
+
221
+ def get_kwargs_query(self) -> dict:
222
+ """Joins cargados automáticamente según la acción."""
223
+ if self.action in ["list_users", "retrieve"]:
224
+ return {"joins": ["user_roles"]}
225
+ return {}
226
+
227
+ def get_filters(self, filters: dict | None = None) -> dict:
228
+ """Inyecta filtros de negocio antes de consultar."""
229
+ filters = {k: v for k, v in (filters or {}).items() if v is not None}
230
+ user = getattr(self.request.state, "user", None) if self.request else None
231
+ if user and "company_id" not in filters:
232
+ filters["company_id"] = user.company_id
233
+ return filters
234
+
235
+ # Métodos de negocio usando las herramientas del repositorio
236
+ async def get_by_email(self, email: str):
237
+ user = await self.repository.get_by_field("email", email)
238
+ if not user:
239
+ raise NotFoundException(message="Usuario no encontrado")
240
+ return user
241
+
242
+ async def get_with_roles(self, user_id: str):
243
+ return await self.repository.get_with_joins(user_id, joins=["user_roles"])
244
+
245
+ async def get_active_by_company(self, company_id):
246
+ return await self.repository.get_by_filters(
247
+ {"company_id": company_id, "status": "active"}
248
+ )
249
+
250
+ async def find_admins(self):
251
+ # Filtro sobre relación: user_roles → role → code
252
+ return await self.repository.get_by_filters(
253
+ {"user_roles__role__code": "admin"}
254
+ )
255
+
256
+
257
+ def get_user_service(
258
+ request: Request,
259
+ session: AsyncSession = Depends(get_db),
260
+ ) -> UserService:
261
+ return UserService(
262
+ repository=UserRepository(session),
263
+ request=request,
264
+ )
265
+ ```
266
+
267
+ ### 5. Controller
268
+
269
+ ```python
270
+ # app/api/v1/endpoints/user/user.py
271
+ from typing import List, Optional, Type
272
+ from uuid import UUID
273
+
274
+ from fastapi import APIRouter, Depends, Query, status
275
+ from fastapi_restful.cbv import cbv
276
+ from pydantic import BaseModel
277
+
278
+ from fastapi_basekit.aio.sqlalchemy.controller.base import SQLAlchemyBaseController
279
+ from fastapi_basekit.aio.permissions.base import BasePermission
280
+ from fastapi_basekit.schema.base import BaseResponse, BasePaginationResponse
281
+
282
+ from app.schemas.user import UserResponseSchema, UserListResponseSchema
283
+ from app.services.user import UserService, get_user_service
284
+ from app.permissions.user import IsAdminPermission
285
+
286
+ router = APIRouter(prefix="/users", tags=["users"])
287
+
288
+
289
+ @cbv(router)
290
+ class UserController(SQLAlchemyBaseController):
291
+ service: UserService = Depends(get_user_service)
292
+ schema_class = UserResponseSchema
293
+
294
+ def get_schema_class(self) -> Type[BaseModel]:
295
+ """Schema diferente según la acción."""
296
+ if self.action == "list_users":
297
+ return UserListResponseSchema # incluye role_name
298
+ return UserResponseSchema
299
+
300
+ def check_permissions(self) -> List[Type[BasePermission]]:
301
+ """Permisos por acción."""
302
+ if self.action in ["delete_user"]:
303
+ return [IsAdminPermission]
304
+ return []
305
+
306
+ @router.get(
307
+ "/",
308
+ response_model=BasePaginationResponse[UserListResponseSchema],
309
+ status_code=status.HTTP_200_OK,
310
+ )
311
+ async def list_users(
312
+ self,
313
+ page: int = Query(1, ge=1),
314
+ count: int = Query(10, ge=1, le=100),
315
+ search: Optional[str] = Query(None, description="Busca en nombre, email, documento, teléfono"),
316
+ order_by: Optional[str] = Query(None, description="Ej: created_at, -created_at"),
317
+ status: Optional[str] = Query(None),
318
+ ):
319
+ """Lista usuarios con paginación, búsqueda y filtros."""
320
+ return await self.list()
321
+
322
+ @router.get("/{user_id}/", response_model=BaseResponse[UserResponseSchema])
323
+ async def get_user(self, user_id: UUID):
324
+ return await self.retrieve(str(user_id))
325
+
326
+ @router.delete("/{user_id}/", response_model=BaseResponse[None])
327
+ async def delete_user(self, user_id: UUID):
328
+ await self.check_permissions_class()
329
+ await self.service.delete(str(user_id))
330
+ return BaseResponse(data=None, message="Usuario eliminado")
331
+ ```
332
+
333
+ ---
334
+
335
+ ## Características destacadas
336
+
337
+ ### Búsqueda multi-campo
338
+
339
+ Define `search_fields` en el servicio. El parámetro `search` del query se convierte automáticamente en `ILIKE %term%` sobre todos esos campos con `OR`.
340
+
341
+ ```python
342
+ class UserService(BaseService):
343
+ search_fields = ["full_name", "email", "document", "phone"]
344
+ ```
345
+
346
+ ```
347
+ GET /users?search=juan → busca "juan" en full_name, email, document, phone
348
+ GET /users?search=@gmail.com → encuentra todos los emails de gmail
349
+ ```
350
+
351
+ Soporta rutas anidadas con `__`:
352
+
353
+ ```python
354
+ search_fields = ["name", "user_roles__role__name"] # busca también en rol relacionado
355
+ ```
356
+
357
+ ---
358
+
359
+ ### Filtros automáticos desde query params
360
+
361
+ Todo lo que el endpoint declara como `Query(...)` (que no sea `page`, `count`, `search`, `order_by`) se pasa automáticamente como filtro. No necesitas extraerlos manualmente.
362
+
363
+ ```python
364
+ @router.get("/")
365
+ async def list_tools(
366
+ self,
367
+ page: int = Query(1, ge=1),
368
+ count: int = Query(10),
369
+ search: Optional[str] = Query(None),
370
+ active: bool | None = Query(None), # → filters["active"]
371
+ tool_type: str | None = Query(None), # → filters["tool_type"]
372
+ platform: str | None = Query(None), # → filters["platform"]
373
+ ):
374
+ return await self.list() # _params() extrae todos los filtros del frame
375
+ ```
376
+
377
+ Soporta filtros con relaciones usando `__`:
378
+
379
+ ```
380
+ GET /users?user_roles__role__code=admin → JOIN automático + WHERE
381
+ ```
382
+
383
+ ---
384
+
385
+ ### Filtros inyectados desde el servicio
386
+
387
+ Usa `get_filters()` para agregar, transformar o validar filtros antes de consultar. Muy útil para filtrar por el usuario autenticado.
388
+
389
+ ```python
390
+ def get_filters(self, filters: dict | None = None) -> dict:
391
+ filters = super().get_filters(filters)
392
+ filters = {k: v for k, v in filters.items() if v is not None}
393
+
394
+ # Inyectar company_id del usuario autenticado
395
+ user = getattr(self.request.state, "user", None) if self.request else None
396
+ if user and "company_id" not in filters:
397
+ filters["company_id"] = user.company_id
398
+
399
+ # Mapear parámetros de query a columnas internas
400
+ if "folder_id" in filters:
401
+ filters["parent_id"] = filters.pop("folder_id")
402
+
403
+ return filters
404
+ ```
405
+
406
+ ---
407
+
408
+ ### Ordenamiento
409
+
410
+ El parámetro `order_by` acepta:
411
+
412
+ | Valor | Resultado |
413
+ |---|---|
414
+ | `created_at` | ORDER BY created_at ASC |
415
+ | `-created_at` | ORDER BY created_at DESC |
416
+ | `user__full_name` | JOIN users + ORDER BY users.full_name ASC |
417
+ | `-user__email` | JOIN users + ORDER BY users.email DESC |
418
+
419
+ ```
420
+ GET /users?order_by=-created_at → más recientes primero
421
+ GET /tools?order_by=tool_type__name → ordenado por nombre del tipo
422
+ ```
423
+
424
+ Para Beanie, soporta ordenamiento anidado usando pipeline de agregación automáticamente:
425
+
426
+ ```
427
+ GET /tools?order_by=-created_at → sort simple
428
+ GET /tools?order_by=tool_type__name → $lookup + $sort automático
429
+ ```
430
+
431
+ ---
432
+
433
+ ### Joins / eager loading (SQLAlchemy)
434
+
435
+ Define qué relaciones cargar según la acción para evitar queries N+1:
436
+
437
+ ```python
438
+ def get_kwargs_query(self) -> dict:
439
+ if self.action in ["list_users", "retrieve"]:
440
+ return {"joins": ["user_roles"]} # selectinload para listas
441
+ return {}
442
+ ```
443
+
444
+ O pásalos directamente desde el controller:
445
+
446
+ ```python
447
+ async def retrieve(self, id: UUID):
448
+ return await self.service.retrieve(str(id), joins=["user_roles", "company"])
449
+ ```
450
+
451
+ ---
452
+
453
+ ### Queryset personalizado con subconsultas
454
+
455
+ Sobreescribe `build_list_queryset()` en el repositorio para cambiar el query base del listado. Los filtros, búsqueda, ordenamiento y paginación se aplican encima automáticamente — no necesitas tocar `list()`.
456
+
457
+ Los parámetros que recibe son los mismos kwargs estándar de `list_paginated` (`filters`, `search`, `order_by`, etc.) — úsalos si quieres tomar decisiones en el query base.
458
+
459
+ ```python
460
+ from sqlalchemy import select
461
+ from fastapi_basekit.aio.sqlalchemy.repository.base import BaseRepository
462
+ from app.models.auth import Users, UserRoles, Roles
463
+
464
+ class UserRepository(BaseRepository):
465
+ model = Users
466
+
467
+ def build_list_queryset(self, **kwargs):
468
+ # Subconsulta correlacionada: nombre del primer rol del usuario
469
+ role_name_subq = (
470
+ select(Roles.name)
471
+ .join(UserRoles, UserRoles.role_id == Roles.id)
472
+ .where(UserRoles.user_id == Users.id)
473
+ .limit(1)
474
+ .scalar_subquery()
475
+ .label("role_name")
476
+ )
477
+ return (
478
+ select(Users, role_name_subq)
479
+ .where(Users.deleted_at.is_(None))
480
+ )
481
+ ```
482
+
483
+ El schema recibe la columna extra directamente gracias a `from_attributes=True`:
484
+
485
+ ```python
486
+ class UserListResponseSchema(BaseModel):
487
+ model_config = ConfigDict(from_attributes=True)
488
+ id: UUID
489
+ email: str
490
+ full_name: str | None
491
+ role_name: str | None # inyectada desde la subconsulta
492
+ ```
493
+
494
+ ---
495
+
496
+ ### Múltiples schemas por controller
497
+
498
+ ```python
499
+ def get_schema_class(self) -> Type[BaseModel]:
500
+ if self.action in ["retrieve", "create", "update"]:
501
+ return ToolDResponseSchema # detallado con relaciones
502
+ return ToolResponseSchema # resumido para el listado
503
+ ```
504
+
505
+ ---
506
+
507
+ ### Múltiples repositorios en un servicio
508
+
509
+ ```python
510
+ class UserService(BaseService):
511
+ def __init__(
512
+ self,
513
+ repository: UserRepository,
514
+ user_role_repository: UserRoleRepository,
515
+ role_repository: RoleRepository,
516
+ permission_repository: PermissionRepository,
517
+ request: Request | None = None,
518
+ ):
519
+ super().__init__(repository, request=request)
520
+ self.user_role_repository = user_role_repository
521
+ self.role_repository = role_repository
522
+ self.permission_repository = permission_repository
523
+
524
+
525
+ def get_user_service(
526
+ request: Request,
527
+ session: AsyncSession = Depends(get_db),
528
+ ) -> UserService:
529
+ return UserService(
530
+ repository=UserRepository(session),
531
+ user_role_repository=UserRoleRepository(session),
532
+ role_repository=RoleRepository(session),
533
+ permission_repository=PermissionRepository(session),
534
+ request=request,
535
+ )
536
+ ```
537
+
538
+ ---
539
+
540
+ ### Permisos
541
+
542
+ ```python
543
+ # app/permissions/user.py
544
+ from fastapi import Request
545
+ from fastapi_basekit.aio.permissions.base import BasePermission
546
+
547
+ class IsAdminPermission(BasePermission):
548
+ message_exception = "Solo administradores pueden realizar esta acción"
549
+
550
+ async def has_permission(self, request: Request) -> bool:
551
+ user = getattr(request.state, "user", None)
552
+ role_codes = getattr(request.state, "user_role_codes", [])
553
+ return "admin" in role_codes if user else False
554
+ ```
555
+
556
+ Aplicar en el controller:
557
+
558
+ ```python
559
+ def check_permissions(self) -> List[Type[BasePermission]]:
560
+ if self.action in ["delete_user", "update_profile"]:
561
+ return [IsAdminPermission]
562
+ return []
563
+
564
+ # O manualmente en un método:
565
+ async def delete_user(self, user_id: UUID):
566
+ await self.check_permissions_class()
567
+ await self.service.delete(str(user_id))
568
+ ```
569
+
570
+ ---
571
+
572
+ ## Beanie (MongoDB)
573
+
574
+ El mismo patrón, usando `BeanieBaseController` y `BeanieBaseService`.
575
+
576
+ ```python
577
+ # app/api/v1/endpoints/tool/tool.py
578
+ from fastapi_basekit.aio.beanie.controller.base import BeanieBaseController
579
+ from fastapi_basekit.aio.beanie.service.base import BaseService
580
+
581
+ @cbv(router)
582
+ class ToolController(BeanieBaseController):
583
+ service: ToolService = Depends(get_tool_service)
584
+ schema_class = ToolResponseSchema
585
+
586
+ def get_schema_class(self):
587
+ if self.action in ["retrieve", "create", "update"]:
588
+ return ToolDResponseSchema
589
+ return ToolResponseSchema
590
+
591
+ @router.get("/", response_model=ToolPResponseSchema)
592
+ async def list(
593
+ self,
594
+ page: int = Query(1, ge=1),
595
+ count: int = Query(10, ge=1),
596
+ search: str | None = Query(None),
597
+ tool_type: PydanticObjectId | None = Query(None),
598
+ active: bool | None = Query(None),
599
+ order_by: str | None = Query(None),
600
+ ):
601
+ return await super().list()
602
+ ```
603
+
604
+ ### fetch_links (relaciones en Beanie)
605
+
606
+ ```python
607
+ class ToolService(BaseService):
608
+ search_fields = ["name", "description"]
609
+
610
+ def get_kwargs_query(self) -> dict:
611
+ """Carga relaciones según la acción."""
612
+ if self.action in ["list", "retrieve", "create", "update"]:
613
+ return {
614
+ "fetch_links": True,
615
+ "nesting_depths_per_field": {"tool_type": 2, "platform": 1},
616
+ }
617
+ return {}
618
+ ```
619
+
620
+ ### get_filters en Beanie
621
+
622
+ ```python
623
+ def get_filters(self, filters: dict | None = None) -> dict:
624
+ filters = {k: v for k, v in (filters or {}).items() if v is not None}
625
+
626
+ user = getattr(self.request.state, "user", None) if self.request else None
627
+ if user:
628
+ category = filters.pop("category", "user")
629
+ if category == "user":
630
+ filters["user"] = user.id
631
+ elif category == "global":
632
+ filters["category"] = ToolCategoryEnum.GLOBAL
633
+ # category="all" → sin filtro adicional
634
+
635
+ return filters
636
+ ```
637
+
638
+ ---
639
+
640
+ ## SQLModel
641
+
642
+ Mismo contrato que SQLAlchemy, solo cambia la sesión y la forma de definir modelos.
643
+
644
+ ```python
645
+ pip install fastapi-basekit[sqlmodel]
646
+ ```
647
+
648
+ ```python
649
+ from sqlmodel import SQLModel, Field, Relationship
650
+
651
+ class Hero(SQLModel, table=True):
652
+ id: int | None = Field(default=None, primary_key=True)
653
+ name: str
654
+ team_id: int | None = Field(default=None, foreign_key="team.id")
655
+ team: "Team | None" = Relationship(back_populates="heroes")
656
+ ```
657
+
658
+ ```python
659
+ from fastapi_basekit.aio.sqlmodel import (
660
+ SQLModelBaseController,
661
+ BaseRepository,
662
+ BaseService,
663
+ )
664
+
665
+ class HeroRepository(BaseRepository):
666
+ model = Hero
667
+
668
+ class HeroService(BaseService):
669
+ search_fields = ["name"]
670
+ duplicate_check_fields = ["name"]
671
+
672
+ @cbv(router)
673
+ class HeroController(SQLModelBaseController):
674
+ service: HeroService = Depends(get_hero_service)
675
+ schema_class = HeroSchema
676
+ ```
677
+
678
+ La sesión usa `sqlmodel.ext.asyncio.session.AsyncSession` internamente. Los queries usan `session.exec()` para tipos seguros.
679
+
680
+ ---
681
+
682
+ ## Formato de respuesta
683
+
684
+ Todas las respuestas siguen el mismo envelope:
685
+
686
+ ```python
687
+ # Detalle / create / update
688
+ BaseResponse[Schema]
689
+ {
690
+ "data": { ... },
691
+ "message": "Operación exitosa",
692
+ "status": "success"
693
+ }
694
+
695
+ # Listado paginado
696
+ BasePaginationResponse[Schema]
697
+ {
698
+ "data": [ ... ],
699
+ "pagination": {
700
+ "page": 1,
701
+ "count": 10,
702
+ "total": 87,
703
+ "total_pages": 9
704
+ },
705
+ "message": "Operación exitosa",
706
+ "status": "status"
707
+ }
708
+ ```
709
+
710
+ Declara el `response_model` en el decorador para que FastAPI genere el OpenAPI correcto:
711
+
712
+ ```python
713
+ @router.get("/", response_model=BasePaginationResponse[UserListResponseSchema])
714
+ async def list_users(self, ...):
715
+ return await self.list()
716
+
717
+ @router.get("/{id}/", response_model=BaseResponse[UserResponseSchema])
718
+ async def get_user(self, id: UUID):
719
+ return await self.retrieve(str(id))
720
+ ```
721
+
722
+ ---
723
+
724
+ ## Excepciones
725
+
726
+ ```python
727
+ from fastapi_basekit.exceptions.api_exceptions import (
728
+ NotFoundException, # 404
729
+ DatabaseIntegrityException, # 400 — registro duplicado
730
+ ValidationException, # 422
731
+ PermissionException, # 403
732
+ JWTAuthenticationException, # 401
733
+ GlobalException, # 500
734
+ )
735
+
736
+ # Uso en servicio o repositorio
737
+ raise NotFoundException(message="Usuario no encontrado")
738
+ raise DatabaseIntegrityException(message="El email ya está en uso", data={"email": email})
739
+ ```
740
+
741
+ Registra el handler global en `main.py`:
742
+
743
+ ```python
744
+ from fastapi_basekit.exceptions.handler import register_exception_handlers
745
+
746
+ app = FastAPI()
747
+ register_exception_handlers(app)
748
+ ```
749
+
750
+ ---
751
+
752
+ ## Arquitectura
753
+
754
+ ```
755
+ Controller ← valida parámetros, permisos, schema de respuesta
756
+
757
+ Service ← lógica de negocio, get_filters(), build_queryset()
758
+
759
+ Repository ← acceso a datos, queries SQL/Mongo, build_list_queryset()
760
+
761
+ DB ← SQLAlchemy / SQLModel / Beanie
762
+ ```
763
+
764
+ ---
765
+
766
+ ## Changelog
767
+
768
+ Ver [CHANGELOG.md](./CHANGELOG.md)
769
+
770
+ ---
771
+
772
+ ## Licencia
773
+
774
+ MIT — ver [LICENSE](./LICENSE)