fastapi-basekit 0.3.2__tar.gz → 0.4.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 (125) hide show
  1. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/PKG-INFO +17 -7
  2. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/README.md +16 -6
  3. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/controller/base.py +2 -0
  4. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/controller/base.py +130 -41
  5. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/controller/base.py +4 -0
  6. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/repository/base.py +63 -13
  7. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/controller/base.py +3 -0
  8. fastapi_basekit-0.4.0/fastapi_basekit/exceptions/__init__.py +4 -0
  9. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/exceptions/handler.py +86 -0
  10. fastapi_basekit-0.4.0/fastapi_basekit/openapi.py +89 -0
  11. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/schema/base.py +1 -1
  12. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit.egg-info/PKG-INFO +17 -7
  13. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit.egg-info/SOURCES.txt +9 -0
  14. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/pyproject.toml +1 -1
  15. fastapi_basekit-0.4.0/tests/test_action_param_leak.py +35 -0
  16. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_base_service.py +18 -1
  17. fastapi_basekit-0.4.0/tests/test_beanie_aggregation_hooks.py +484 -0
  18. fastapi_basekit-0.4.0/tests/test_beanie_aggregation_integration.py +199 -0
  19. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_controller_auto_permissions.py +56 -71
  20. fastapi_basekit-0.4.0/tests/test_crud_sqlmodel_repository_service.py +242 -0
  21. fastapi_basekit-0.4.0/tests/test_exception_handlers.py +55 -0
  22. fastapi_basekit-0.4.0/tests/test_filter_operators.py +78 -0
  23. fastapi_basekit-0.4.0/tests/test_openapi_simplify.py +38 -0
  24. fastapi_basekit-0.4.0/tests/test_sql_queryset_override.py +332 -0
  25. fastapi_basekit-0.3.2/fastapi_basekit/exceptions/__init__.py +0 -3
  26. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/LICENSE +0 -0
  27. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/__init__.py +0 -0
  28. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/__init__.py +0 -0
  29. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  30. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  31. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  32. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/repository/base.py +0 -0
  33. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  34. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/beanie/service/base.py +0 -0
  35. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/controller/__init__.py +0 -0
  36. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  37. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/permissions/base.py +0 -0
  38. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  39. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  40. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  41. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  42. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/service/base.py +0 -0
  43. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
  44. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
  45. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
  46. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
  47. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
  48. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
  49. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
  50. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/cli/__init__.py +0 -0
  51. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/cli/main.py +0 -0
  52. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  53. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/exceptions/domain.py +0 -0
  54. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/schema/__init__.py +0 -0
  55. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/schema/jwt.py +0 -0
  56. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/schema/schema.py +0 -0
  57. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/servicios/__init__.py +0 -0
  58. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  59. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  60. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
  61. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
  62. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
  63. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
  64. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
  65. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
  66. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
  67. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
  68. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
  69. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
  70. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
  71. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
  72. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
  73. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
  74. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/__init__.py +0 -0
  75. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/__init__.py +0 -0
  76. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
  77. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
  78. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
  79. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
  80. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
  81. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
  82. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
  83. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
  84. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
  85. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
  86. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
  87. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
  88. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
  89. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
  90. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
  91. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
  92. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
  93. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/__init__.py +0 -0
  94. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
  95. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
  96. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/__init__.py +0 -0
  97. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
  98. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/__init__.py +0 -0
  99. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
  100. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
  101. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
  102. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/__init__.py +0 -0
  103. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
  104. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
  105. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
  106. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
  107. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
  108. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
  109. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
  110. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
  111. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
  112. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
  113. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
  114. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
  115. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  116. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit.egg-info/entry_points.txt +0 -0
  117. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit.egg-info/requires.txt +0 -0
  118. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/fastapi_basekit.egg-info/top_level.txt +0 -0
  119. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/setup.cfg +0 -0
  120. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_api_exceptions.py +0 -0
  121. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_base_response.py +0 -0
  122. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_crud_beanie_controller.py +0 -0
  123. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_crud_controller.py +0 -0
  124. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_jwt_service.py +0 -0
  125. {fastapi_basekit-0.3.2 → fastapi_basekit-0.4.0}/tests/test_sqlalchemy_base_service_order.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -105,13 +105,25 @@ Abre http://localhost:8000/docs. Login con `admin@example.com` / `ChangeMe2026!`
105
105
 
106
106
  ## Como plugin de Claude Code
107
107
 
108
+ Dentro de Claude Code (terminal) corre:
109
+
108
110
  ```bash
109
- /plugin marketplace add https://github.com/mundobien2025/fastapi-basekit
110
- /plugin install fastapi-basekit
111
- /plugin list
111
+ /plugin marketplace add mundobien2025/fastapi-basekit
112
+ /plugin install fastapi-basekit@fastapi-basekit
113
+ /plugin list # verifica instalación
112
114
  ```
113
115
 
114
- Luego pide: *"Crea el recurso `Invoice` con CRUD completo"* Claude usa la skill automáticamente.
116
+ Eso registra el marketplace `fastapi-basekit` desde el repo de GitHub e
117
+ instala el plugin (que incluye la skill `fastapi-basekit-crud`). Luego
118
+ pide: *"Crea el recurso `Invoice` con CRUD completo siguiendo
119
+ fastapi-basekit"* — Claude detecta la skill y aplica el patrón canónico
120
+ (model → repo → service → schema → controller con `@cbv`).
121
+
122
+ Para actualizar a una versión nueva:
123
+
124
+ ```bash
125
+ /plugin update fastapi-basekit@fastapi-basekit
126
+ ```
115
127
 
116
128
  ## Instalación por ORM
117
129
 
@@ -127,8 +139,6 @@ pip install fastapi-basekit[all] # todo
127
139
 
128
140
  PRs bienvenidos. Setup local en [docs/contributing](https://mundobien2025.github.io/fastapi-basekit/contributing).
129
141
 
130
- Mantenedores: [`RELEASING.md`](./RELEASING.md) tiene release flow, CI/CD, mike y troubleshooting.
131
-
132
142
  ## Licencia
133
143
 
134
144
  [MIT](./LICENSE) — © Jerson Moreno
@@ -59,13 +59,25 @@ Abre http://localhost:8000/docs. Login con `admin@example.com` / `ChangeMe2026!`
59
59
 
60
60
  ## Como plugin de Claude Code
61
61
 
62
+ Dentro de Claude Code (terminal) corre:
63
+
62
64
  ```bash
63
- /plugin marketplace add https://github.com/mundobien2025/fastapi-basekit
64
- /plugin install fastapi-basekit
65
- /plugin list
65
+ /plugin marketplace add mundobien2025/fastapi-basekit
66
+ /plugin install fastapi-basekit@fastapi-basekit
67
+ /plugin list # verifica instalación
66
68
  ```
67
69
 
68
- Luego pide: *"Crea el recurso `Invoice` con CRUD completo"* Claude usa la skill automáticamente.
70
+ Eso registra el marketplace `fastapi-basekit` desde el repo de GitHub e
71
+ instala el plugin (que incluye la skill `fastapi-basekit-crud`). Luego
72
+ pide: *"Crea el recurso `Invoice` con CRUD completo siguiendo
73
+ fastapi-basekit"* — Claude detecta la skill y aplica el patrón canónico
74
+ (model → repo → service → schema → controller con `@cbv`).
75
+
76
+ Para actualizar a una versión nueva:
77
+
78
+ ```bash
79
+ /plugin update fastapi-basekit@fastapi-basekit
80
+ ```
69
81
 
70
82
  ## Instalación por ORM
71
83
 
@@ -81,8 +93,6 @@ pip install fastapi-basekit[all] # todo
81
93
 
82
94
  PRs bienvenidos. Setup local en [docs/contributing](https://mundobien2025.github.io/fastapi-basekit/contributing).
83
95
 
84
- Mantenedores: [`RELEASING.md`](./RELEASING.md) tiene release flow, CI/CD, mike y troubleshooting.
85
-
86
96
  ## Licencia
87
97
 
88
98
  [MIT](./LICENSE) — © Jerson Moreno
@@ -18,6 +18,7 @@ class BeanieBaseController(BaseController):
18
18
 
19
19
  async def list(self):
20
20
  """Lista documentos con paginación usando Beanie."""
21
+ await self.prepare_action("list")
21
22
  params = self._params(skip_frames=2)
22
23
  items, total = await self.service.list(**params)
23
24
  count = params.get("count") or 0
@@ -39,6 +40,7 @@ class BeanieBaseController(BaseController):
39
40
  check_fields: Optional[List[str]] = None,
40
41
  ):
41
42
  """Crea un nuevo documento con validación de campos únicos."""
43
+ await self.prepare_action("create")
42
44
  result = await self.service.create(validated_data, check_fields)
43
45
  return self.format_response(result, message="Creado exitosamente")
44
46
 
@@ -14,6 +14,14 @@ class BaseController:
14
14
 
15
15
  service = Depends()
16
16
  schema_class: ClassVar[Type[BaseModel]]
17
+
18
+ # DRF Style: Permisos globales por defecto
19
+ permission_classes: ClassVar[List[Type[BasePermission]]] = []
20
+
21
+ # ClassVar so cbv (fastapi-restful) does NOT promote it to a query param.
22
+ # A plain `Optional[str]` annotation here leaks a spurious `action` query
23
+ # parameter onto every mounted endpoint. `prepare_action` still assigns
24
+ # `self.action` (instance attr) at runtime, which works fine.
17
25
  action: ClassVar[Optional[str]] = None
18
26
  request: Request
19
27
  _params_excluded_fields: ClassVar[Set[str]] = {
@@ -22,6 +30,7 @@ class BaseController:
22
30
  "count",
23
31
  "search",
24
32
  "order_by",
33
+ "action",
25
34
  "__class__",
26
35
  "args",
27
36
  "kwargs",
@@ -32,34 +41,84 @@ class BaseController:
32
41
  }
33
42
 
34
43
  def __init__(self) -> None:
35
- endpoint_func = (
36
- self.request.scope.get("endpoint")
37
- if hasattr(self, "request") and self.request
38
- else None
39
- )
40
- self.action = endpoint_func.__name__ if endpoint_func else None
44
+ """Inicializa el controller."""
45
+ pass
46
+
47
+ def get_permissions(self) -> List[Type[BasePermission]]:
48
+ """
49
+ Instancia y retorna la lista de permisos que esta vista requiere.
50
+
51
+ Sobrescribir esto permite lógica tipo DRF:
52
+
53
+ if self.action == 'list':
54
+ return [AllowAny]
55
+ return [IsAuthenticated]
56
+ """
57
+ return self.permission_classes
58
+
59
+ async def prepare_action(self, action_name: str) -> None:
60
+ """Set the current action and run permission checks.
61
+
62
+ Auto-called by ``ControllerMeta`` for every public async method on
63
+ the controller. Idempotent within one invocation: if the same
64
+ ``action_name`` has already been prepared on this instance,
65
+ subsequent calls are no-ops. This lets custom methods opt into
66
+ calling ``await self.prepare_action(...)`` explicitly without
67
+ double-firing permission checks (when the metaclass already ran
68
+ before entering the method body).
69
+ """
70
+ if getattr(self, "_basekit_prepared_action", None) == action_name:
71
+ return
72
+ self.action = action_name
73
+ self._basekit_prepared_action = action_name
74
+ # Propagate the canonical CRUD action ("list"/"retrieve"/...) to the
75
+ # service so `get_kwargs_query` / `get_filters` can branch on it. The
76
+ # service's own constructor derives `action` from the endpoint
77
+ # function name (e.g. "list_users"), which is unreliable for that
78
+ # purpose; the controller knows the canonical action.
79
+ service = getattr(self, "service", None)
80
+ if service is not None:
81
+ try:
82
+ service.action = action_name
83
+ except Exception:
84
+ pass
85
+ await self.check_permissions()
86
+
87
+ async def check_permissions(self):
88
+ """Run each declared permission. Raises ``PermissionException``
89
+ on the first denial.
90
+ """
91
+ for permission_class in self.get_permissions():
92
+ permission = permission_class()
93
+ has_perm = await permission.has_permission(self.request)
94
+ if not has_perm:
95
+ message = getattr(
96
+ permission,
97
+ "message_exception",
98
+ "No tienes permiso para realizar esta acción.",
99
+ )
100
+ raise PermissionException(message)
101
+
102
+ async def check_permissions_class(self):
103
+ """Backward-compat alias for ``check_permissions``.
104
+
105
+ Pre-0.3.2 controllers called this manually inside endpoint methods.
106
+ Keep working for users who haven't migrated to ``permission_classes``
107
+ + auto-wrapping yet. New code should declare ``permission_classes``
108
+ on the controller and let the metaclass run permissions.
109
+ """
110
+ await self.check_permissions()
41
111
 
42
112
  def get_schema_class(self) -> Type[BaseModel]:
43
113
  assert self.schema_class is not None, (
44
114
  "'%s' should either include a `schema_class` attribute, "
45
- "or override the `get_serializer_class()` method."
115
+ "or override the `get_schema_class()` method."
46
116
  % self.__class__.__name__
47
117
  )
48
118
  return self.schema_class
49
119
 
50
- async def check_permissions_class(self):
51
- permissions = self.check_permissions()
52
- if permissions:
53
- for permission in permissions:
54
- obj = permission()
55
- check = await obj.has_permission(self.request)
56
- if not check:
57
- raise PermissionException(obj.message_exception)
58
-
59
- def check_permissions(self) -> List[Type[BasePermission]]:
60
- pass
61
-
62
120
  async def list(self):
121
+ await self.prepare_action("list")
63
122
  params = self._params()
64
123
  items, total = await self.service.list(**params)
65
124
  count = params.get("count") or 0
@@ -75,18 +134,22 @@ class BaseController:
75
134
  return self.format_response(data=items, pagination=pagination)
76
135
 
77
136
  async def retrieve(self, id: str):
137
+ await self.prepare_action("retrieve")
78
138
  item = await self.service.retrieve(id)
79
139
  return self.format_response(data=item)
80
140
 
81
141
  async def create(self, validated_data: Any):
142
+ await self.prepare_action("create")
82
143
  result = await self.service.create(validated_data)
83
144
  return self.format_response(result, message="Creado exitosamente")
84
145
 
85
146
  async def update(self, id: str, validated_data: Any):
147
+ await self.prepare_action("update")
86
148
  result = await self.service.update(id, validated_data)
87
149
  return self.format_response(result, message="Actualizado exitosamente")
88
150
 
89
151
  async def delete(self, id: str):
152
+ await self.prepare_action("delete")
90
153
  await self.service.delete(id)
91
154
  return self.format_response(None, message="Eliminado exitosamente")
92
155
 
@@ -99,33 +162,48 @@ class BaseController:
99
162
  ) -> BaseModel:
100
163
  schema = self.get_schema_class()
101
164
 
165
+ # Robust Pydantic v2 validation. Each branch falls back to the
166
+ # raw value when the schema doesn't fit (custom-action endpoints
167
+ # often return ad-hoc dicts that don't match the controller's
168
+ # default schema_class — those should pass through untouched).
102
169
  if isinstance(data, list):
103
170
  data_dicts = [self.to_dict(item) for item in data]
104
- adapter = TypeAdapter(List[schema])
105
- data_parsed = adapter.validate_python(data_dicts)
106
- elif self.service.repository and isinstance(
107
- data, self.service.repository.model
108
- ):
109
- data_parsed = self.to_dict(data)
110
- data_parsed = schema.model_validate(data_parsed)
171
+ try:
172
+ adapter = TypeAdapter(List[schema])
173
+ data_parsed = adapter.validate_python(data_dicts)
174
+ except Exception:
175
+ data_parsed = data_dicts
176
+
111
177
  elif isinstance(data, dict):
112
- data_parsed = schema.model_validate(data)
178
+ try:
179
+ data_parsed = schema.model_validate(data)
180
+ except Exception:
181
+ data_parsed = data
182
+
183
+ elif hasattr(data, "__dict__"):
184
+ data_dict = self.to_dict(data)
185
+ try:
186
+ data_parsed = schema.model_validate(data_dict)
187
+ except Exception:
188
+ data_parsed = data_dict
189
+
190
+ elif data is None:
191
+ data_parsed = None
113
192
  else:
114
193
  data_parsed = data
115
194
 
195
+ response_cls = BasePaginationResponse if pagination else BaseResponse
196
+
197
+ # Construcción dinámica de argumentos
198
+ kwargs = {
199
+ "data": data_parsed,
200
+ "message": message or "Operación exitosa",
201
+ "status": response_status,
202
+ }
116
203
  if pagination:
117
- return BasePaginationResponse(
118
- data=data_parsed,
119
- pagination=pagination,
120
- message=message or "Operación exitosa",
121
- status=response_status,
122
- )
123
- else:
124
- return BaseResponse(
125
- data=data_parsed,
126
- message=message or "Operación exitosa",
127
- status=response_status,
128
- )
204
+ kwargs["pagination"] = pagination
205
+
206
+ return response_cls(**kwargs)
129
207
 
130
208
  def _params(self, skip_frames: int = 1) -> Dict[str, Any]:
131
209
  """
@@ -195,7 +273,10 @@ class BaseController:
195
273
  search = final_value
196
274
  elif param_name == "order_by":
197
275
  order_by = final_value
198
- elif param_name not in standard_params:
276
+ elif (
277
+ param_name not in standard_params
278
+ and param_name not in self._params_excluded_fields
279
+ ):
199
280
  # Es un filtro
200
281
  filters[param_name] = final_value
201
282
 
@@ -208,6 +289,14 @@ class BaseController:
208
289
  }
209
290
 
210
291
  def to_dict(self, obj: Any):
211
- if hasattr(obj, "model_dump"):
292
+ """Helper para convertir modelos ORM/Pydantic a dict."""
293
+ if hasattr(obj, "model_dump"): # Pydantic v2
212
294
  return obj.model_dump()
295
+ if hasattr(obj, "dict"): # Pydantic v1
296
+ return obj.dict()
297
+ if hasattr(obj, "__dict__"): # SQLAlchemy models (basic)
298
+ # Filtramos atributos privados de SQLAlchemy
299
+ return {
300
+ k: v for k, v in obj.__dict__.items() if not k.startswith("_")
301
+ }
213
302
  return obj
@@ -24,6 +24,7 @@ class SQLAlchemyBaseController(BaseController):
24
24
  "use_or",
25
25
  "joins",
26
26
  "order_by",
27
+ "action",
27
28
  "__class__",
28
29
  "args",
29
30
  "kwargs",
@@ -48,6 +49,7 @@ class SQLAlchemyBaseController(BaseController):
48
49
  joins: Lista de relaciones a hacer JOIN eager loading
49
50
  order_by: Expresión de ordenamiento (ej: User.created_at.desc())
50
51
  """
52
+ await self.prepare_action("list")
51
53
  params = self._params(skip_frames=2)
52
54
  service_params = {
53
55
  **params,
@@ -73,6 +75,7 @@ class SQLAlchemyBaseController(BaseController):
73
75
  id: ID del registro
74
76
  joins: Lista de relaciones a hacer JOIN eager loading
75
77
  """
78
+ await self.prepare_action("retrieve")
76
79
  item = await self.service.retrieve(id, joins=joins)
77
80
  return self.format_response(data=item)
78
81
 
@@ -89,6 +92,7 @@ class SQLAlchemyBaseController(BaseController):
89
92
  validated_data: Datos validados para crear
90
93
  check_fields: Campos a verificar por duplicados antes de crear
91
94
  """
95
+ await self.prepare_action("create")
92
96
  result = await self.service.create(validated_data, check_fields)
93
97
  return self.format_response(result, message="Creado exitosamente")
94
98
 
@@ -131,7 +131,13 @@ class BaseRepository:
131
131
  joins_to_apply = {}
132
132
 
133
133
  for filter_path, value in filters.items():
134
- attr, field_joins = self._resolve_field_path(filter_path)
134
+ # Detect an optional trailing operator suffix (e.g.
135
+ # "age__gte", "created_at__lte", "name__ilike", "id__in").
136
+ # The remaining path is resolved normally, so relationship
137
+ # traversal ("user__role__code") keeps working — only a final
138
+ # token that matches a known operator is treated as one.
139
+ resolve_path, op = self._split_operator(filter_path)
140
+ attr, field_joins = self._resolve_field_path(resolve_path)
135
141
 
136
142
  if attr is None:
137
143
  continue
@@ -156,10 +162,53 @@ class BaseRepository:
156
162
  )
157
163
 
158
164
  if not is_relationship and is_column:
159
- resolved_filters[filter_path] = (attr, processed_value)
165
+ resolved_filters[filter_path] = (attr, processed_value, op)
160
166
 
161
167
  return resolved_filters, joins_to_apply
162
168
 
169
+ # Operadores de filtro soportados como sufijo "campo__op".
170
+ FILTER_OPERATORS = frozenset(
171
+ {"eq", "ne", "gt", "gte", "lt", "lte", "in", "like", "ilike"}
172
+ )
173
+
174
+ def _split_operator(self, filter_path: str) -> Tuple[str, str]:
175
+ """Separa un sufijo de operador del path de filtro.
176
+
177
+ "age__gte" → ("age", "gte"); "user__role__code" → (..., "eq").
178
+ Solo se interpreta como operador si el último token coincide con
179
+ ``FILTER_OPERATORS`` y queda al menos un token de campo delante.
180
+ """
181
+ if "__" in filter_path:
182
+ head, _, last = filter_path.rpartition("__")
183
+ if head and last in self.FILTER_OPERATORS:
184
+ return head, last
185
+ return filter_path, "eq"
186
+
187
+ @staticmethod
188
+ def _condition_for(field: Any, value: Any, op: str) -> Any:
189
+ """Construye la condición SQLAlchemy para un operador dado."""
190
+ if op == "ne":
191
+ return field != value
192
+ if op == "gt":
193
+ return field > value
194
+ if op == "gte":
195
+ return field >= value
196
+ if op == "lt":
197
+ return field < value
198
+ if op == "lte":
199
+ return field <= value
200
+ if op == "in":
201
+ seq = value if isinstance(value, (list, tuple, set)) else [value]
202
+ return field.in_(list(seq))
203
+ if op == "like":
204
+ return field.like(f"%{value}%")
205
+ if op == "ilike":
206
+ return field.ilike(f"%{value}%")
207
+ # op == "eq" (default): conserva la semántica IN para listas.
208
+ if isinstance(value, (list, tuple)) and not isinstance(value, str):
209
+ return field.in_(value)
210
+ return field == value
211
+
163
212
  def _resolve_order_by(
164
213
  self,
165
214
  order_by: Optional[Any] = None,
@@ -292,14 +341,13 @@ class BaseRepository:
292
341
 
293
342
  # Modo avanzado: usar resolved_filters si está disponible
294
343
  if resolved_filters is not None:
295
- for _, (field, processed_value) in resolved_filters.items():
296
- # Aplicar la lógica de IN o ==
297
- if isinstance(
298
- processed_value, (list, tuple)
299
- ) and not isinstance(processed_value, str):
300
- conditions.append(field.in_(processed_value))
301
- else:
302
- conditions.append(field == processed_value)
344
+ for _, resolved in resolved_filters.items():
345
+ # Tupla (field, value) legacy o (field, value, op) con operador.
346
+ field, processed_value = resolved[0], resolved[1]
347
+ op = resolved[2] if len(resolved) > 2 else "eq"
348
+ conditions.append(
349
+ self._condition_for(field, processed_value, op)
350
+ )
303
351
  # Modo legacy: compatibilidad con código existente
304
352
  elif filters is not None:
305
353
  for fn, value in filters.items():
@@ -442,8 +490,10 @@ class BaseRepository:
442
490
  if not resolved_filters:
443
491
  return None
444
492
 
445
- # Obtener el atributo resuelto
446
- _, (field, processed_value) = next(iter(resolved_filters.items()))
493
+ # Obtener el atributo resuelto (puede traer operador como 3er elem)
494
+ _, resolved = next(iter(resolved_filters.items()))
495
+ field, processed_value = resolved[0], resolved[1]
496
+ op = resolved[2] if len(resolved) > 2 else "eq"
447
497
 
448
498
  # Construir query con JOINs de filtrado
449
499
  query = select(self.model)
@@ -453,7 +503,7 @@ class BaseRepository:
453
503
  query = query.join(relation_attr)
454
504
 
455
505
  # Aplicar condición
456
- query = query.where(field == processed_value)
506
+ query = query.where(self._condition_for(field, processed_value, op))
457
507
 
458
508
  # Aplicar joins de carga
459
509
  query = self._apply_joins(query, joins)
@@ -49,6 +49,7 @@ class SQLModelBaseController(BaseController):
49
49
  joins: Lista de relaciones para eager loading.
50
50
  order_by: Expresión de ordenamiento (ej: ``"-created_at"``).
51
51
  """
52
+ await self.prepare_action("list")
52
53
  params = self._params(skip_frames=2)
53
54
  service_params = {
54
55
  **params,
@@ -73,6 +74,7 @@ class SQLModelBaseController(BaseController):
73
74
  id: ID del registro.
74
75
  joins: Lista de relaciones para eager loading.
75
76
  """
77
+ await self.prepare_action("retrieve")
76
78
  item = await self.service.retrieve(id, joins=joins)
77
79
  return self.format_response(data=item)
78
80
 
@@ -88,6 +90,7 @@ class SQLModelBaseController(BaseController):
88
90
  validated_data: Datos validados para crear.
89
91
  check_fields: Campos a verificar por duplicados antes de crear.
90
92
  """
93
+ await self.prepare_action("create")
91
94
  result = await self.service.create(validated_data, check_fields)
92
95
  return self.format_response(result, message="Creado exitosamente")
93
96
 
@@ -0,0 +1,4 @@
1
+ from fastapi_basekit.exceptions.domain import DomainError
2
+ from fastapi_basekit.exceptions.handler import register_exception_handlers
3
+
4
+ __all__ = ["DomainError", "register_exception_handlers"]
@@ -109,3 +109,89 @@ async def value_exception_handler(
109
109
  status_code=status.HTTP_400_BAD_REQUEST,
110
110
  content=response.model_dump(mode="json"),
111
111
  )
112
+
113
+
114
+ async def request_validation_handler(
115
+ request: Request, exc: RequestValidationError
116
+ ):
117
+ """FastAPI request validation → envelope unificado (no relanza).
118
+
119
+ Equivalente a `validation_exception_handler` pero devuelve la respuesta
120
+ directamente, sin depender de que otro handler capture la excepción
121
+ relanzada. Cuerpo idéntico al que produciría `ValidationException`.
122
+ """
123
+ response = BaseResponse(
124
+ status="VALIDATION_ERROR",
125
+ message="Error de validación",
126
+ data=exc.errors(),
127
+ )
128
+ return JSONResponse(
129
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
130
+ content=response.model_dump(mode="json"),
131
+ )
132
+
133
+
134
+ async def integrity_error_handler(request: Request, exc: Exception):
135
+ """SQLAlchemy IntegrityError → envelope de integridad (HTTP 400)."""
136
+ detail = getattr(exc, "orig", None)
137
+ response = BaseResponse(
138
+ status="DATABASE_INTEGRITY_ERROR",
139
+ message="Registro ya existe o viola una restricción de integridad",
140
+ data={"detail": str(detail) if detail is not None else str(exc)},
141
+ )
142
+ return JSONResponse(
143
+ status_code=status.HTTP_400_BAD_REQUEST,
144
+ content=response.model_dump(mode="json"),
145
+ )
146
+
147
+
148
+ def register_exception_handlers(
149
+ app,
150
+ *,
151
+ sqlalchemy: bool = True,
152
+ mongo: bool = True,
153
+ ) -> None:
154
+ """Registra el conjunto completo de handlers sobre una app FastAPI.
155
+
156
+ Un solo punto de cableado para que cada proyecto NO copie-pegue el
157
+ mapeo de excepciones → ``BaseResponse``. Todas las subclases de
158
+ ``APIException`` (NotFound, Permission, JWT, Validation, Integrity,
159
+ Global...) las atiende ``api_exception_handler`` vía resolución por MRO.
160
+
161
+ Args:
162
+ app: instancia FastAPI.
163
+ sqlalchemy: registra el handler de ``IntegrityError`` (requiere
164
+ SQLAlchemy instalado).
165
+ mongo: registra handlers de Mongo/Beanie si están disponibles.
166
+ """
167
+ app.add_exception_handler(APIException, api_exception_handler)
168
+ app.add_exception_handler(
169
+ RequestValidationError, request_validation_handler
170
+ )
171
+ app.add_exception_handler(ValidationError, value_exception_handler)
172
+ app.add_exception_handler(ValueError, value_exception_handler)
173
+
174
+ if sqlalchemy:
175
+ try: # pragma: no cover - dependencia opcional
176
+ from sqlalchemy.exc import IntegrityError
177
+
178
+ app.add_exception_handler(
179
+ IntegrityError, integrity_error_handler
180
+ )
181
+ except ImportError:
182
+ pass
183
+
184
+ if mongo:
185
+ # Solo registra si las clases reales (no los fallbacks) existen.
186
+ if DuplicateKeyError.__module__ != __name__:
187
+ app.add_exception_handler(
188
+ DuplicateKeyError, duplicate_key_exception_handler
189
+ )
190
+ if DocumentNotFound.__module__ != __name__:
191
+ app.add_exception_handler(
192
+ DocumentNotFound, document_not_found_handler
193
+ )
194
+
195
+ # Catch-all al final; las subclases de APIException ya tienen prioridad
196
+ # por especificidad de tipo.
197
+ app.add_exception_handler(Exception, global_exception_handler)