nene2-python 1.5.0__tar.gz → 1.7.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 (199) hide show
  1. {nene2_python-1.5.0 → nene2_python-1.7.0}/CHANGELOG.md +31 -0
  2. {nene2_python-1.5.0 → nene2_python-1.7.0}/PKG-INFO +1 -1
  3. nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-13.md +54 -0
  4. nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-14.md +45 -0
  5. nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-15.md +67 -0
  6. nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-16.md +62 -0
  7. nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-17.md +66 -0
  8. {nene2_python-1.5.0 → nene2_python-1.7.0}/pyproject.toml +1 -1
  9. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/__init__.py +2 -1
  10. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/exceptions.py +4 -0
  11. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/sqlalchemy_executor.py +17 -5
  12. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/security_headers.py +24 -7
  13. nene2_python-1.7.0/src/nene2/use_case/protocols.py +51 -0
  14. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/validation/exceptions.py +20 -2
  15. nene2_python-1.7.0/tests/nene2/database/test_transaction.py +137 -0
  16. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_security_headers.py +41 -0
  17. nene2_python-1.7.0/tests/nene2/validation/test_exceptions.py +46 -0
  18. {nene2_python-1.5.0 → nene2_python-1.7.0}/uv.lock +1 -1
  19. nene2_python-1.5.0/src/nene2/use_case/protocols.py +0 -24
  20. nene2_python-1.5.0/tests/nene2/database/test_transaction.py +0 -73
  21. nene2_python-1.5.0/tests/nene2/validation/test_exceptions.py +0 -22
  22. {nene2_python-1.5.0 → nene2_python-1.7.0}/.env.example +0 -0
  23. {nene2_python-1.5.0 → nene2_python-1.7.0}/.github/workflows/ci.yml +0 -0
  24. {nene2_python-1.5.0 → nene2_python-1.7.0}/.github/workflows/docs.yml +0 -0
  25. {nene2_python-1.5.0 → nene2_python-1.7.0}/.github/workflows/publish.yml +0 -0
  26. {nene2_python-1.5.0 → nene2_python-1.7.0}/.gitignore +0 -0
  27. {nene2_python-1.5.0 → nene2_python-1.7.0}/.vitepress/config.mts +0 -0
  28. {nene2_python-1.5.0 → nene2_python-1.7.0}/.vitepress/theme/custom.css +0 -0
  29. {nene2_python-1.5.0 → nene2_python-1.7.0}/.vitepress/theme/index.ts +0 -0
  30. {nene2_python-1.5.0 → nene2_python-1.7.0}/AGENTS.md +0 -0
  31. {nene2_python-1.5.0 → nene2_python-1.7.0}/CLAUDE.md +0 -0
  32. {nene2_python-1.5.0 → nene2_python-1.7.0}/Dockerfile +0 -0
  33. {nene2_python-1.5.0 → nene2_python-1.7.0}/LICENSE +0 -0
  34. {nene2_python-1.5.0 → nene2_python-1.7.0}/README.md +0 -0
  35. {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/README +0 -0
  36. {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/env.py +0 -0
  37. {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/script.py.mako +0 -0
  38. {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
  39. {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic.ini +0 -0
  40. {nene2_python-1.5.0 → nene2_python-1.7.0}/compose.yaml +0 -0
  41. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0001-toolchain.md +0 -0
  42. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0002-clean-architecture.md +0 -0
  43. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0003-security-first.md +0 -0
  44. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0004-ai-first-design.md +0 -0
  45. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0005-logging.md +0 -0
  46. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0006-rate-limiting.md +0 -0
  47. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0009-mcp-design.md +0 -0
  48. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0010-async-use-case.md +0 -0
  49. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0011-mcp-as-core-dependency.md +0 -0
  50. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/de/index.md +0 -0
  51. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/de/tutorials/getting-started.md +0 -0
  52. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/explanation/architecture.md +0 -0
  53. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/explanation/design-philosophy.md +0 -0
  54. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
  55. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-10.md +0 -0
  56. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-11.md +0 -0
  57. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-12.md +0 -0
  58. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
  59. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
  60. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
  61. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
  62. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
  63. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-7.md +0 -0
  64. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-8.md +0 -0
  65. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-9.md +0 -0
  66. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/fr/index.md +0 -0
  67. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/fr/tutorials/getting-started.md +0 -0
  68. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/add-new-domain.md +0 -0
  69. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/configure-auth.md +0 -0
  70. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/new-project.md +0 -0
  71. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/run-tests.md +0 -0
  72. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/sqlalchemy-repository.md +0 -0
  73. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/howto/mcp-setup.md +0 -0
  74. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/index.md +0 -0
  75. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/explanation/architecture.md +0 -0
  76. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/explanation/design-philosophy.md +0 -0
  77. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/add-new-domain.md +0 -0
  78. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/configure-auth.md +0 -0
  79. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/new-project.md +0 -0
  80. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/run-tests.md +0 -0
  81. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
  82. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/howto/mcp-setup.md +0 -0
  83. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/index.md +0 -0
  84. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/reference/api.md +0 -0
  85. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/reference/configuration.md +0 -0
  86. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/reference/framework-modules.md +0 -0
  87. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/tutorials/first-domain.md +0 -0
  88. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/tutorials/getting-started.md +0 -0
  89. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/pt-br/index.md +0 -0
  90. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/pt-br/tutorials/getting-started.md +0 -0
  91. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/reference/api.md +0 -0
  92. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/reference/configuration.md +0 -0
  93. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/reference/framework-modules.md +0 -0
  94. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/roadmap.md +0 -0
  95. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/todo/current.md +0 -0
  96. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/tutorials/first-domain.md +0 -0
  97. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/tutorials/getting-started.md +0 -0
  98. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/zh/index.md +0 -0
  99. {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/zh/tutorials/getting-started.md +0 -0
  100. {nene2_python-1.5.0 → nene2_python-1.7.0}/package-lock.json +0 -0
  101. {nene2_python-1.5.0 → nene2_python-1.7.0}/package.json +0 -0
  102. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/__init__.py +0 -0
  103. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/__main__.py +0 -0
  104. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/app.py +0 -0
  105. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/__init__.py +0 -0
  106. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/entity.py +0 -0
  107. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/exceptions.py +0 -0
  108. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/handler.py +0 -0
  109. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/repository.py +0 -0
  110. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/sqlalchemy_repository.py +0 -0
  111. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/use_case.py +0 -0
  112. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/mcp.py +0 -0
  113. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/__init__.py +0 -0
  114. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/async_use_case.py +0 -0
  115. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/entity.py +0 -0
  116. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/exceptions.py +0 -0
  117. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/handler.py +0 -0
  118. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/repository.py +0 -0
  119. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/sqlalchemy_repository.py +0 -0
  120. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/use_case.py +0 -0
  121. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/schema.py +0 -0
  122. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/__init__.py +0 -0
  123. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/entity.py +0 -0
  124. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/exceptions.py +0 -0
  125. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/handler.py +0 -0
  126. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/repository.py +0 -0
  127. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/sqlalchemy_repository.py +0 -0
  128. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/use_case.py +0 -0
  129. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/__init__.py +0 -0
  130. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/__init__.py +0 -0
  131. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/api_key.py +0 -0
  132. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/bearer_token.py +0 -0
  133. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/exceptions.py +0 -0
  134. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/interfaces.py +0 -0
  135. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/local_verifier.py +0 -0
  136. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/config/__init__.py +0 -0
  137. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/config/settings.py +0 -0
  138. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/health.py +0 -0
  139. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/interfaces.py +0 -0
  140. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/utils.py +0 -0
  141. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/__init__.py +0 -0
  142. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/health.py +0 -0
  143. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/pagination.py +0 -0
  144. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/problem_details.py +0 -0
  145. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/log/__init__.py +0 -0
  146. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/log/setup.py +0 -0
  147. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/mcp/__init__.py +0 -0
  148. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/mcp/http_client.py +0 -0
  149. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/mcp/server.py +0 -0
  150. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/__init__.py +0 -0
  151. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/domain_exception.py +0 -0
  152. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/error_handler.py +0 -0
  153. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/request_id.py +0 -0
  154. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/request_logging.py +0 -0
  155. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/request_size_limit.py +0 -0
  156. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/throttle.py +0 -0
  157. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/py.typed +0 -0
  158. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/use_case/__init__.py +0 -0
  159. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/validation/__init__.py +0 -0
  160. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/scripts/__init__.py +0 -0
  161. {nene2_python-1.5.0 → nene2_python-1.7.0}/src/scripts/export_openapi.py +0 -0
  162. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/__init__.py +0 -0
  163. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/__init__.py +0 -0
  164. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/__init__.py +0 -0
  165. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/test_comment_http.py +0 -0
  166. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/test_comment_repository.py +0 -0
  167. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/test_comment_use_case.py +0 -0
  168. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/conftest.py +0 -0
  169. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/__init__.py +0 -0
  170. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/test_async_note_use_case.py +0 -0
  171. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/test_list_notes.py +0 -0
  172. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/test_note_repository.py +0 -0
  173. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/tag/__init__.py +0 -0
  174. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/tag/test_tag_repository.py +0 -0
  175. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/tag/test_tags.py +0 -0
  176. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/test_cors.py +0 -0
  177. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/test_mcp.py +0 -0
  178. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/__init__.py +0 -0
  179. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/__init__.py +0 -0
  180. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/test_api_key.py +0 -0
  181. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/test_bearer_token.py +0 -0
  182. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/test_token_issuer.py +0 -0
  183. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/database/__init__.py +0 -0
  184. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/database/test_utils.py +0 -0
  185. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/http/__init__.py +0 -0
  186. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/http/test_pagination.py +0 -0
  187. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/mcp/__init__.py +0 -0
  188. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/mcp/test_http_client.py +0 -0
  189. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/__init__.py +0 -0
  190. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_error_handler.py +0 -0
  191. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_request_id.py +0 -0
  192. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_request_logging.py +0 -0
  193. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
  194. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_throttle.py +0 -0
  195. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/use_case/__init__.py +0 -0
  196. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/use_case/test_protocols.py +0 -0
  197. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/validation/__init__.py +0 -0
  198. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/scripts/__init__.py +0 -0
  199. {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/scripts/test_export_openapi.py +0 -0
@@ -5,6 +5,37 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ---
7
7
 
8
+ ## [1.7.0] — 2026-05-20
9
+
10
+ FT14〜FT17 フィールドトライアル — プロトコル docstring 改善・ミドルウェアカスタマイズ・DB 例外統一・バグ修正。
11
+
12
+ ### Added
13
+ - `SecurityHeadersMiddleware` — `csp: str | None` パラメータで Content-Security-Policy 値をカスタマイズ可能に (FT15)
14
+ - `SecurityHeadersMiddleware` — `extra_no_csp_paths: list[str] | None` パラメータでカスタム OpenAPI パスの CSP スキップを設定可能に (FT15)
15
+ - `DatabaseIntegrityException` — UNIQUE/FK/CHECK 制約違反時に発生する新例外クラス (FT16)
16
+ - Field trial reports: `docs/field-trials/2026-05-field-trial-14.md` 〜 `docs/field-trials/2026-05-field-trial-17.md`
17
+
18
+ ### Changed
19
+ - `AsyncUseCaseProtocol` / `UseCaseProtocol` — docstring に `@runtime_checkable` の `isinstance` 制限と `inspect.iscoroutinefunction()` によるランタイム確認方法を明記 (FT14)
20
+ - `SqlAlchemyTransactionManager.transactional()` — `IntegrityError` をキャッチして `DatabaseIntegrityException` にラップするよう変更 (FT16)
21
+
22
+ ### Fixed
23
+ - `SqlAlchemyQueryExecutor.write()` — `IntegrityError` が `DatabaseIntegrityException` にラップされない不整合を修正 (FT17-F1)
24
+ - `SqlAlchemyQueryExecutor.write()` と `_BoundQueryExecutor.write()` — UPDATE/DELETE で 0 行影響した場合に前の INSERT の `lastrowid` が返るバグを修正; INSERT のみ `lastrowid`、UPDATE/DELETE は `rowcount` を返すよう変更 (FT17-F2)
25
+
26
+ ---
27
+
28
+ ## [1.6.0] — 2026-05-20
29
+
30
+ FT13 (ValidationException実運用) field trial — validation DX improvements.
31
+
32
+ ### Added
33
+ - `ValidationException.single(field, message, code)` — convenience classmethod for single-error raises
34
+ - `ValidationError.__post_init__` now names the specific empty field in the error message
35
+ - Field trial report: `docs/field-trials/2026-05-field-trial-13.md`
36
+
37
+ ---
38
+
8
39
  ## [1.5.0] — 2026-05-20
9
40
 
10
41
  FT12 (ThrottleMiddleware + RequestSizeLimitMiddleware) field trial — middleware exclude_paths consistency.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nene2-python
3
- Version: 1.5.0
3
+ Version: 1.7.0
4
4
  Summary: NENE2 Python — minimal API framework following NENE2's design philosophy
5
5
  Project-URL: Homepage, https://github.com/hideyukiMORI/nene2-python
6
6
  Project-URL: Repository, https://github.com/hideyukiMORI/nene2-python
@@ -0,0 +1,54 @@
1
+ # Field Trial 13 — ValidationException 実運用
2
+
3
+ **Date:** 2026-05-20
4
+ **App:** User Registration API(メール・パスワード・年齢の複合バリデーション)
5
+ **Directory:** `/home/xi/docker/nene2-python-FT/ft13-validation/`
6
+ **nene2-python version:** v1.5.0
7
+
8
+ ## 概要
9
+
10
+ `ValidationException` と `ValidationError` を実際のアプリで使い、
11
+ 複数フィールドのバリデーション・複数エラーの集約・レスポンス形式を検証した。
12
+
13
+ ## 動作確認結果
14
+
15
+ - 複数フィールドのバリデーションエラーが一度に返ること ✓(fail-fast ではなく fail-all)
16
+ - `ErrorHandlerMiddleware` が `ValidationException` を 422 に変換すること ✓
17
+ - レスポンスが RFC 9457 形式で `errors` 配列を含むこと ✓
18
+ - `ValidationError` の `field`, `message`, `code` が全フィールドで返ること ✓
19
+
20
+ ## 摩擦点
21
+
22
+ ### FT13-F1 (LOW): 単一エラーでもリストラップが必要
23
+
24
+ 単一フィールドのエラーでも `list` でラップする必要があり、やや冗長。
25
+
26
+ ```python
27
+ # 現状(毎回リストが必要)
28
+ raise ValidationException([ValidationError("email", "invalid", "invalid_email")])
29
+
30
+ # こうしたい
31
+ raise ValidationException.single("email", "invalid", "invalid_email")
32
+ ```
33
+
34
+ 特に UseCase 層で早期リターンするケース(1つのエラーが致命的で後続チェック不要な場合)に
35
+ `ValidationException([...])` は読みにくい。
36
+
37
+ ### FT13-F2 (LOW): ValidationError の空文字エラーメッセージがどのフィールドか不明
38
+
39
+ `ValidationError("", "message", "code")` を作ると `ValueError: field, message, and code must be non-empty strings` が出るが、どのフィールドが空かわからない。開発時のデバッグに時間がかかる。
40
+
41
+ ```python
42
+ # 現状
43
+ ValidationError("", "msg", "code")
44
+ # → ValueError: field, message, and code must be non-empty strings
45
+
46
+ # 改善案
47
+ # → ValueError: ValidationError.field must not be empty
48
+ ```
49
+
50
+ ## まとめ
51
+
52
+ `ValidationException` の基本動作は問題なし。摩擦は軽微で、実務で使うのに大きな障壁はない。
53
+ FT13-F1 の `ValidationException.single()` はよくある単一エラーケースの DX 改善として有用。
54
+ FT13-F2 はデバッグ時の QoL 向上。どちらも LOW 優先度。
@@ -0,0 +1,45 @@
1
+ # Field Trial 14 — AsyncUseCaseProtocol 実運用
2
+
3
+ **Date:** 2026-05-20
4
+ **App:** Weather Dashboard API(複数都市の天気を asyncio.gather で並列取得)
5
+ **Directory:** `/home/xi/docker/nene2-python-FT/ft14-async/`
6
+ **nene2-python version:** v1.6.0
7
+
8
+ ## 概要
9
+
10
+ `AsyncUseCaseProtocol` を使った非同期 UseCase を実際に実装し、
11
+ `asyncio.gather` による並列 I/O の動作とプロトコルの挙動を検証した。
12
+
13
+ ## 動作確認結果
14
+
15
+ - `AsyncUseCaseProtocol` の `isinstance` 検査が正しく動くこと ✓
16
+ - `asyncio.gather` で 4 都市を並列取得した場合、50ms(直列の場合 200ms)で完了すること ✓
17
+ - `FetchDashboardUseCase(fetch_weather: AsyncUseCaseProtocol[...])` のコンストラクタインジェクションが機能すること ✓
18
+ - FastAPI の `async def` ハンドラーから非同期 UseCase を `await` で呼び出せること ✓
19
+
20
+ ## 摩擦点
21
+
22
+ ### FT14-F1 (LOW, ドキュメント): runtime_checkable の制限がプロトコルの docstring に記載されていない
23
+
24
+ `isinstance(sync_obj, AsyncUseCaseProtocol)` が `True` を返す(Python の `@runtime_checkable` は
25
+ メソッド名の存在のみを検査し、async/sync を区別しない)。
26
+
27
+ ```python
28
+ class FakeSyncWeather:
29
+ def execute(self, input_: WeatherInput) -> str: # async ではない
30
+ return "fake"
31
+
32
+ isinstance(FakeSyncWeather(), AsyncUseCaseProtocol) # → True(注意が必要)
33
+ ```
34
+
35
+ この制限は ADR-0010 に記録済みだが、`AsyncUseCaseProtocol` の docstring には記載されていない。
36
+ 利用者が docstring だけ見て `isinstance` でランタイムガードを書くと誤動作する。
37
+
38
+ **対応**: プロトコルの docstring に「mypy --strict が静的に保証する; isinstance はメソッド名のみを検査する」旨を追記する。
39
+
40
+ ## まとめ
41
+
42
+ 基本動作は問題なし。`asyncio.gather` パターン・コンストラクタインジェクション・FastAPI 統合のいずれも
43
+ 摩擦なく動作した。唯一の摩擦は docstring によるドキュメントギャップ(LOW)のみ。
44
+
45
+ FT14 は「設計が正しく実装されている」ことの確認として有用だった。
@@ -0,0 +1,67 @@
1
+ # Field Trial 15 — SecurityHeadersMiddleware 実運用
2
+
3
+ **Date:** 2026-05-20
4
+ **App:** FT15 Security Headers API(セキュリティヘッダーの付与・CSP 動作確認)
5
+ **Directory:** `/home/xi/docker/nene2-python-FT/ft15-security-headers/`
6
+ **nene2-python version:** v1.6.0
7
+
8
+ ## 概要
9
+
10
+ `SecurityHeadersMiddleware` を実際のアプリに組み込み、各ヘッダーの付与動作・
11
+ CSP スキップロジック・カスタマイズ可能性を検証した。
12
+
13
+ ## 動作確認結果
14
+
15
+ - 通常エンドポイントに `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`, `Permissions-Policy` の全ヘッダーが付与されること ✓
16
+ - `/docs`, `/redoc`, `/openapi.json` では `Content-Security-Policy` がスキップされること ✓
17
+ - CSP スキップ時も他のヘッダーは引き続き付与されること ✓
18
+
19
+ ## 摩擦点
20
+
21
+ ### FT15-F1 (MEDIUM, 拡張性): カスタム OpenAPI パスで CSP スキップが効かない
22
+
23
+ `SecurityHeadersMiddleware` がスキップする OpenAPI パスは `_OPENAPI_PATHS` 定数で
24
+ `{"/docs", "/redoc", "/openapi.json"}` にハードコードされている。
25
+
26
+ FastAPI では `docs_url` / `redoc_url` でカスタムパスを設定できるが、
27
+ カスタムパス(例: `/api-docs`)では CSP スキップが効かず、Swagger UI が CDN アセットを
28
+ ブロックされる可能性がある。
29
+
30
+ ```python
31
+ app = FastAPI(docs_url="/api-docs", redoc_url="/api-redoc")
32
+ app.add_middleware(SecurityHeadersMiddleware)
33
+ # → /api-docs に CSP "default-src 'self'" が付いてしまう
34
+ ```
35
+
36
+ **対応**: コンストラクタに `extra_no_csp_paths: list[str] | None = None` を追加し、
37
+ ユーザーがカスタム OpenAPI パスを指定できるようにする。または `ThrottleMiddleware` と
38
+ 同様の `exclude_paths` パターンで完全スキップを可能にする。
39
+
40
+ ### FT15-F2 (LOW, 拡張性): CSP をコンストラクタで上書きできない
41
+
42
+ CSP の値 `"default-src 'self'"` はモジュールレベルの `_HEADERS` 定数にハードコードされており、
43
+ コンストラクタから上書きできない。
44
+
45
+ CDN アセットを許可したい(`content-src 'self' cdn.example.com`)など、
46
+ CSP を変更したいユーザーは `_HEADERS` をモジュールレベルで直接書き換えるしかなく、
47
+ グローバルな副作用が発生する。
48
+
49
+ ```python
50
+ # 現状の唯一の方法(副作用あり)
51
+ from nene2.middleware import security_headers
52
+ security_headers._HEADERS["Content-Security-Policy"] = "default-src 'self' cdn.example.com"
53
+ ```
54
+
55
+ **対応**: `SecurityHeadersMiddleware(csp: str | None = None)` でコンストラクタから
56
+ CSP 値を上書きできるようにする。
57
+
58
+ ## まとめ
59
+
60
+ 基本動作は問題なし。全セキュリティヘッダーの付与・OpenAPI パスでの CSP スキップも
61
+ 正常に機能した。摩擦は拡張性の面で2点あり:
62
+
63
+ - FT15-F1 (MEDIUM): カスタム OpenAPI パスで CSP スキップが効かない
64
+ - FT15-F2 (LOW): CSP 値をコンストラクタから変更できない
65
+
66
+ `ThrottleMiddleware` が `exclude_paths` を持つのに対し、`SecurityHeadersMiddleware` は
67
+ カスタマイズポイントがゼロであるため、実用アプリでの柔軟性が低い。
@@ -0,0 +1,62 @@
1
+ # Field Trial 16 — transactional(callback) パターン実運用
2
+
3
+ **Date:** 2026-05-20
4
+ **App:** 銀行口座送金 API(送金元の残高を減らして送金先に加算する atomic 操作)
5
+ **Directory:** `/home/xi/docker/nene2-python-FT/ft16-transaction/`
6
+ **nene2-python version:** v1.6.0
7
+
8
+ ## 概要
9
+
10
+ `transactional(callback)` パターンを実際の送金ユースケースに適用し、
11
+ ロールバック挙動・例外処理・async コンテキストとの相互作用を検証した。
12
+
13
+ ## 動作確認結果
14
+
15
+ - `transactional(callback)` が atomic に実行されること ✓
16
+ - `CHECK 制約違反`(残高 < 0)発生時に全操作がロールバックされること ✓
17
+ - `UNIQUE 制約違反`(IntegrityError)でもロールバックが正しく行われること ✓
18
+ - SQLite `:memory:` + `StaticPool` で `SqlAlchemyQueryExecutor` と `SqlAlchemyTransactionManager` を同一 DB に向けられること ✓
19
+
20
+ ## 摩擦点
21
+
22
+ ### FT16-F1 (MEDIUM, API一貫性): IntegrityError が DatabaseConnectionException にラップされない
23
+
24
+ `SqlAlchemyTransactionManager.transactional()` は `OperationalError` のみを
25
+ `DatabaseConnectionException` に変換し、`IntegrityError`(UNIQUE 制約違反・FK 制約違反など)は
26
+ 生の SQLAlchemy 例外として呼び出し側に伝播する。
27
+
28
+ UseCase 層でフレームワーク独自例外に統一されていないため、
29
+ 呼び出し元が SQLAlchemy の例外型に依存した `except IntegrityError` を書く必要がある。
30
+
31
+ ```python
32
+ from sqlalchemy.exc import IntegrityError
33
+
34
+ with pytest.raises(IntegrityError): # 摩擦: SQLAlchemy 依存が漏れ出す
35
+ tx_manager.transactional(_duplicate_insert)
36
+ ```
37
+
38
+ **対応案**: `IntegrityError` も `DatabaseConnectionException`(または新設の `DatabaseIntegrityException`)にラップする。または `transactional()` がキャッチすべき SQLAlchemy 例外一覧をドキュメントに明記する。
39
+
40
+ ### FT16-F2 (LOW, 非同期対応): async コンテキストから transactional() を呼ぶとイベントループをブロックする
41
+
42
+ `SqlAlchemyTransactionManager.transactional()` は同期 API であるため、
43
+ FastAPI の `async def` ハンドラーから直接呼ぶとイベントループをブロックする。
44
+ 現状の実装でも動作はするが、高負荷時にパフォーマンス劣化の原因になりうる。
45
+
46
+ ```python
47
+ @app.post("/transfer")
48
+ async def transfer(...) -> JSONResponse:
49
+ # 同期 transactional() を直接呼んでいる(イベントループブロッキング)
50
+ result = transfer_uc.execute(...)
51
+ ```
52
+
53
+ **対応案**: `asyncio.to_thread()` でのラップをドキュメントに記載する。
54
+ または `AsyncSqlAlchemyTransactionManager` を将来的に追加する(SQLAlchemy async core を利用)。
55
+
56
+ ## まとめ
57
+
58
+ コアの atomic 保証(コミット・ロールバック)は期待通りに機能した。
59
+ 摩擦は例外ハンドリングの API 一貫性(F1: MEDIUM)と非同期対応(F2: LOW)の2点。
60
+
61
+ F1 は UseCase 層が SQLAlchemy に依存するアーキテクチャ上の問題であり、
62
+ Clean Architecture の原則に照らして対応が必要。
@@ -0,0 +1,66 @@
1
+ # Field Trial 17 — 複数ドメイン連携実運用
2
+
3
+ **Date:** 2026-05-20
4
+ **App:** タスク管理API(Task + Category の2ドメイン連携)
5
+ **Directory:** `/home/xi/docker/nene2-python-FT/ft17-multi-domain/`
6
+ **nene2-python version:** v1.6.0 (local dev build)
7
+
8
+ ## 概要
9
+
10
+ Task(タスク)と Category(カテゴリ)の2ドメインを連携させたアプリを実装し、
11
+ `SqlAlchemyQueryExecutor.write()` と `DatabaseIntegrityException`、
12
+ `transactional(callback)` の混在パターンの DX を検証した。
13
+
14
+ ## 動作確認結果
15
+
16
+ - カテゴリ一覧・タスク一覧・カテゴリでのフィルタリングが動作すること ✓
17
+ - タスク完了(UPDATE)操作が正常に機能すること ✓(ただし FT17-F2 の制約あり)
18
+ - `transactional()` 内の `IntegrityError` が `DatabaseIntegrityException` に変換されること ✓
19
+
20
+ ## 摩擦点
21
+
22
+ ### FT17-F1 (HIGH, バグ): SqlAlchemyQueryExecutor.write() が IntegrityError をキャッチしない
23
+
24
+ `_BoundQueryExecutor.write()`(`transactional()` 内部で使われる)は FT16 で `IntegrityError` を
25
+ `DatabaseIntegrityException` に変換するよう修正されたが、
26
+ `SqlAlchemyQueryExecutor.write()`(直接呼び出し)は対応していない。
27
+
28
+ 同じ「INSERT が重複するケース」でも、使う API によって異なる例外型が飛ぶ:
29
+
30
+ ```python
31
+ # 直接 write() → IntegrityError (SQLAlchemy)
32
+ executor.write("INSERT INTO categories (name) VALUES (:name)", {"name": "dup"})
33
+
34
+ # transactional() 内 write() → DatabaseIntegrityException (nene2)
35
+ tx_manager.transactional(lambda ex: ex.write("INSERT INTO categories (name) VALUES (:name)", {"name": "dup"}))
36
+ ```
37
+
38
+ UseCase 層でどちらの API を使うかによって `except` 節を変える必要があり、一貫性がない。
39
+
40
+ **対応**: `SqlAlchemyQueryExecutor.write()` でも `IntegrityError` をキャッチして
41
+ `DatabaseIntegrityException` にラップする。
42
+
43
+ ### FT17-F2 (HIGH, バグ): write() が UPDATE/DELETE で 0 行影響した場合に誤った値を返す
44
+
45
+ `write()` は `result.lastrowid or result.rowcount` を返すが、SQLite では UPDATE/DELETE の後も
46
+ `cursor.lastrowid` が前の INSERT の rowid を保持する。
47
+
48
+ ```python
49
+ # DB に id=1 の行がある状態で
50
+ result = executor.write("UPDATE items SET name = 'x' WHERE id = 999") # 0行影響
51
+ # result == 1 (前のINSERTのlastrowid) — 期待値は 0
52
+ ```
53
+
54
+ `if affected == 0: raise NotFound` のパターンが正しく動かない。
55
+
56
+ **対応**: SQL が `INSERT` で始まる場合のみ `lastrowid` を使い、UPDATE/DELETE は `rowcount` を返す。
57
+
58
+ ## まとめ
59
+
60
+ ドメイン間連携(JOIN クエリ、カテゴリフィルタリング)は問題なく動作した。
61
+ ただし `SqlAlchemyQueryExecutor.write()` に HIGH レベルのバグが2件あり:
62
+
63
+ - FT17-F1: `IntegrityError` が `DatabaseIntegrityException` に変換されない(API 非対称)
64
+ - FT17-F2: UPDATE/DELETE で 0 行影響した場合の戻り値が不正
65
+
66
+ 両方とも `_BoundQueryExecutor.write()` には修正済みだが、`SqlAlchemyQueryExecutor.write()` に未適用。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nene2-python"
3
- version = "1.5.0"
3
+ version = "1.7.0"
4
4
  description = "NENE2 Python — minimal API framework following NENE2's design philosophy"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  """NENE2 database abstraction layer."""
2
2
 
3
- from .exceptions import DatabaseConnectionException
3
+ from .exceptions import DatabaseConnectionException, DatabaseIntegrityException
4
4
  from .health import DatabaseHealthCheck
5
5
  from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
6
6
  from .sqlalchemy_executor import SqlAlchemyQueryExecutor, SqlAlchemyTransactionManager
@@ -8,6 +8,7 @@ from .utils import parse_db_datetime
8
8
 
9
9
  __all__ = [
10
10
  "DatabaseConnectionException",
11
+ "DatabaseIntegrityException",
11
12
  "DatabaseHealthCheck",
12
13
  "DatabaseQueryExecutorInterface",
13
14
  "DatabaseTransactionManagerInterface",
@@ -3,3 +3,7 @@
3
3
 
4
4
  class DatabaseConnectionException(Exception):
5
5
  """Raised when a database connection cannot be established."""
6
+
7
+
8
+ class DatabaseIntegrityException(Exception):
9
+ """Raised when a database integrity constraint is violated (UNIQUE, FK, CHECK, etc.)."""
@@ -7,9 +7,9 @@ from collections.abc import Callable
7
7
  from typing import Any
8
8
 
9
9
  from sqlalchemy import Connection, Engine, text
10
- from sqlalchemy.exc import OperationalError
10
+ from sqlalchemy.exc import IntegrityError, OperationalError
11
11
 
12
- from .exceptions import DatabaseConnectionException
12
+ from .exceptions import DatabaseConnectionException, DatabaseIntegrityException
13
13
  from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
14
14
 
15
15
 
@@ -41,7 +41,7 @@ class SqlAlchemyQueryExecutor(DatabaseQueryExecutorInterface):
41
41
 
42
42
  Return value semantics:
43
43
  - INSERT with AUTOINCREMENT/SERIAL column → ``lastrowid`` (the new row's PK, always > 0)
44
- - INSERT without auto-PK, or multi-row INSERT → falls back to ``rowcount``
44
+ - INSERT without auto-PK, or multi-row INSERT → ``rowcount``
45
45
  - UPDATE / DELETE → ``rowcount`` (number of rows affected; 0 means nothing matched)
46
46
 
47
47
  Use the return value to detect missing rows::
@@ -53,7 +53,11 @@ class SqlAlchemyQueryExecutor(DatabaseQueryExecutorInterface):
53
53
  try:
54
54
  with self._engine.begin() as conn:
55
55
  result = conn.execute(text(sql), params or {})
56
- return result.lastrowid or result.rowcount
56
+ if sql.strip().upper().startswith("INSERT"):
57
+ return result.lastrowid or result.rowcount
58
+ return result.rowcount
59
+ except IntegrityError as exc:
60
+ raise DatabaseIntegrityException(str(exc)) from exc
57
61
  except OperationalError as exc:
58
62
  raise DatabaseConnectionException(str(exc)) from exc
59
63
 
@@ -82,9 +86,13 @@ class _BoundQueryExecutor(DatabaseQueryExecutorInterface):
82
86
  def write(self, sql: str, params: dict[str, Any] | None = None) -> int:
83
87
  try:
84
88
  result = self._conn.execute(text(sql), params or {})
89
+ except IntegrityError as exc:
90
+ raise DatabaseIntegrityException(str(exc)) from exc
85
91
  except OperationalError as exc:
86
92
  raise DatabaseConnectionException(str(exc)) from exc
87
- return result.lastrowid or result.rowcount
93
+ if sql.strip().upper().startswith("INSERT"):
94
+ return result.lastrowid or result.rowcount
95
+ return result.rowcount
88
96
 
89
97
 
90
98
  class SqlAlchemyTransactionManager(DatabaseTransactionManagerInterface):
@@ -104,6 +112,10 @@ class SqlAlchemyTransactionManager(DatabaseTransactionManagerInterface):
104
112
  try:
105
113
  with self._engine.begin() as conn:
106
114
  return callback(_BoundQueryExecutor(conn))
115
+ except DatabaseIntegrityException:
116
+ raise
117
+ except IntegrityError as exc:
118
+ raise DatabaseIntegrityException(str(exc)) from exc
107
119
  except OperationalError as exc:
108
120
  raise DatabaseConnectionException(str(exc)) from exc
109
121
 
@@ -9,15 +9,16 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
9
9
  from starlette.requests import Request
10
10
  from starlette.responses import Response
11
11
 
12
- _HEADERS: dict[str, str] = {
12
+ _DEFAULT_CSP = "default-src 'self'"
13
+
14
+ _NON_CSP_HEADERS: dict[str, str] = {
13
15
  "X-Content-Type-Options": "nosniff",
14
16
  "X-Frame-Options": "DENY",
15
17
  "Referrer-Policy": "strict-origin-when-cross-origin",
16
- "Content-Security-Policy": "default-src 'self'",
17
18
  "Permissions-Policy": "geolocation=(), microphone=()",
18
19
  }
19
20
 
20
- _OPENAPI_PATHS: frozenset[str] = frozenset({"/docs", "/redoc", "/openapi.json"})
21
+ _DEFAULT_NO_CSP_PATHS: frozenset[str] = frozenset({"/docs", "/redoc", "/openapi.json"})
21
22
 
22
23
 
23
24
  class SecurityHeadersMiddleware(BaseHTTPMiddleware):
@@ -25,13 +26,29 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
25
26
 
26
27
  Content-Security-Policy is omitted for OpenAPI documentation paths so that
27
28
  Swagger UI and ReDoc (which load assets from CDN) continue to work in development.
29
+
30
+ Args:
31
+ csp: Custom Content-Security-Policy header value.
32
+ Defaults to ``"default-src 'self'"`` when not specified.
33
+ extra_no_csp_paths: Additional paths to skip the CSP header for.
34
+ Useful when FastAPI is configured with custom ``docs_url`` / ``redoc_url``.
35
+ The built-in paths ``/docs``, ``/redoc``, and ``/openapi.json`` are always included.
28
36
  """
29
37
 
38
+ def __init__(
39
+ self,
40
+ app: object,
41
+ csp: str | None = None,
42
+ extra_no_csp_paths: list[str] | None = None,
43
+ ) -> None:
44
+ super().__init__(app) # type: ignore[arg-type]
45
+ self._csp = csp if csp is not None else _DEFAULT_CSP
46
+ self._no_csp_paths = _DEFAULT_NO_CSP_PATHS | frozenset(extra_no_csp_paths or [])
47
+
30
48
  async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
31
49
  response = await call_next(request)
32
- is_openapi_path = request.url.path in _OPENAPI_PATHS
33
- for header, value in _HEADERS.items():
34
- if is_openapi_path and header == "Content-Security-Policy":
35
- continue
50
+ for header, value in _NON_CSP_HEADERS.items():
36
51
  response.headers[header] = value
52
+ if request.url.path not in self._no_csp_paths:
53
+ response.headers["Content-Security-Policy"] = self._csp
37
54
  return response
@@ -0,0 +1,51 @@
1
+ """Structural type contracts for UseCase and AsyncUseCase.
2
+
3
+ UseCaseProtocol — synchronous execute(input_) -> output
4
+ AsyncUseCaseProtocol — async execute(input_) -> output (awaitable)
5
+
6
+ Both use Python 3.12 generic syntax; any class with a matching
7
+ execute() signature satisfies them structurally (no inheritance needed).
8
+
9
+ Runtime isinstance() limitation
10
+ ---------------------------------
11
+ Both protocols are @runtime_checkable, but Python's isinstance() only
12
+ checks that the ``execute`` attribute exists — it does NOT distinguish
13
+ between sync and async implementations. As a result:
14
+
15
+ isinstance(sync_obj, AsyncUseCaseProtocol) # → True (false positive)
16
+ isinstance(async_obj, UseCaseProtocol) # → True (false positive)
17
+
18
+ Static type safety (sync vs async) is guaranteed by mypy --strict, not
19
+ by isinstance() at runtime. If you need a runtime async check, use:
20
+
21
+ import inspect
22
+ inspect.iscoroutinefunction(obj.execute)
23
+
24
+ See ADR-0010 for the full rationale.
25
+ """
26
+
27
+ from typing import Protocol, runtime_checkable
28
+
29
+
30
+ @runtime_checkable
31
+ class UseCaseProtocol[I, O](Protocol):
32
+ """Synchronous use-case contract.
33
+
34
+ Warning: isinstance() only checks that ``execute`` exists, not whether
35
+ it is synchronous. Use mypy --strict for compile-time enforcement, or
36
+ ``not inspect.iscoroutinefunction(obj.execute)`` for a runtime check.
37
+ """
38
+
39
+ def execute(self, input_: I) -> O: ...
40
+
41
+
42
+ @runtime_checkable
43
+ class AsyncUseCaseProtocol[I, O](Protocol):
44
+ """Asynchronous use-case contract — execute must be a coroutine.
45
+
46
+ Warning: isinstance() only checks that ``execute`` exists, not whether
47
+ it is async. Use mypy --strict for compile-time enforcement, or
48
+ ``inspect.iscoroutinefunction(obj.execute)`` for a runtime check.
49
+ """
50
+
51
+ async def execute(self, input_: I) -> O: ...
@@ -16,8 +16,9 @@ class ValidationError:
16
16
  code: str
17
17
 
18
18
  def __post_init__(self) -> None:
19
- if not self.field or not self.message or not self.code:
20
- raise ValueError("field, message, and code must be non-empty strings")
19
+ for attr in ("field", "message", "code"):
20
+ if not getattr(self, attr):
21
+ raise ValueError(f"ValidationError.{attr} must not be empty")
21
22
 
22
23
  def to_dict(self) -> dict[str, str]:
23
24
  return {"field": self.field, "message": self.message, "code": self.code}
@@ -27,8 +28,25 @@ class ValidationException(Exception):
27
28
  """Raised when one or more validation rules fail.
28
29
 
29
30
  ErrorHandlerMiddleware maps this to a 422 validation-failed Problem Details response.
31
+
32
+ For a single error, use the convenience method::
33
+
34
+ raise ValidationException.single("email", "invalid", "invalid_email")
35
+
36
+ For multiple errors accumulated during validation::
37
+
38
+ errors = []
39
+ if not valid_email:
40
+ errors.append(ValidationError("email", "invalid", "invalid_email"))
41
+ if errors:
42
+ raise ValidationException(errors)
30
43
  """
31
44
 
32
45
  def __init__(self, errors: list[ValidationError]) -> None:
33
46
  super().__init__("Validation failed")
34
47
  self.errors = errors
48
+
49
+ @classmethod
50
+ def single(cls, field: str, message: str, code: str) -> "ValidationException":
51
+ """Convenience constructor for a single validation error."""
52
+ return cls([ValidationError(field, message, code)])