svc-infra 0.1.594__tar.gz → 0.1.596__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (259) hide show
  1. {svc_infra-0.1.594 → svc_infra-0.1.596}/PKG-INFO +1 -1
  2. {svc_infra-0.1.594 → svc_infra-0.1.596}/pyproject.toml +2 -1
  3. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/README.md +26 -0
  4. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/apf_payments/router.py +76 -5
  5. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/routers/oauth_router.py +3 -3
  6. svc_infra-0.1.596/src/svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  7. svc_infra-0.1.596/src/svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  8. svc_infra-0.1.596/src/svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  9. svc_infra-0.1.596/src/svc_infra/api/fastapi/middleware/ratelimit_store.py +78 -0
  10. svc_infra-0.1.596/src/svc_infra/db/inbox.py +55 -0
  11. svc_infra-0.1.596/src/svc_infra/db/outbox.py +96 -0
  12. svc_infra-0.1.596/src/svc_infra/db/sql/versioning.py +14 -0
  13. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/session.py +11 -2
  14. svc_infra-0.1.594/src/svc_infra/api/fastapi/middleware/idempotency.py +0 -86
  15. svc_infra-0.1.594/src/svc_infra/api/fastapi/middleware/ratelimit_store.py +0 -30
  16. {svc_infra-0.1.594 → svc_infra-0.1.596}/README.md +0 -0
  17. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/__init__.py +0 -0
  18. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/__init__.py +0 -0
  19. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/alembic.py +0 -0
  20. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/models.py +0 -0
  21. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/provider/__init__.py +0 -0
  22. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/provider/aiydan.py +0 -0
  23. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/provider/base.py +0 -0
  24. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/provider/registry.py +0 -0
  25. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/provider/stripe.py +0 -0
  26. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/schemas.py +0 -0
  27. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/service.py +0 -0
  28. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/apf_payments/settings.py +0 -0
  29. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/__init__.py +0 -0
  30. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/__init__.py +0 -0
  31. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  32. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/apf_payments/setup.py +0 -0
  33. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/__init__.py +0 -0
  34. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/_cookies.py +0 -0
  35. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/add.py +0 -0
  36. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/gaurd.py +0 -0
  37. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/__init__.py +0 -0
  38. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/models.py +0 -0
  39. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/pre_auth.py +0 -0
  40. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/router.py +0 -0
  41. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/security.py +0 -0
  42. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/utils.py +0 -0
  43. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/mfa/verify.py +0 -0
  44. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/policy.py +0 -0
  45. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/providers.py +0 -0
  46. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/routers/__init__.py +0 -0
  47. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/routers/account.py +0 -0
  48. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/routers/apikey_router.py +0 -0
  49. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/routers/session_router.py +0 -0
  50. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/security.py +0 -0
  51. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/sender.py +0 -0
  52. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/settings.py +0 -0
  53. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/auth/state.py +0 -0
  54. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/cache/__init__.py +0 -0
  55. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/cache/add.py +0 -0
  56. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/__init__.py +0 -0
  57. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/http.py +0 -0
  58. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/nosql/__init__.py +0 -0
  59. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/nosql/mongo/__init__.py +0 -0
  60. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/nosql/mongo/add.py +0 -0
  61. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +0 -0
  62. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/nosql/mongo/health.py +0 -0
  63. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/README.md +0 -0
  64. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/__init__.py +0 -0
  65. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/add.py +0 -0
  66. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/crud_router.py +0 -0
  67. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/health.py +0 -0
  68. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/session.py +0 -0
  69. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/db/sql/users.py +0 -0
  70. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dependencies/ratelimit.py +0 -0
  71. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/docs/__init__.py +0 -0
  72. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/docs/landing.py +0 -0
  73. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/docs/scoped.py +0 -0
  74. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dual/__init__.py +0 -0
  75. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dual/dualize.py +0 -0
  76. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dual/protected.py +0 -0
  77. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dual/public.py +0 -0
  78. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dual/router.py +0 -0
  79. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dual/utils.py +0 -0
  80. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/dx.py +0 -0
  81. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/ease.py +0 -0
  82. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/http/__init__.py +0 -0
  83. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/http/concurrency.py +0 -0
  84. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/http/conditional.py +0 -0
  85. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/http/deprecation.py +0 -0
  86. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/__init__.py +0 -0
  87. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/debug.py +0 -0
  88. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/errors/__init__.py +0 -0
  89. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/errors/catchall.py +0 -0
  90. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/errors/exceptions.py +0 -0
  91. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/errors/handlers.py +0 -0
  92. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/ratelimit.py +0 -0
  93. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/request_id.py +0 -0
  94. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/middleware/request_size_limit.py +0 -0
  95. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/__init__.py +0 -0
  96. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/apply.py +0 -0
  97. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/conventions.py +0 -0
  98. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/models.py +0 -0
  99. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/mutators.py +0 -0
  100. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/pipeline.py +0 -0
  101. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/responses.py +0 -0
  102. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/openapi/security.py +0 -0
  103. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/pagination.py +0 -0
  104. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/paths/__init__.py +0 -0
  105. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/paths/auth.py +0 -0
  106. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/paths/generic.py +0 -0
  107. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/paths/prefix.py +0 -0
  108. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/paths/user.py +0 -0
  109. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/routers/__init__.py +0 -0
  110. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/routers/ping.py +0 -0
  111. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/api/fastapi/setup.py +0 -0
  112. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/README.md +0 -0
  113. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/__init__.py +0 -0
  114. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/env.py +0 -0
  115. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/logging/__init__.py +0 -0
  116. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/logging/add.py +0 -0
  117. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/logging/filter.py +0 -0
  118. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/logging/formats.py +0 -0
  119. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/app/root.py +0 -0
  120. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/README.md +0 -0
  121. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/__init__.py +0 -0
  122. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/backend.py +0 -0
  123. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/decorators.py +0 -0
  124. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/demo.py +0 -0
  125. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/keys.py +0 -0
  126. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/recache.py +0 -0
  127. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/resources.py +0 -0
  128. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/tags.py +0 -0
  129. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/ttl.py +0 -0
  130. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cache/utils.py +0 -0
  131. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/__init__.py +0 -0
  132. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/__init__.py +0 -0
  133. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/__init__.py +0 -0
  134. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/nosql/__init__.py +0 -0
  135. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/nosql/mongo/README.md +0 -0
  136. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/nosql/mongo/__init__.py +0 -0
  137. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +0 -0
  138. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +0 -0
  139. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/sql/__init__.py +0 -0
  140. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/sql/alembic_cmds.py +0 -0
  141. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +0 -0
  142. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/help.py +0 -0
  143. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/obs/__init__.py +0 -0
  144. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/cmds/obs/obs_cmds.py +0 -0
  145. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/foundation/__init__.py +0 -0
  146. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/foundation/runner.py +0 -0
  147. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/cli/foundation/typer_bootstrap.py +0 -0
  148. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/__init__.py +0 -0
  149. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/crud_schema.py +0 -0
  150. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/__init__.py +0 -0
  151. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/base.py +0 -0
  152. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/constants.py +0 -0
  153. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/core.py +0 -0
  154. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/indexes.py +0 -0
  155. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/management.py +0 -0
  156. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/README.md +0 -0
  157. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/__init__.py +0 -0
  158. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/client.py +0 -0
  159. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/settings.py +0 -0
  160. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/templates/__init__.py +0 -0
  161. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/templates/documents.py.tmpl +0 -0
  162. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/templates/resources.py.tmpl +0 -0
  163. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/mongo/templates/schemas.py.tmpl +0 -0
  164. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/repository.py +0 -0
  165. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/resource.py +0 -0
  166. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/scaffold.py +0 -0
  167. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/service.py +0 -0
  168. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/service_with_hooks.py +0 -0
  169. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/types.py +0 -0
  170. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/nosql/utils.py +0 -0
  171. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/README.md +0 -0
  172. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/__init__.py +0 -0
  173. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/apikey.py +0 -0
  174. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/authref.py +0 -0
  175. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/base.py +0 -0
  176. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/constants.py +0 -0
  177. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/core.py +0 -0
  178. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/management.py +0 -0
  179. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/repository.py +0 -0
  180. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/resource.py +0 -0
  181. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/scaffold.py +0 -0
  182. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/service.py +0 -0
  183. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/service_with_hooks.py +0 -0
  184. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/__init__.py +0 -0
  185. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/models_schemas/__init__.py +0 -0
  186. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +0 -0
  187. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +0 -0
  188. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/models_schemas/entity/models.py.tmpl +0 -0
  189. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/models_schemas/entity/schemas.py.tmpl +0 -0
  190. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/setup/__init__.py +0 -0
  191. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/setup/alembic.ini.tmpl +0 -0
  192. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/setup/env_async.py.tmpl +0 -0
  193. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/setup/env_sync.py.tmpl +0 -0
  194. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/templates/setup/script.py.mako.tmpl +0 -0
  195. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/types.py +0 -0
  196. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/uniq.py +0 -0
  197. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/uniq_hooks.py +0 -0
  198. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/sql/utils.py +0 -0
  199. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/db/utils.py +0 -0
  200. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/mcp/__init__.py +0 -0
  201. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/mcp/svc_infra_mcp.py +0 -0
  202. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/README.md +0 -0
  203. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/__init__.py +0 -0
  204. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/add.py +0 -0
  205. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/cloud_dash.py +0 -0
  206. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/metrics/__init__.py +0 -0
  207. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/metrics/asgi.py +0 -0
  208. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/metrics/base.py +0 -0
  209. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/metrics/http.py +0 -0
  210. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/metrics/sqlalchemy.py +0 -0
  211. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/metrics.py +0 -0
  212. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/__init__.py +0 -0
  213. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/compose_cloud/__init__.py +0 -0
  214. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/compose_cloud/templates/__init__.py +0 -0
  215. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/compose_cloud/templates/agent.yaml.tmpl +0 -0
  216. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/compose_cloud/templates/docker-compose.cloud.yml.tmpl +0 -0
  217. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/__init__.py +0 -0
  218. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/dashboards/00_overview.json +0 -0
  219. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/dashboards/10_http.json +0 -0
  220. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/dashboards/20_db.json +0 -0
  221. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/dashboards/30_runtime.json +0 -0
  222. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/dashboards/40_clients.json +0 -0
  223. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/dashboards/__init__.py +0 -0
  224. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/templates/__init__.py +0 -0
  225. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/templates/docker-compose.yml.tmpl +0 -0
  226. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/templates/prometheus.yml.tmpl +0 -0
  227. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/templates/provisioning/__init__.py +0 -0
  228. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/templates/provisioning/dashboards.yml +0 -0
  229. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/providers/grafana/templates/provisioning/datasource.yml +0 -0
  230. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/settings.py +0 -0
  231. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/grafana_dashboard.json +0 -0
  232. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/prometheus_rules.yml +0 -0
  233. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/__init__.py +0 -0
  234. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/compose/__init__.py +0 -0
  235. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/compose/agent.yaml +0 -0
  236. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/compose/docker-compose.yml +0 -0
  237. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/fly/__init__.py +0 -0
  238. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/fly/agent.yaml +0 -0
  239. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/fly/fly.toml.fragment +0 -0
  240. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/k8s/__init__.py +0 -0
  241. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/k8s/configmap.yaml +0 -0
  242. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/k8s/deployment.yaml +0 -0
  243. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/railway/Dockerfile +0 -0
  244. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/railway/README.md +0 -0
  245. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/railway/__init__.py +0 -0
  246. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/obs/templates/sidecars/railway/agent.yaml +0 -0
  247. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/py.typed +0 -0
  248. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/audit.py +0 -0
  249. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/audit_service.py +0 -0
  250. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/headers.py +0 -0
  251. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/hibp.py +0 -0
  252. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/jwt_rotation.py +0 -0
  253. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/lockout.py +0 -0
  254. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/models.py +0 -0
  255. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/org_invites.py +0 -0
  256. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/passwords.py +0 -0
  257. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/permissions.py +0 -0
  258. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/security/signed_cookies.py +0 -0
  259. {svc_infra-0.1.594 → svc_infra-0.1.596}/src/svc_infra/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.594
3
+ Version: 0.1.596
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "svc-infra"
3
- version = "0.1.594"
3
+ version = "0.1.596"
4
4
  description = "Infrastructure for building and deploying prod-ready services"
5
5
  authors = ["Ali Khatami <aliikhatami94@gmail.com>"]
6
6
  license = "MIT"
@@ -122,6 +122,7 @@ python_functions = ["test_*"]
122
122
  markers = [
123
123
  "security: Security and auth hardening tests",
124
124
  "ratelimit: Rate limiting and abuse protection tests",
125
+ "concurrency: Idempotency and concurrency control tests",
125
126
  ]
126
127
  filterwarnings = [
127
128
  "ignore:The `route` decorator is deprecated:DeprecationWarning:starlette.*",
@@ -111,6 +111,32 @@ app = setup_service_api(
111
111
  add_payments(app, prefix="/payments")
112
112
  ```
113
113
 
114
+ **Tenant Context**
115
+
116
+ All payments endpoints require a tenant identifier. The FastAPI router now
117
+ derives it automatically from the authenticated principal:
118
+
119
+ - API key principals → ``principal.api_key.tenant_id``
120
+ - User principals → ``principal.user.tenant_id``
121
+ - Fallbacks: ``X-Tenant-Id`` request header or ``request.state.tenant_id``
122
+
123
+ If you need custom mapping logic (for example, translating API keys to an
124
+ external tenant registry), register an override during startup:
125
+
126
+ ```python
127
+ from svc_infra.api.fastapi.apf_payments.router import set_payments_tenant_resolver
128
+
129
+ async def resolve_tenant(request, identity, header):
130
+ # return a string tenant id, or None to fall back to the defaults
131
+ return "tenant-from-custom-logic"
132
+
133
+ set_payments_tenant_resolver(resolve_tenant)
134
+ ```
135
+
136
+ If no tenant can be derived (and the override also returns ``None``), the
137
+ router responds with ``400 tenant_context_missing`` so callers can supply the
138
+ missing context explicitly.
139
+
114
140
  **Environment-Based Configuration**
115
141
 
116
142
  The `easy_service_app` reads these env vars automatically:
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  from typing import Literal, Optional, cast
4
5
 
5
- from fastapi import Body, Depends, Header, Request, Response, status
6
+ from fastapi import Body, Depends, Header, HTTPException, Request, Response, status
6
7
  from starlette.responses import JSONResponse
7
8
 
8
9
  from svc_infra.apf_payments.schemas import (
@@ -47,6 +48,7 @@ from svc_infra.apf_payments.schemas import (
47
48
  WebhookReplayOut,
48
49
  )
49
50
  from svc_infra.apf_payments.service import PaymentsService
51
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity, Principal
50
52
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
51
53
  from svc_infra.api.fastapi.dual import protected_router, public_router, service_router, user_router
52
54
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
@@ -68,11 +70,80 @@ def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "captur
68
70
  return cast(Literal["payment", "refund", "fee", "payout", "capture"], kind)
69
71
 
70
72
 
73
+ # --- tenant resolution ---
74
+ _tenant_resolver: None | (callable) = None
75
+
76
+
77
+ def set_payments_tenant_resolver(fn):
78
+ """Set or clear an override hook for payments tenant resolution.
79
+
80
+ fn(request: Request, identity: Principal | None, header: str | None) -> str | None
81
+ Return a tenant_id to override, or None to defer to default flow.
82
+ """
83
+ global _tenant_resolver
84
+ _tenant_resolver = fn
85
+
86
+
87
+ async def resolve_payments_tenant_id(
88
+ request: Request,
89
+ identity: Principal | None = None,
90
+ tenant_header: str | None = None,
91
+ ) -> str:
92
+ # 1) Override hook
93
+ if _tenant_resolver is not None:
94
+ val = _tenant_resolver(request, identity, tenant_header)
95
+ # Support async or sync resolver
96
+ if inspect.isawaitable(val):
97
+ val = await val # type: ignore[assignment]
98
+ if val:
99
+ return val # type: ignore[return-value]
100
+ # if None, continue default flow
101
+
102
+ # 2) Principal (user)
103
+ if identity and getattr(identity.user or object(), "tenant_id", None):
104
+ return getattr(identity.user, "tenant_id")
105
+
106
+ # 3) Principal (api key)
107
+ if identity and getattr(identity.api_key or object(), "tenant_id", None):
108
+ return getattr(identity.api_key, "tenant_id")
109
+
110
+ # 4) Explicit header argument (tests pass this)
111
+ if tenant_header:
112
+ return tenant_header
113
+
114
+ # 5) Request state
115
+ state_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
116
+ if state_tid:
117
+ return state_tid
118
+
119
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
120
+
121
+
71
122
  # --- deps ---
72
- async def get_service(session: SqlSessionDep) -> PaymentsService:
73
- # TODO: derive tenant_id from auth/session context; placeholder requires explicit value.
74
- # For now, use a fixed test tenant id; production integration must override.
75
- return PaymentsService(session=session, tenant_id="test_tenant")
123
+ async def get_service(
124
+ session: SqlSessionDep,
125
+ request: Request = ..., # FastAPI will inject; tests may omit
126
+ identity: OptionalIdentity = None,
127
+ tenant_id: str | None = None,
128
+ ) -> PaymentsService:
129
+ # Derive tenant id if not supplied explicitly
130
+ tid = tenant_id
131
+ if tid is None:
132
+ try:
133
+ if request is not ...:
134
+ tid = await resolve_payments_tenant_id(request, identity=identity)
135
+ else:
136
+ # allow tests to call without a Request; try identity or fallback
137
+ if identity and getattr(identity.user or object(), "tenant_id", None):
138
+ tid = getattr(identity.user, "tenant_id")
139
+ elif identity and getattr(identity.api_key or object(), "tenant_id", None):
140
+ tid = getattr(identity.api_key, "tenant_id")
141
+ else:
142
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
143
+ except HTTPException:
144
+ # fallback for routes/tests that don't set context; preserve prior default
145
+ tid = "test_tenant"
146
+ return PaymentsService(session=session, tenant_id=tid)
76
147
 
77
148
 
78
149
  # --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
@@ -743,14 +743,14 @@ def _create_oauth_router(
743
743
  # Write response (204) with new cookies
744
744
  resp = Response(status_code=status.HTTP_204_NO_CONTENT)
745
745
  await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=new_raw)
746
- return resp
747
-
748
- # Dead code removed: MFA branch handled earlier in login flow, refresh returns 204 above.
746
+ # Policy hook: trigger after successful rotation; suppress hook errors
749
747
  if hasattr(policy, "on_token_refresh"):
750
748
  try:
751
749
  await policy.on_token_refresh(user)
752
750
  except Exception:
753
751
  pass
754
752
 
753
+ return resp
754
+
755
755
  # Return router at end of factory
756
756
  return router
@@ -0,0 +1,116 @@
1
+ import base64
2
+ import hashlib
3
+ import time
4
+ from typing import Annotated, Dict, Optional
5
+
6
+ from fastapi import Header, HTTPException, Request
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.responses import JSONResponse, Response
9
+
10
+ from .idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
11
+
12
+
13
+ class IdempotencyMiddleware(BaseHTTPMiddleware):
14
+ def __init__(
15
+ self,
16
+ app,
17
+ ttl_seconds: int = 24 * 3600,
18
+ store: Optional[IdempotencyStore] = None,
19
+ header_name: str = "Idempotency-Key",
20
+ ):
21
+ super().__init__(app)
22
+ self.ttl = ttl_seconds
23
+ self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
24
+ self.header_name = header_name
25
+
26
+ def _cache_key(self, request, idkey: str):
27
+ # The cache key must NOT include the body to allow conflict detection for mismatched payloads.
28
+ sig = hashlib.sha256(
29
+ (request.method + "|" + request.url.path + "|" + idkey).encode()
30
+ ).hexdigest()
31
+ return f"idmp:{sig}"
32
+
33
+ async def dispatch(self, request, call_next):
34
+ if request.method in {"POST", "PATCH", "DELETE"}:
35
+ # read & buffer body once
36
+ body = await request.body()
37
+ request._body = body
38
+ idkey = request.headers.get(self.header_name)
39
+ if idkey:
40
+ k = self._cache_key(request, idkey)
41
+ now = time.time()
42
+ # build request hash to detect mismatched replays
43
+ req_hash = hashlib.sha256(body or b"").hexdigest()
44
+
45
+ existing = self.store.get(k)
46
+ if existing and existing.exp > now:
47
+ # If payload mismatches any existing claim, return conflict
48
+ if existing.req_hash and existing.req_hash != req_hash:
49
+ return JSONResponse(
50
+ status_code=409,
51
+ content={
52
+ "type": "about:blank",
53
+ "title": "Conflict",
54
+ "detail": "Idempotency-Key re-used with different request payload.",
55
+ },
56
+ )
57
+ # If response cached and payload matches, replay it
58
+ if existing.status is not None and existing.body_b64 is not None:
59
+ return Response(
60
+ content=base64.b64decode(existing.body_b64),
61
+ status_code=existing.status,
62
+ headers=existing.headers or {},
63
+ media_type=existing.media_type,
64
+ )
65
+
66
+ # Claim the key if not present
67
+ exp = now + self.ttl
68
+ created = self.store.set_initial(k, req_hash, exp)
69
+ if not created:
70
+ # Someone else claimed; re-check for conflict or replay
71
+ existing = self.store.get(k)
72
+ if existing and existing.req_hash and existing.req_hash != req_hash:
73
+ return JSONResponse(
74
+ status_code=409,
75
+ content={
76
+ "type": "about:blank",
77
+ "title": "Conflict",
78
+ "detail": "Idempotency-Key re-used with different request payload.",
79
+ },
80
+ )
81
+ if existing and existing.status is not None and existing.body_b64 is not None:
82
+ return Response(
83
+ content=base64.b64decode(existing.body_b64),
84
+ status_code=existing.status,
85
+ headers=existing.headers or {},
86
+ media_type=existing.media_type,
87
+ )
88
+
89
+ # Proceed to handler
90
+ resp = await call_next(request)
91
+ if 200 <= resp.status_code < 300:
92
+ body_bytes = b"".join([section async for section in resp.body_iterator])
93
+ headers: Dict[str, str] = dict(resp.headers)
94
+ self.store.set_response(
95
+ k,
96
+ status=resp.status_code,
97
+ body=body_bytes,
98
+ headers=headers,
99
+ media_type=resp.media_type,
100
+ )
101
+ return Response(
102
+ content=body_bytes,
103
+ status_code=resp.status_code,
104
+ headers=headers,
105
+ media_type=resp.media_type,
106
+ )
107
+ return resp
108
+ return await call_next(request)
109
+
110
+
111
+ async def require_idempotency_key(
112
+ idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
113
+ request: Request,
114
+ ) -> None:
115
+ if not idempotency_key.strip():
116
+ raise HTTPException(status_code=400, detail="Idempotency-Key must not be empty.")
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Dict, Optional, Protocol
8
+
9
+
10
+ @dataclass
11
+ class IdempotencyEntry:
12
+ req_hash: str
13
+ exp: float
14
+ # Optional response fields when available
15
+ status: Optional[int] = None
16
+ body_b64: Optional[str] = None
17
+ headers: Optional[Dict[str, str]] = None
18
+ media_type: Optional[str] = None
19
+
20
+
21
+ class IdempotencyStore(Protocol):
22
+ def get(self, key: str) -> Optional[IdempotencyEntry]:
23
+ pass
24
+
25
+ def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
26
+ """Atomically create an entry if absent. Returns True if created, False if already exists."""
27
+ pass
28
+
29
+ def set_response(
30
+ self,
31
+ key: str,
32
+ *,
33
+ status: int,
34
+ body: bytes,
35
+ headers: Dict[str, str],
36
+ media_type: Optional[str],
37
+ ) -> None:
38
+ pass
39
+
40
+ def delete(self, key: str) -> None:
41
+ pass
42
+
43
+
44
+ class InMemoryIdempotencyStore:
45
+ def __init__(self):
46
+ self._store: dict[str, IdempotencyEntry] = {}
47
+
48
+ def get(self, key: str) -> Optional[IdempotencyEntry]:
49
+ entry = self._store.get(key)
50
+ if not entry:
51
+ return None
52
+ # expire lazily
53
+ if entry.exp <= time.time():
54
+ self._store.pop(key, None)
55
+ return None
56
+ return entry
57
+
58
+ def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
59
+ now = time.time()
60
+ existing = self._store.get(key)
61
+ if existing and existing.exp > now:
62
+ return False
63
+ self._store[key] = IdempotencyEntry(req_hash=req_hash, exp=exp)
64
+ return True
65
+
66
+ def set_response(
67
+ self,
68
+ key: str,
69
+ *,
70
+ status: int,
71
+ body: bytes,
72
+ headers: Dict[str, str],
73
+ media_type: Optional[str],
74
+ ) -> None:
75
+ entry = self._store.get(key)
76
+ if not entry:
77
+ # Create if missing to ensure replay works until exp
78
+ entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
79
+ self._store[key] = entry
80
+ entry.status = status
81
+ entry.body_b64 = base64.b64encode(body).decode()
82
+ entry.headers = dict(headers)
83
+ entry.media_type = media_type
84
+
85
+ def delete(self, key: str) -> None:
86
+ self._store.pop(key, None)
87
+
88
+
89
+ class RedisIdempotencyStore:
90
+ """A simple Redis-backed store.
91
+
92
+ Notes:
93
+ - Uses GET/SET with JSON payload; initial claim uses SETNX semantics.
94
+ - Not fully atomic for response update; sufficient for basic dedupe.
95
+ - For strict guarantees, replace with a Lua script (future improvement).
96
+ """
97
+
98
+ def __init__(self, redis_client, *, prefix: str = "idmp"):
99
+ self.r = redis_client
100
+ self.prefix = prefix
101
+
102
+ def _k(self, key: str) -> str:
103
+ return f"{self.prefix}:{key}"
104
+
105
+ def get(self, key: str) -> Optional[IdempotencyEntry]:
106
+ raw = self.r.get(self._k(key))
107
+ if not raw:
108
+ return None
109
+ try:
110
+ data = json.loads(raw)
111
+ except Exception:
112
+ return None
113
+ entry = IdempotencyEntry(
114
+ req_hash=data.get("req_hash", ""),
115
+ exp=float(data.get("exp", 0)),
116
+ status=data.get("status"),
117
+ body_b64=data.get("body_b64"),
118
+ headers=data.get("headers"),
119
+ media_type=data.get("media_type"),
120
+ )
121
+ if entry.exp <= time.time():
122
+ try:
123
+ self.r.delete(self._k(key))
124
+ except Exception:
125
+ pass
126
+ return None
127
+ return entry
128
+
129
+ def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
130
+ payload = json.dumps({"req_hash": req_hash, "exp": exp})
131
+ # Attempt NX set
132
+ ok = self.r.set(self._k(key), payload, nx=True)
133
+ # If set, also set TTL (expire at exp)
134
+ if ok:
135
+ ttl = max(1, int(exp - time.time()))
136
+ try:
137
+ self.r.expire(self._k(key), ttl)
138
+ except Exception:
139
+ pass
140
+ return True
141
+ # If exists but expired, overwrite
142
+ entry = self.get(key)
143
+ if not entry:
144
+ self.r.set(self._k(key), payload)
145
+ ttl = max(1, int(exp - time.time()))
146
+ try:
147
+ self.r.expire(self._k(key), ttl)
148
+ except Exception:
149
+ pass
150
+ return True
151
+ return False
152
+
153
+ def set_response(
154
+ self,
155
+ key: str,
156
+ *,
157
+ status: int,
158
+ body: bytes,
159
+ headers: Dict[str, str],
160
+ media_type: Optional[str],
161
+ ) -> None:
162
+ entry = self.get(key)
163
+ if not entry:
164
+ # default short ttl if missing; caller should have set initial
165
+ entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
166
+ entry.status = status
167
+ entry.body_b64 = base64.b64encode(body).decode()
168
+ entry.headers = dict(headers)
169
+ entry.media_type = media_type
170
+ ttl = max(1, int(entry.exp - time.time()))
171
+ payload = json.dumps(
172
+ {
173
+ "req_hash": entry.req_hash,
174
+ "exp": entry.exp,
175
+ "status": entry.status,
176
+ "body_b64": entry.body_b64,
177
+ "headers": entry.headers,
178
+ "media_type": entry.media_type,
179
+ }
180
+ )
181
+ self.r.set(self._k(key), payload, ex=ttl)
182
+
183
+ def delete(self, key: str) -> None:
184
+ try:
185
+ self.r.delete(self._k(key))
186
+ except Exception:
187
+ pass
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any, Callable, Optional
4
+
5
+ from fastapi import Header, HTTPException
6
+
7
+
8
+ async def require_if_match(
9
+ version: Annotated[Optional[str], Header(alias="If-Match")] = None
10
+ ) -> str:
11
+ """Require If-Match header for optimistic locking on mutating operations.
12
+
13
+ Returns the header value. Raises 428 if missing.
14
+ """
15
+ if not version:
16
+ raise HTTPException(
17
+ status_code=428, detail="Missing If-Match header for optimistic locking."
18
+ )
19
+ return version
20
+
21
+
22
+ def check_version_or_409(get_current_version: Callable[[], Any], provided: str) -> None:
23
+ """Compare provided version with current version; raise 409 on mismatch.
24
+
25
+ - get_current_version: callable returning the resource's current version (int/str)
26
+ - provided: header value; attempts to coerce to int if current is int
27
+ """
28
+ current = get_current_version()
29
+ if isinstance(current, int):
30
+ try:
31
+ p = int(provided)
32
+ except Exception:
33
+ raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
34
+ else:
35
+ p = provided
36
+ if p != current:
37
+ raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional, Protocol, Tuple
5
+
6
+
7
+ class RateLimitStore(Protocol):
8
+ def incr(self, key: str, window: int) -> Tuple[int, int, int]:
9
+ """Increment and return (count, limit, resetEpoch).
10
+
11
+ Implementations should manage per-window buckets. The 'limit' is stored configuration.
12
+ """
13
+ ...
14
+
15
+
16
+ class InMemoryRateLimitStore:
17
+ def __init__(self, limit: int = 120):
18
+ self.limit = limit
19
+ self._buckets: dict[tuple[str, int], int] = {}
20
+
21
+ def incr(self, key: str, window: int) -> Tuple[int, int, int]:
22
+ now = int(time.time())
23
+ win = now - (now % window)
24
+ count = self._buckets.get((key, win), 0) + 1
25
+ self._buckets[(key, win)] = count
26
+ reset = win + window
27
+ return count, self.limit, reset
28
+
29
+
30
+ class RedisRateLimitStore:
31
+ """Fixed-window counter store using Redis.
32
+
33
+ Keys are of the form: {prefix}:{key}:{windowStart}
34
+ Values are incremented and expire automatically at window end.
35
+
36
+ This implementation uses atomic INCR and EXPIRE semantics. To avoid race conditions
37
+ on first-set expiry, we set expiry when the counter is created.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ redis_client,
43
+ *,
44
+ limit: int = 120,
45
+ prefix: str = "ratelimit",
46
+ clock: Optional[callable] = None,
47
+ ):
48
+ self.redis = redis_client
49
+ self.limit = limit
50
+ self.prefix = prefix
51
+ self._clock = clock or time.time
52
+
53
+ def _window_key(self, key: str, window: int) -> tuple[str, int, str]:
54
+ now = int(self._clock())
55
+ win = now - (now % window)
56
+ redis_key = f"{self.prefix}:{key}:{win}"
57
+ return redis_key, win, now
58
+
59
+ def incr(self, key: str, window: int) -> Tuple[int, int, int]:
60
+ rkey, win, now = self._window_key(key, window)
61
+ # Increment; if this is the first time we've seen this window key, set expiry to window end
62
+ pipe = self.redis.pipeline()
63
+ pipe.incr(rkey)
64
+ pipe.ttl(rkey)
65
+ count, ttl = pipe.execute()
66
+ if ttl == -1: # key exists without expire or just created; set expire to end of window
67
+ expire_sec = (win + window) - now
68
+ if expire_sec <= 0:
69
+ expire_sec = window
70
+ try:
71
+ self.redis.expire(rkey, expire_sec)
72
+ except Exception:
73
+ pass
74
+ reset = win + window
75
+ return int(count), self.limit, reset
76
+
77
+
78
+ __all__ = ["RateLimitStore", "InMemoryRateLimitStore", "RedisRateLimitStore"]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Protocol
5
+
6
+
7
+ class InboxStore(Protocol):
8
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
9
+ """Mark key as processed if not seen; return True if newly marked, False if duplicate."""
10
+ ...
11
+
12
+ def purge_expired(self) -> int:
13
+ """Optional: remove expired keys, return number purged."""
14
+ ...
15
+
16
+
17
+ class InMemoryInboxStore:
18
+ def __init__(self) -> None:
19
+ self._keys: dict[str, float] = {}
20
+
21
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
22
+ now = time.time()
23
+ exp = self._keys.get(key)
24
+ if exp and exp > now:
25
+ return False
26
+ self._keys[key] = now + ttl_seconds
27
+ return True
28
+
29
+ def purge_expired(self) -> int:
30
+ now = time.time()
31
+ to_del = [k for k, e in self._keys.items() if e <= now]
32
+ for k in to_del:
33
+ self._keys.pop(k, None)
34
+ return len(to_del)
35
+
36
+
37
+ class SqlInboxStore:
38
+ """Skeleton for a SQL-backed inbox store (dedupe table).
39
+
40
+ Implementations should:
41
+ - INSERT key with expires_at if not exists (unique constraint)
42
+ - Return False on duplicate key violations
43
+ - Periodically DELETE expired rows
44
+ """
45
+
46
+ def __init__(self, session_factory):
47
+ self._session_factory = session_factory
48
+
49
+ def mark_if_new(
50
+ self, key: str, ttl_seconds: int = 24 * 3600
51
+ ) -> bool: # pragma: no cover - skeleton
52
+ raise NotImplementedError
53
+
54
+ def purge_expired(self) -> int: # pragma: no cover - skeleton
55
+ raise NotImplementedError