hexcore 6.0.2__tar.gz → 6.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 (163) hide show
  1. {hexcore-6.0.2/hexcore.egg-info → hexcore-6.2.0}/PKG-INFO +39 -2
  2. {hexcore-6.0.2 → hexcore-6.2.0}/README.md +38 -1
  3. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/fastapi.py +2 -0
  4. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/app.py +46 -6
  5. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/health.py +77 -14
  6. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/middlewares.py +28 -0
  7. {hexcore-6.0.2 → hexcore-6.2.0/hexcore.egg-info}/PKG-INFO +39 -2
  8. {hexcore-6.0.2 → hexcore-6.2.0}/pyproject.toml +1 -1
  9. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_health.py +111 -0
  10. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_middlewares_and_routing.py +54 -0
  11. {hexcore-6.0.2 → hexcore-6.2.0}/LICENSE +0 -0
  12. {hexcore-6.0.2 → hexcore-6.2.0}/MANIFEST.in +0 -0
  13. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/__init__.py +0 -0
  14. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/__main__.py +0 -0
  15. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/_deprecation.py +0 -0
  16. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/__init__.py +0 -0
  17. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/__init__.py +0 -0
  18. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/adapters.py +0 -0
  19. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/config.py +0 -0
  20. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/factory.py +0 -0
  21. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
  22. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/pipeline.py +0 -0
  23. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/registry.py +0 -0
  24. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/cqrs/scheduler.py +0 -0
  25. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/__init__.py +0 -0
  26. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/base.py +0 -0
  27. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/cursor.py +0 -0
  28. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/errors.py +0 -0
  29. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/dtos/query.py +0 -0
  30. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/use_cases/__init__.py +0 -0
  31. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/use_cases/base.py +0 -0
  32. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/application/use_cases/query.py +0 -0
  33. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/config.py +0 -0
  34. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/cqrs.py +0 -0
  35. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/__init__.py +0 -0
  36. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/auth/__init__.py +0 -0
  37. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/auth/permissions.py +0 -0
  38. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/auth/value_objects.py +0 -0
  39. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/base.py +0 -0
  40. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/__init__.py +0 -0
  41. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/buses.py +0 -0
  42. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/commands.py +0 -0
  43. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/context.py +0 -0
  44. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/cron.py +0 -0
  45. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/decorators.py +0 -0
  46. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  47. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/handlers.py +0 -0
  48. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/middleware.py +0 -0
  49. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/queries.py +0 -0
  50. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/resolution.py +0 -0
  51. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/serializer.py +0 -0
  52. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  53. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/events.py +0 -0
  54. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/exceptions.py +0 -0
  55. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/repositories.py +0 -0
  56. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/services.py +0 -0
  57. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/domain/uow.py +0 -0
  58. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/__init__.py +0 -0
  59. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/__init__.py +0 -0
  60. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/cqrs.py +0 -0
  61. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/exception_handlers.py +0 -0
  62. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/lifespan.py +0 -0
  63. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/rate_limit.py +0 -0
  64. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/routing.py +0 -0
  65. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/streaming.py +0 -0
  66. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/api/utils.py +0 -0
  67. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  68. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  69. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  70. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  71. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cli.py +0 -0
  72. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  73. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/cron_sql.py +0 -0
  74. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  75. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
  76. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
  77. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  78. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  79. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  80. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
  81. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
  82. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/events/__init__.py +0 -0
  83. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  84. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  85. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  86. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/base.py +0 -0
  87. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  88. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  89. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  90. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  91. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  92. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  93. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  94. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  95. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  96. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  97. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
  98. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
  99. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  100. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  101. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  102. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/uow/scopes.py +0 -0
  103. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  104. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/consumer.py +0 -0
  105. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  106. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/infrastructure/workers/runner.py +0 -0
  107. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/py.typed +0 -0
  108. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/sql.py +0 -0
  109. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/__init__.py +0 -0
  110. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/fakes.py +0 -0
  111. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/fixtures.py +0 -0
  112. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/testing/helpers.py +0 -0
  113. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore/types.py +0 -0
  114. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/SOURCES.txt +0 -0
  115. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/dependency_links.txt +0 -0
  116. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/entry_points.txt +0 -0
  117. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/requires.txt +0 -0
  118. {hexcore-6.0.2 → hexcore-6.2.0}/hexcore.egg-info/top_level.txt +0 -0
  119. {hexcore-6.0.2 → hexcore-6.2.0}/scripts/__init__.py +0 -0
  120. {hexcore-6.0.2 → hexcore-6.2.0}/scripts/main.py +0 -0
  121. {hexcore-6.0.2 → hexcore-6.2.0}/setup.cfg +0 -0
  122. {hexcore-6.0.2 → hexcore-6.2.0}/tests/conftest.py +0 -0
  123. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_app_and_lifespan.py +0 -0
  124. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_cqrs_providers.py +0 -0
  125. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_exception_handlers.py +0 -0
  126. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_rate_limit.py +0 -0
  127. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_api_streaming.py +0 -0
  128. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_basic.py +0 -0
  129. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_beanie_query_utils.py +0 -0
  130. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_celery_event_loop.py +0 -0
  131. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_config_loading.py +0 -0
  132. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_consumer_optional_event_bus.py +0 -0
  133. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cqrs.py +0 -0
  134. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cqrs_factory.py +0 -0
  135. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cqrs_middlewares.py +0 -0
  136. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_cron_sql.py +0 -0
  137. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_deprecations.py +0 -0
  138. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_documentation_examples.py +0 -0
  139. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_domain_service_query.py +0 -0
  140. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_dotted_resolution.py +0 -0
  141. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_facades.py +0 -0
  142. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_handler_registry.py +0 -0
  143. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_infrastructure_query_path.py +0 -0
  144. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_lock_error_policy.py +0 -0
  145. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_optional_dependencies.py +0 -0
  146. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_postgres_bus.py +0 -0
  147. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_postgres_lock.py +0 -0
  148. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_query_cursor_and_endpoint.py +0 -0
  149. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_query_field_validation.py +0 -0
  150. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_rabbitmq_bus.py +0 -0
  151. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_redis_bus.py +0 -0
  152. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_redis_lock.py +0 -0
  153. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_repositories_utils.py +0 -0
  154. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_scheduler.py +0 -0
  155. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_scheduler_catchup.py +0 -0
  156. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_smart_routing.py +0 -0
  157. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_sql_session_layer.py +0 -0
  158. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_task_queues_adapters.py +0 -0
  159. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_testing_utilities.py +0 -0
  160. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_uow_session_regression.py +0 -0
  161. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_use_cases_query.py +0 -0
  162. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_worker_bus_integration.py +0 -0
  163. {hexcore-6.0.2 → hexcore-6.2.0}/tests/test_worker_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 6.0.2
3
+ Version: 6.2.0
4
4
  Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
5
5
  Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
6
6
  License-Expression: MIT
@@ -155,7 +155,7 @@ pero están deprecados — ver [Versiones y soporte](#versiones-y-soporte).
155
155
  | Sesión o UoW fuera de un request | `sql.session_scope()`, `sql.uow_scope()` | `sql` |
156
156
  | Request-id correlacionado en los logs | `hx.RequestIDMiddleware`, `hx.install_request_id_logging()` | `api` |
157
157
  | Excepciones de dominio → HTTP | `hx.register_exception_handlers()` | `api` |
158
- | Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()` | `api` |
158
+ | Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()`, `hx.HealthRoutes` | `api` |
159
159
  | Rate limiting | `hx.rate_limit()` | `api` |
160
160
  | SSE / WebSocket / límite de conexiones | `hx.sse_stream()`, `hx.ws_heartbeat()`, `hx.connection_slot()` | `api` |
161
161
  | Composición de routers | `hx.build_root_router()`, `hx.mount_routers()` | `api` |
@@ -308,6 +308,36 @@ register_health_routes(app, probes=[
308
308
  ])
309
309
  ```
310
310
 
311
+ #### Si tu app ya publica su propio `/health`
312
+
313
+ Las dos rutas se registran por separado, así que una app **ya en producción** —con su forma de
314
+ respuesta y un cliente tipado generado desde su OpenAPI— puede quedarse con la readiness, que es
315
+ la parte que no se escribe a mano, sin tocar el contrato que ya publicó:
316
+
317
+ ```python
318
+ register_health_routes(app, liveness=False) # sólo /health/ready
319
+ register_health_routes(app, liveness=False, readiness_path="/_ready")
320
+ ```
321
+
322
+ Y si lo que hay que conservar es la **forma del cuerpo**, `response_factory` la adapta sin
323
+ renunciar a las sondas. El status code lo sigue decidiendo el informe, que es lo que lee el
324
+ orquestador:
325
+
326
+ ```python
327
+ register_health_routes(
328
+ app,
329
+ response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
330
+ )
331
+ ```
332
+
333
+ Lo mismo desde `create_app`, sin apagar la feature entera:
334
+
335
+ ```python
336
+ from hexcore.fastapi import AppFeatures, HealthRoutes, create_app
337
+
338
+ app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
339
+ ```
340
+
311
341
  ### Rate limiting
312
342
 
313
343
  ```python
@@ -331,8 +361,11 @@ rate_limit(10, 60, on_backend_error="deny")
331
361
  ### Request-id correlacionado
332
362
 
333
363
  ```python
364
+ import logging
365
+
334
366
  from hexcore.fastapi import get_request_id, install_request_id_logging
335
367
 
368
+ logging.basicConfig(level=logging.INFO) # primero: configurá el logging
336
369
  install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
337
370
  ```
338
371
 
@@ -341,6 +374,10 @@ la traza) y lo publica en un `ContextVar` y en `request.state`. `install_request
341
374
  lo inyecta en **cada línea de log**, que es la mitad del valor: sin eso, tener el header no
342
375
  correlaciona nada.
343
376
 
377
+ > **El orden importa.** `install_request_id_logging()` instrumenta los handlers que **ya
378
+ > existen**. En un proceso donde nadie configuró el logging todavía no hay ninguno, así que la
379
+ > llamada no tiene nada que hacer — y avisa con un `RuntimeWarning` en vez de quedarse callada.
380
+
344
381
  ### Streaming: SSE, WebSocket y límite de conexiones
345
382
 
346
383
  ```python
@@ -110,7 +110,7 @@ pero están deprecados — ver [Versiones y soporte](#versiones-y-soporte).
110
110
  | Sesión o UoW fuera de un request | `sql.session_scope()`, `sql.uow_scope()` | `sql` |
111
111
  | Request-id correlacionado en los logs | `hx.RequestIDMiddleware`, `hx.install_request_id_logging()` | `api` |
112
112
  | Excepciones de dominio → HTTP | `hx.register_exception_handlers()` | `api` |
113
- | Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()` | `api` |
113
+ | Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()`, `hx.HealthRoutes` | `api` |
114
114
  | Rate limiting | `hx.rate_limit()` | `api` |
115
115
  | SSE / WebSocket / límite de conexiones | `hx.sse_stream()`, `hx.ws_heartbeat()`, `hx.connection_slot()` | `api` |
116
116
  | Composición de routers | `hx.build_root_router()`, `hx.mount_routers()` | `api` |
@@ -263,6 +263,36 @@ register_health_routes(app, probes=[
263
263
  ])
264
264
  ```
265
265
 
266
+ #### Si tu app ya publica su propio `/health`
267
+
268
+ Las dos rutas se registran por separado, así que una app **ya en producción** —con su forma de
269
+ respuesta y un cliente tipado generado desde su OpenAPI— puede quedarse con la readiness, que es
270
+ la parte que no se escribe a mano, sin tocar el contrato que ya publicó:
271
+
272
+ ```python
273
+ register_health_routes(app, liveness=False) # sólo /health/ready
274
+ register_health_routes(app, liveness=False, readiness_path="/_ready")
275
+ ```
276
+
277
+ Y si lo que hay que conservar es la **forma del cuerpo**, `response_factory` la adapta sin
278
+ renunciar a las sondas. El status code lo sigue decidiendo el informe, que es lo que lee el
279
+ orquestador:
280
+
281
+ ```python
282
+ register_health_routes(
283
+ app,
284
+ response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
285
+ )
286
+ ```
287
+
288
+ Lo mismo desde `create_app`, sin apagar la feature entera:
289
+
290
+ ```python
291
+ from hexcore.fastapi import AppFeatures, HealthRoutes, create_app
292
+
293
+ app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
294
+ ```
295
+
266
296
  ### Rate limiting
267
297
 
268
298
  ```python
@@ -286,8 +316,11 @@ rate_limit(10, 60, on_backend_error="deny")
286
316
  ### Request-id correlacionado
287
317
 
288
318
  ```python
319
+ import logging
320
+
289
321
  from hexcore.fastapi import get_request_id, install_request_id_logging
290
322
 
323
+ logging.basicConfig(level=logging.INFO) # primero: configurá el logging
291
324
  install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
292
325
  ```
293
326
 
@@ -296,6 +329,10 @@ la traza) y lo publica en un `ContextVar` y en `request.state`. `install_request
296
329
  lo inyecta en **cada línea de log**, que es la mitad del valor: sin eso, tener el header no
297
330
  correlaciona nada.
298
331
 
332
+ > **El orden importa.** `install_request_id_logging()` instrumenta los handlers que **ya
333
+ > existen**. En un proceso donde nadie configuró el logging todavía no hay ninguno, así que la
334
+ > llamada no tiene nada que hacer — y avisa con un `RuntimeWarning` en vez de quedarse callada.
335
+
299
336
  ### Streaming: SSE, WebSocket y límite de conexiones
300
337
 
301
338
  ```python
@@ -21,6 +21,7 @@ _EXPORTS: dict[str, tuple[str, str]] = {
21
21
  # ── App y lifespan ────────────────────────────────────────────────────────
22
22
  "create_app": ("hexcore.infrastructure.api.app", "create_app"),
23
23
  "AppFeatures": ("hexcore.infrastructure.api.app", "AppFeatures"),
24
+ "HealthRoutes": ("hexcore.infrastructure.api.app", "HealthRoutes"),
24
25
  "build_lifespan": ("hexcore.infrastructure.api.lifespan", "build_lifespan"),
25
26
  "StartupStep": ("hexcore.infrastructure.api.lifespan", "StartupStep"),
26
27
  "CallableStep": ("hexcore.infrastructure.api.lifespan", "CallableStep"),
@@ -98,6 +99,7 @@ _EXPORTS: dict[str, tuple[str, str]] = {
98
99
  "HealthReport": ("hexcore.infrastructure.api.health", "HealthReport"),
99
100
  "DependencyReport": ("hexcore.infrastructure.api.health", "DependencyReport"),
100
101
  "Probe": ("hexcore.infrastructure.api.health", "Probe"),
102
+ "ResponseFactory": ("hexcore.infrastructure.api.health", "ResponseFactory"),
101
103
  }
102
104
 
103
105
  __all__ = sorted(_EXPORTS)
@@ -15,11 +15,29 @@ from fastapi.middleware.cors import CORSMiddleware
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from .exception_handlers import register_exception_handlers
18
- from .health import Probe, register_health_routes
18
+ from .health import Probe, ResponseFactory, register_health_routes
19
19
  from .middlewares import RequestIDMiddleware, TimingMiddleware
20
20
  from .routing import MountableRouter, mount_routers
21
21
 
22
- __all__ = ["AppFeatures", "create_app"]
22
+ __all__ = ["AppFeatures", "HealthRoutes", "create_app"]
23
+
24
+
25
+ class HealthRoutes(BaseModel):
26
+ """
27
+ Qué rutas de health cablea `create_app()`, y con qué forma.
28
+
29
+ Existe para que una app **ya publicada** pueda adoptar la readiness sin renunciar a
30
+ su `/health` ni al cliente tipado generado desde su OpenAPI. Los argumentos son los
31
+ de `register_health_routes`, agrupados para no engordar la firma de `create_app`.
32
+ """
33
+
34
+ model_config = {"arbitrary_types_allowed": True}
35
+
36
+ path: str = "/health"
37
+ liveness: bool = True
38
+ readiness: bool = True
39
+ readiness_path: str | None = None
40
+ response_factory: ResponseFactory | None = None
23
41
 
24
42
 
25
43
  class AppFeatures(BaseModel):
@@ -41,8 +59,16 @@ class AppFeatures(BaseModel):
41
59
  exception_handlers: bool = True
42
60
  """Mapeo de excepciones de dominio a HTTP (F5)."""
43
61
 
44
- health: bool = True
45
- """Rutas `/health` y `/health/ready` (F16)."""
62
+ health: bool | HealthRoutes = True
63
+ """
64
+ Rutas `/health` y `/health/ready` (F16).
65
+
66
+ Acepta también un `HealthRoutes` para adoptarlas **por partes**: una app que ya
67
+ publica su propio `/health` puede quedarse con la readiness, que es la que no se
68
+ escribe a mano, sin apagar la feature entera::
69
+
70
+ AppFeatures(health=HealthRoutes(liveness=False))
71
+ """
46
72
 
47
73
 
48
74
  def create_app(
@@ -65,7 +91,8 @@ def create_app(
65
91
  features: Qué cablear. Ver `AppFeatures`.
66
92
  routers: Routers a montar. Acepta `APIRouter` o `(APIRouter, kwargs)` (F13).
67
93
  health_probes: Sondas para `/health/ready`. Por defecto, las deducidas de la
68
- configuración (F16).
94
+ configuración (F16). Qué rutas se registran y con qué forma se controla con
95
+ `AppFeatures(health=HealthRoutes(...))`.
69
96
  exception_mapping: Excepciones extra a mapear, fusionadas con el default (F5).
70
97
  **fastapi_kwargs: Se pasan tal cual a `FastAPI` (`title`, `version`,
71
98
  `docs_url`, `openapi_tags`…). Lo que pases gana sobre los defaults derivados
@@ -99,7 +126,20 @@ def create_app(
99
126
  register_exception_handlers(app, mapping=exception_mapping)
100
127
 
101
128
  if resolved_features.health:
102
- register_health_routes(app, probes=health_probes)
129
+ health_routes = (
130
+ resolved_features.health
131
+ if isinstance(resolved_features.health, HealthRoutes)
132
+ else HealthRoutes()
133
+ )
134
+ register_health_routes(
135
+ app,
136
+ probes=health_probes,
137
+ path=health_routes.path,
138
+ liveness=health_routes.liveness,
139
+ readiness=health_routes.readiness,
140
+ readiness_path=health_routes.readiness_path,
141
+ response_factory=health_routes.response_factory,
142
+ )
103
143
 
104
144
  if routers:
105
145
  mount_routers(app, list(routers))
@@ -26,6 +26,7 @@ __all__ = [
26
26
  "DependencyReport",
27
27
  "HealthReport",
28
28
  "Probe",
29
+ "ResponseFactory",
29
30
  "check_health",
30
31
  "register_health_routes",
31
32
  "default_probes",
@@ -56,6 +57,11 @@ class HealthReport(BaseModel):
56
57
  return 503 if self.status == "down" else 200
57
58
 
58
59
 
60
+ #: Adapta el cuerpo de las rutas de health a la forma que ya publica una app. Recibe el
61
+ #: informe y devuelve lo que haya que serializar.
62
+ ResponseFactory = t.Callable[[HealthReport], t.Any]
63
+
64
+
59
65
  class Probe(t.NamedTuple):
60
66
  """Una sonda con nombre y timeout propio."""
61
67
 
@@ -231,6 +237,10 @@ def register_health_routes(
231
237
  *,
232
238
  path: str = "/health",
233
239
  probes: t.Sequence[Probe] | None = None,
240
+ liveness: bool = True,
241
+ readiness: bool = True,
242
+ readiness_path: str | None = None,
243
+ response_factory: ResponseFactory | None = None,
234
244
  ) -> None:
235
245
  """
236
246
  Registra `GET {path}` (liveness) y `GET {path}/ready` (readiness).
@@ -238,18 +248,71 @@ def register_health_routes(
238
248
  - `{path}` responde 200 sin I/O. Apuntá aquí el liveness probe.
239
249
  - `{path}/ready` sondea las dependencias y responde **503** si algo crítico falla,
240
250
  con el detalle y la latencia por dependencia. Apuntá aquí el readiness probe.
251
+
252
+ Las dos rutas se registran por separado a propósito. Una app que **ya publica**
253
+ `/health` con su propia forma —y con un cliente tipado generado desde su OpenAPI— no
254
+ puede aceptar la forma de `HealthReport` sin romper el contrato, pero sí quiere la
255
+ readiness, que es la parte que no se puede escribir a mano en cinco minutos::
256
+
257
+ register_health_routes(app, liveness=False) # sólo /health/ready
258
+
259
+ Y si lo que hace falta es conservar la forma del cuerpo, `response_factory` la
260
+ adapta sin renunciar a las sondas::
261
+
262
+ register_health_routes(
263
+ app,
264
+ response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
265
+ )
266
+
267
+ Args:
268
+ app: La app donde registrar.
269
+ path: Ruta del liveness. También la base del readiness, salvo que se dé
270
+ `readiness_path`.
271
+ probes: Sondas del readiness. Por defecto, las de `default_probes()`.
272
+ liveness: Registrar `GET {path}`. Poné `False` si ya publicás el tuyo.
273
+ readiness: Registrar el readiness.
274
+ readiness_path: Ruta del readiness. Por defecto ``f"{path}/ready"``.
275
+ response_factory: Si se da, se le pasa el `HealthReport` y lo que devuelva es el
276
+ cuerpo de la respuesta. El **status code** lo sigue decidiendo el informe
277
+ (503 si algo crítico está caído), salvo que devuelvas una `Response` propia,
278
+ en cuyo caso el status es cosa tuya.
279
+
280
+ Raises:
281
+ ValueError: Si se desactivan las dos rutas. Una llamada que no registra nada es
282
+ un error de configuración, y descubrirlo por silencio cuesta un incidente.
241
283
  """
242
- @app.get(path, tags=["health"], summary="Liveness: el proceso responde")
243
- async def health() -> HealthReport:
244
- return await check_health(deep=False)
245
-
246
- @app.get(
247
- f"{path}/ready",
248
- tags=["health"],
249
- summary="Readiness: las dependencias responden",
250
- responses={503: {"description": "Alguna dependencia crítica no responde"}},
251
- )
252
- async def health_ready(response: Response) -> HealthReport:
253
- report = await check_health(deep=True, probes=probes)
254
- response.status_code = report.http_status
255
- return report
284
+ if not liveness and not readiness:
285
+ raise ValueError(
286
+ "register_health_routes(liveness=False, readiness=False) no registra nada. "
287
+ "Si no querés ninguna de las dos rutas, no llames a la función."
288
+ )
289
+
290
+ # Sin factory se declara `HealthReport` para que el OpenAPI documente la forma; con
291
+ # factory el cuerpo es lo que devuelva el usuario y no hay modelo que prometer.
292
+ response_model = None if response_factory is not None else HealthReport
293
+
294
+ def render(report: HealthReport) -> t.Any:
295
+ return report if response_factory is None else response_factory(report)
296
+
297
+ if liveness:
298
+ @app.get(
299
+ path,
300
+ tags=["health"],
301
+ summary="Liveness: el proceso responde",
302
+ response_model=response_model,
303
+ )
304
+ async def health() -> t.Any:
305
+ return render(await check_health(deep=False))
306
+
307
+ if readiness:
308
+ @app.get(
309
+ readiness_path or f"{path}/ready",
310
+ tags=["health"],
311
+ summary="Readiness: las dependencias responden",
312
+ response_model=response_model,
313
+ responses={503: {"description": "Alguna dependencia crítica no responde"}},
314
+ )
315
+ async def health_ready(response: Response) -> t.Any:
316
+ report = await check_health(deep=True, probes=probes)
317
+ response.status_code = report.http_status
318
+ return render(report)
@@ -12,6 +12,7 @@ import logging
12
12
  import time
13
13
  import typing as t
14
14
  import uuid
15
+ import warnings
15
16
  from contextvars import ContextVar
16
17
 
17
18
  from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
@@ -123,11 +124,25 @@ def install_request_id_logging(
123
124
  """
124
125
  Instala el filtro de request-id en los handlers de un logger.
125
126
 
127
+ **Configurá el logging primero.** Esta función instrumenta los handlers que
128
+ **ya existen**; si no hay ninguno, no hay nada que instrumentar y no hace nada::
129
+
130
+ logging.basicConfig(level=logging.INFO) # primero
131
+ install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
132
+
133
+ Al revés no falla, pero deja el logging sin configurar y el request-id sin
134
+ aparecer. Por eso el caso "sin handlers" emite un `RuntimeWarning` en vez de
135
+ devolver en silencio: el síntoma sería "no veo el request-id en los logs", que no
136
+ apunta a ninguna causa.
137
+
126
138
  Args:
127
139
  logger: El logger a instrumentar. Por defecto, el root logger.
128
140
  fmt: Si se da, se aplica como formato a los handlers del logger. Usá
129
141
  ``%(request_id)s`` en él. Si es None, no se toca el formato: el filtro
130
142
  deja el atributo disponible para el formato que ya tengas.
143
+
144
+ Warns:
145
+ RuntimeWarning: Si el logger de destino no tiene ningún handler.
131
146
  """
132
147
  target = logger or logging.getLogger()
133
148
  log_filter = RequestIDLogFilter()
@@ -135,6 +150,19 @@ def install_request_id_logging(
135
150
  # El filtro va en los handlers, no en el logger: un filtro de logger no se aplica
136
151
  # a los registros que suben por propagación desde loggers hijos.
137
152
  handlers = target.handlers or logging.getLogger().handlers
153
+ if not handlers:
154
+ # Un no-op silencioso en una utilidad de *observabilidad* es especialmente malo:
155
+ # el usuario descubre que no funcionó cuando necesita correlacionar un incidente.
156
+ warnings.warn(
157
+ f"install_request_id_logging: el logger {target.name!r} no tiene handlers, "
158
+ "así que no hay nada que instrumentar y esta llamada no hizo nada. "
159
+ "Configurá el logging primero (p. ej. logging.basicConfig(...)) y llamá "
160
+ "después.",
161
+ RuntimeWarning,
162
+ stacklevel=2,
163
+ )
164
+ return
165
+
138
166
  for handler in handlers:
139
167
  if not any(isinstance(f, RequestIDLogFilter) for f in handler.filters):
140
168
  handler.addFilter(log_filter)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 6.0.2
3
+ Version: 6.2.0
4
4
  Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
5
5
  Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
6
6
  License-Expression: MIT
@@ -155,7 +155,7 @@ pero están deprecados — ver [Versiones y soporte](#versiones-y-soporte).
155
155
  | Sesión o UoW fuera de un request | `sql.session_scope()`, `sql.uow_scope()` | `sql` |
156
156
  | Request-id correlacionado en los logs | `hx.RequestIDMiddleware`, `hx.install_request_id_logging()` | `api` |
157
157
  | Excepciones de dominio → HTTP | `hx.register_exception_handlers()` | `api` |
158
- | Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()` | `api` |
158
+ | Health checks que sondean de verdad | `hx.register_health_routes()`, `hx.check_health()`, `hx.HealthRoutes` | `api` |
159
159
  | Rate limiting | `hx.rate_limit()` | `api` |
160
160
  | SSE / WebSocket / límite de conexiones | `hx.sse_stream()`, `hx.ws_heartbeat()`, `hx.connection_slot()` | `api` |
161
161
  | Composición de routers | `hx.build_root_router()`, `hx.mount_routers()` | `api` |
@@ -308,6 +308,36 @@ register_health_routes(app, probes=[
308
308
  ])
309
309
  ```
310
310
 
311
+ #### Si tu app ya publica su propio `/health`
312
+
313
+ Las dos rutas se registran por separado, así que una app **ya en producción** —con su forma de
314
+ respuesta y un cliente tipado generado desde su OpenAPI— puede quedarse con la readiness, que es
315
+ la parte que no se escribe a mano, sin tocar el contrato que ya publicó:
316
+
317
+ ```python
318
+ register_health_routes(app, liveness=False) # sólo /health/ready
319
+ register_health_routes(app, liveness=False, readiness_path="/_ready")
320
+ ```
321
+
322
+ Y si lo que hay que conservar es la **forma del cuerpo**, `response_factory` la adapta sin
323
+ renunciar a las sondas. El status code lo sigue decidiendo el informe, que es lo que lee el
324
+ orquestador:
325
+
326
+ ```python
327
+ register_health_routes(
328
+ app,
329
+ response_factory=lambda r: {"ok": r.status != "down", "checks": r.dependencies},
330
+ )
331
+ ```
332
+
333
+ Lo mismo desde `create_app`, sin apagar la feature entera:
334
+
335
+ ```python
336
+ from hexcore.fastapi import AppFeatures, HealthRoutes, create_app
337
+
338
+ app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
339
+ ```
340
+
311
341
  ### Rate limiting
312
342
 
313
343
  ```python
@@ -331,8 +361,11 @@ rate_limit(10, 60, on_backend_error="deny")
331
361
  ### Request-id correlacionado
332
362
 
333
363
  ```python
364
+ import logging
365
+
334
366
  from hexcore.fastapi import get_request_id, install_request_id_logging
335
367
 
368
+ logging.basicConfig(level=logging.INFO) # primero: configurá el logging
336
369
  install_request_id_logging(fmt="%(asctime)s [%(request_id)s] %(message)s")
337
370
  ```
338
371
 
@@ -341,6 +374,10 @@ la traza) y lo publica en un `ContextVar` y en `request.state`. `install_request
341
374
  lo inyecta en **cada línea de log**, que es la mitad del valor: sin eso, tener el header no
342
375
  correlaciona nada.
343
376
 
377
+ > **El orden importa.** `install_request_id_logging()` instrumenta los handlers que **ya
378
+ > existen**. En un proceso donde nadie configuró el logging todavía no hay ninguno, así que la
379
+ > llamada no tiene nada que hacer — y avisa con un `RuntimeWarning` en vez de quedarse callada.
380
+
344
381
  ### Streaming: SSE, WebSocket y límite de conexiones
345
382
 
346
383
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexcore"
3
- version = "6.0.2"
3
+ version = "6.2.0"
4
4
  authors = [
5
5
  { name="David Latosefki (Indroic)", email="indroic@outlook.com" },
6
6
  ]
@@ -218,3 +218,114 @@ def test_health_report_is_serializable():
218
218
  report = HealthReport(status="ok")
219
219
 
220
220
  assert report.model_dump()["status"] == "ok"
221
+
222
+
223
+ # ── R5: adopción por partes en una app que ya publica su /health ───────────────
224
+
225
+
226
+ def test_readiness_can_be_registered_without_touching_the_published_liveness():
227
+ """
228
+ El caso que dejó F16 sin adoptar: la app ya publica `/health` con su propia forma y
229
+ un cliente tipado generado desde el OpenAPI. Antes había que apagar la feature
230
+ entera, y con ella se perdía la readiness, que es la parte valiosa.
231
+ """
232
+ app = FastAPI()
233
+
234
+ @app.get("/health")
235
+ async def mi_health() -> dict[str, str]:
236
+ return {"estado": "vivo"} # el contrato ya publicado, intacto
237
+
238
+ register_health_routes(app, liveness=False, probes=[Probe("sql", _boom)])
239
+
240
+ with TestClient(app) as client:
241
+ assert client.get("/health").json() == {"estado": "vivo"}
242
+ assert client.get("/health/ready").status_code == 503
243
+
244
+
245
+ def test_liveness_can_be_registered_alone():
246
+ app = FastAPI()
247
+ register_health_routes(app, readiness=False, probes=[Probe("sql", _boom)])
248
+
249
+ with TestClient(app) as client:
250
+ assert client.get("/health").status_code == 200
251
+ assert client.get("/health/ready").status_code == 404
252
+
253
+
254
+ def test_disabling_both_routes_is_an_error_not_a_silent_noop():
255
+ app = FastAPI()
256
+
257
+ with pytest.raises(ValueError, match="no registra nada"):
258
+ register_health_routes(app, liveness=False, readiness=False)
259
+
260
+
261
+ def test_readiness_path_can_be_set_independently():
262
+ app = FastAPI()
263
+ register_health_routes(
264
+ app, liveness=False, readiness_path="/_ready", probes=[Probe("sql", _ok)]
265
+ )
266
+
267
+ with TestClient(app) as client:
268
+ assert client.get("/_ready").status_code == 200
269
+ assert client.get("/health/ready").status_code == 404
270
+
271
+
272
+ def test_response_factory_adapts_the_body_but_not_the_status():
273
+ """
274
+ La otra mitad de la adopción: conservar la forma del cuerpo sin renunciar a que un
275
+ 503 siga siendo un 503, que es lo que lee el orquestador.
276
+ """
277
+ app = FastAPI()
278
+ register_health_routes(
279
+ app,
280
+ probes=[Probe("sql", _boom)],
281
+ response_factory=lambda report: {
282
+ "ok": report.status != "down",
283
+ "checks": [dep.name for dep in report.dependencies],
284
+ },
285
+ )
286
+
287
+ with TestClient(app) as client:
288
+ live = client.get("/health")
289
+ ready = client.get("/health/ready")
290
+
291
+ assert live.status_code == 200
292
+ assert live.json() == {"ok": True, "checks": []}
293
+
294
+ assert ready.status_code == 503
295
+ assert ready.json() == {"ok": False, "checks": ["sql"]}
296
+
297
+
298
+ def test_response_factory_body_is_not_forced_into_the_health_report_schema():
299
+ """
300
+ Con `response_model=HealthReport`, FastAPI filtraría el cuerpo del factory a los
301
+ campos del informe y devolvería `{}`: adaptarlo sería inútil.
302
+ """
303
+ app = FastAPI()
304
+ register_health_routes(app, response_factory=lambda report: {"forma": "propia"})
305
+
306
+ with TestClient(app) as client:
307
+ assert client.get("/health").json() == {"forma": "propia"}
308
+
309
+
310
+ def test_create_app_can_register_only_the_readiness():
311
+ from hexcore.infrastructure.api.app import AppFeatures, HealthRoutes, create_app
312
+
313
+ app = create_app(features=AppFeatures(health=HealthRoutes(liveness=False)))
314
+
315
+ @app.get("/health")
316
+ async def mi_health() -> dict[str, str]:
317
+ return {"estado": "vivo"}
318
+
319
+ with TestClient(app) as client:
320
+ assert client.get("/health").json() == {"estado": "vivo"}
321
+ assert client.get("/health/ready").status_code in (200, 503)
322
+
323
+
324
+ def test_create_app_health_true_still_registers_both():
325
+ from hexcore.infrastructure.api.app import create_app
326
+
327
+ app = create_app()
328
+
329
+ paths = {route.path for route in app.routes if hasattr(route, "path")}
330
+
331
+ assert {"/health", "/health/ready"} <= paths