nene2-python 1.2.0__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. {nene2_python-1.2.0 → nene2_python-1.4.0}/PKG-INFO +1 -1
  2. nene2_python-1.4.0/docs/field-trials/2026-05-field-trial-10.md +167 -0
  3. nene2_python-1.4.0/docs/field-trials/2026-05-field-trial-11.md +116 -0
  4. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/how-to/configure-auth.md +49 -0
  5. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/how-to/sqlalchemy-repository.md +78 -0
  6. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/reference/framework-modules.md +14 -0
  7. {nene2_python-1.2.0 → nene2_python-1.4.0}/pyproject.toml +1 -1
  8. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/auth/api_key.py +22 -3
  9. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/auth/bearer_token.py +21 -2
  10. nene2_python-1.4.0/src/nene2/auth/local_verifier.py +36 -0
  11. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/http/pagination.py +41 -4
  12. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/auth/test_api_key.py +21 -0
  13. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/auth/test_bearer_token.py +55 -0
  14. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/http/test_pagination.py +40 -1
  15. {nene2_python-1.2.0 → nene2_python-1.4.0}/uv.lock +1 -1
  16. nene2_python-1.2.0/src/nene2/auth/local_verifier.py +0 -17
  17. {nene2_python-1.2.0 → nene2_python-1.4.0}/.env.example +0 -0
  18. {nene2_python-1.2.0 → nene2_python-1.4.0}/.github/workflows/ci.yml +0 -0
  19. {nene2_python-1.2.0 → nene2_python-1.4.0}/.github/workflows/docs.yml +0 -0
  20. {nene2_python-1.2.0 → nene2_python-1.4.0}/.github/workflows/publish.yml +0 -0
  21. {nene2_python-1.2.0 → nene2_python-1.4.0}/.gitignore +0 -0
  22. {nene2_python-1.2.0 → nene2_python-1.4.0}/.vitepress/config.mts +0 -0
  23. {nene2_python-1.2.0 → nene2_python-1.4.0}/.vitepress/theme/custom.css +0 -0
  24. {nene2_python-1.2.0 → nene2_python-1.4.0}/.vitepress/theme/index.ts +0 -0
  25. {nene2_python-1.2.0 → nene2_python-1.4.0}/AGENTS.md +0 -0
  26. {nene2_python-1.2.0 → nene2_python-1.4.0}/CHANGELOG.md +0 -0
  27. {nene2_python-1.2.0 → nene2_python-1.4.0}/CLAUDE.md +0 -0
  28. {nene2_python-1.2.0 → nene2_python-1.4.0}/Dockerfile +0 -0
  29. {nene2_python-1.2.0 → nene2_python-1.4.0}/LICENSE +0 -0
  30. {nene2_python-1.2.0 → nene2_python-1.4.0}/README.md +0 -0
  31. {nene2_python-1.2.0 → nene2_python-1.4.0}/alembic/README +0 -0
  32. {nene2_python-1.2.0 → nene2_python-1.4.0}/alembic/env.py +0 -0
  33. {nene2_python-1.2.0 → nene2_python-1.4.0}/alembic/script.py.mako +0 -0
  34. {nene2_python-1.2.0 → nene2_python-1.4.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
  35. {nene2_python-1.2.0 → nene2_python-1.4.0}/alembic.ini +0 -0
  36. {nene2_python-1.2.0 → nene2_python-1.4.0}/compose.yaml +0 -0
  37. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0001-toolchain.md +0 -0
  38. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0002-clean-architecture.md +0 -0
  39. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0003-security-first.md +0 -0
  40. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0004-ai-first-design.md +0 -0
  41. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0005-logging.md +0 -0
  42. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0006-rate-limiting.md +0 -0
  43. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0009-mcp-design.md +0 -0
  44. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/adr/0010-async-use-case.md +0 -0
  45. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/de/index.md +0 -0
  46. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/de/tutorials/getting-started.md +0 -0
  47. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/explanation/architecture.md +0 -0
  48. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/explanation/design-philosophy.md +0 -0
  49. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
  50. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
  51. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
  52. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
  53. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
  54. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
  55. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-7.md +0 -0
  56. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-8.md +0 -0
  57. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/field-trials/2026-05-field-trial-9.md +0 -0
  58. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/fr/index.md +0 -0
  59. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/fr/tutorials/getting-started.md +0 -0
  60. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/how-to/add-new-domain.md +0 -0
  61. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/how-to/new-project.md +0 -0
  62. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/how-to/run-tests.md +0 -0
  63. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/howto/mcp-setup.md +0 -0
  64. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/index.md +0 -0
  65. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/explanation/architecture.md +0 -0
  66. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/explanation/design-philosophy.md +0 -0
  67. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/how-to/add-new-domain.md +0 -0
  68. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/how-to/configure-auth.md +0 -0
  69. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/how-to/new-project.md +0 -0
  70. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/how-to/run-tests.md +0 -0
  71. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
  72. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/howto/mcp-setup.md +0 -0
  73. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/index.md +0 -0
  74. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/reference/api.md +0 -0
  75. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/reference/configuration.md +0 -0
  76. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/reference/framework-modules.md +0 -0
  77. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/tutorials/first-domain.md +0 -0
  78. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/ja/tutorials/getting-started.md +0 -0
  79. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/pt-br/index.md +0 -0
  80. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/pt-br/tutorials/getting-started.md +0 -0
  81. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/reference/api.md +0 -0
  82. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/reference/configuration.md +0 -0
  83. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/roadmap.md +0 -0
  84. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/todo/current.md +0 -0
  85. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/tutorials/first-domain.md +0 -0
  86. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/tutorials/getting-started.md +0 -0
  87. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/zh/index.md +0 -0
  88. {nene2_python-1.2.0 → nene2_python-1.4.0}/docs/zh/tutorials/getting-started.md +0 -0
  89. {nene2_python-1.2.0 → nene2_python-1.4.0}/package-lock.json +0 -0
  90. {nene2_python-1.2.0 → nene2_python-1.4.0}/package.json +0 -0
  91. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/__init__.py +0 -0
  92. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/__main__.py +0 -0
  93. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/app.py +0 -0
  94. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/__init__.py +0 -0
  95. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/entity.py +0 -0
  96. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/exceptions.py +0 -0
  97. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/handler.py +0 -0
  98. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/repository.py +0 -0
  99. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/sqlalchemy_repository.py +0 -0
  100. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/comment/use_case.py +0 -0
  101. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/mcp.py +0 -0
  102. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/__init__.py +0 -0
  103. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/async_use_case.py +0 -0
  104. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/entity.py +0 -0
  105. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/exceptions.py +0 -0
  106. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/handler.py +0 -0
  107. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/repository.py +0 -0
  108. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/sqlalchemy_repository.py +0 -0
  109. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/note/use_case.py +0 -0
  110. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/schema.py +0 -0
  111. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/__init__.py +0 -0
  112. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/entity.py +0 -0
  113. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/exceptions.py +0 -0
  114. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/handler.py +0 -0
  115. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/repository.py +0 -0
  116. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/sqlalchemy_repository.py +0 -0
  117. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/example/tag/use_case.py +0 -0
  118. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/__init__.py +0 -0
  119. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/auth/__init__.py +0 -0
  120. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/auth/exceptions.py +0 -0
  121. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/auth/interfaces.py +0 -0
  122. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/config/__init__.py +0 -0
  123. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/config/settings.py +0 -0
  124. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/database/__init__.py +0 -0
  125. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/database/exceptions.py +0 -0
  126. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/database/health.py +0 -0
  127. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/database/interfaces.py +0 -0
  128. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/database/sqlalchemy_executor.py +0 -0
  129. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/database/utils.py +0 -0
  130. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/http/__init__.py +0 -0
  131. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/http/health.py +0 -0
  132. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/http/problem_details.py +0 -0
  133. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/log/__init__.py +0 -0
  134. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/log/setup.py +0 -0
  135. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/mcp/__init__.py +0 -0
  136. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/mcp/http_client.py +0 -0
  137. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/mcp/server.py +0 -0
  138. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/__init__.py +0 -0
  139. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/domain_exception.py +0 -0
  140. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/error_handler.py +0 -0
  141. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/request_id.py +0 -0
  142. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/request_logging.py +0 -0
  143. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/request_size_limit.py +0 -0
  144. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/security_headers.py +0 -0
  145. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/middleware/throttle.py +0 -0
  146. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/py.typed +0 -0
  147. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/use_case/__init__.py +0 -0
  148. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/use_case/protocols.py +0 -0
  149. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/validation/__init__.py +0 -0
  150. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/nene2/validation/exceptions.py +0 -0
  151. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/scripts/__init__.py +0 -0
  152. {nene2_python-1.2.0 → nene2_python-1.4.0}/src/scripts/export_openapi.py +0 -0
  153. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/__init__.py +0 -0
  154. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/__init__.py +0 -0
  155. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/comment/__init__.py +0 -0
  156. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/comment/test_comment_http.py +0 -0
  157. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/comment/test_comment_repository.py +0 -0
  158. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/comment/test_comment_use_case.py +0 -0
  159. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/conftest.py +0 -0
  160. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/note/__init__.py +0 -0
  161. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/note/test_async_note_use_case.py +0 -0
  162. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/note/test_list_notes.py +0 -0
  163. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/note/test_note_repository.py +0 -0
  164. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/tag/__init__.py +0 -0
  165. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/tag/test_tag_repository.py +0 -0
  166. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/tag/test_tags.py +0 -0
  167. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/test_cors.py +0 -0
  168. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/example/test_mcp.py +0 -0
  169. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/__init__.py +0 -0
  170. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/auth/__init__.py +0 -0
  171. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/auth/test_token_issuer.py +0 -0
  172. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/database/__init__.py +0 -0
  173. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/database/test_transaction.py +0 -0
  174. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/database/test_utils.py +0 -0
  175. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/http/__init__.py +0 -0
  176. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/mcp/__init__.py +0 -0
  177. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/mcp/test_http_client.py +0 -0
  178. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/__init__.py +0 -0
  179. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_error_handler.py +0 -0
  180. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_request_id.py +0 -0
  181. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_request_logging.py +0 -0
  182. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
  183. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_security_headers.py +0 -0
  184. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/middleware/test_throttle.py +0 -0
  185. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/use_case/__init__.py +0 -0
  186. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/use_case/test_protocols.py +0 -0
  187. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/validation/__init__.py +0 -0
  188. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/nene2/validation/test_exceptions.py +0 -0
  189. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/scripts/__init__.py +0 -0
  190. {nene2_python-1.2.0 → nene2_python-1.4.0}/tests/scripts/test_export_openapi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nene2-python
3
- Version: 1.2.0
3
+ Version: 1.4.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,167 @@
1
+ # Field Trial 10 — inventory: MySQL アダプター DX 検証
2
+
3
+ ## Date
4
+
5
+ 2026-05-20
6
+
7
+ ## Baseline
8
+
9
+ - nene2-python v1.2.0(PyPI 経由)
10
+ - Python 3.14(uv managed)
11
+ - プロジェクト: **inventory** — 在庫管理 API
12
+ - エンティティ: `Product(id, name, price, stock, created_at)`
13
+ - HTTP API: ポート 8110(FastAPI + MySQL 8)
14
+ - DB: MySQL 8.0(Docker Compose)
15
+
16
+ ## Goal
17
+
18
+ FT1〜FT9 はすべて SQLite を使用。MySQL 8 で初めて以下を検証:
19
+
20
+ 1. Docker Compose で MySQL 8 を立ち上げる手順
21
+ 2. `SqlAlchemyQueryExecutor` / `SqlAlchemyTransactionManager` の MySQL 上での動作
22
+ 3. `parse_db_datetime()` が MySQL の `datetime` オブジェクトを正しく処理するか
23
+ 4. SQLite との挙動差(`CURRENT_TIMESTAMP` 型、`RETURNING` 句)
24
+
25
+ ---
26
+
27
+ ## Steps Taken
28
+
29
+ ### 1. Docker Compose で MySQL 8 を起動
30
+
31
+ ```yaml
32
+ services:
33
+ mysql:
34
+ image: mysql:8.0
35
+ environment:
36
+ MYSQL_ROOT_PASSWORD: rootpass
37
+ MYSQL_DATABASE: inventory
38
+ MYSQL_USER: appuser
39
+ MYSQL_PASSWORD: apppass
40
+ ports:
41
+ - "3310:3306"
42
+ healthcheck:
43
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uappuser", "-papppass"]
44
+ interval: 5s
45
+ timeout: 5s
46
+ retries: 10
47
+ ```
48
+
49
+ `depends_on: condition: service_healthy` でアプリが MySQL の起動完了後に起動する構成。
50
+
51
+ ### 2. 接続文字列と依存
52
+
53
+ ```python
54
+ url = f"mysql+pymysql://{user}:{password}@{host}:{port}/{name}"
55
+ engine = create_engine(url, pool_pre_ping=True)
56
+ ```
57
+
58
+ `pymysql` + `cryptography` を追加(MySQL 8 の認証プラグイン `caching_sha2_password` 対応に必要)。
59
+
60
+ ### 3. `CURRENT_TIMESTAMP` の型差異の確認
61
+
62
+ | DB | `CURRENT_TIMESTAMP` の Python 型 | `parse_db_datetime()` の結果 |
63
+ |----|----------------------------------|------------------------------|
64
+ | SQLite | `str` (`"2026-05-20 12:34:56"`) | ✓ UTC-aware datetime |
65
+ | MySQL | `datetime` (naive, server timezone) | ✓ UTC-aware datetime |
66
+
67
+ MySQL は `datetime` オブジェクトを返すが naive(tzinfo なし)。
68
+ `parse_db_datetime()` が `isinstance(value, datetime)` で分岐して `.replace(tzinfo=UTC)` を付与するため、
69
+ SQLite と同一の出力が得られることを確認。
70
+
71
+ ### 4. SELECT-after-INSERT の動作確認
72
+
73
+ `RETURNING` 句は MySQL 8.0 でサポートされていない(PostgreSQL にはある)。
74
+ FT8 で採用した SELECT-after-INSERT パターンがそのまま動作:
75
+
76
+ ```python
77
+ new_id = executor.write("INSERT INTO products ...", {...})
78
+ row = executor.fetch_one("SELECT ... WHERE id = :id", {"id": new_id})
79
+ ```
80
+
81
+ `executor.write()` の戻り値 (`lastrowid`) が MySQL でも正しく返ることを確認。
82
+
83
+ ---
84
+
85
+ ## Friction Points
86
+
87
+ ### FT10-1: `cryptography` が暗黙の依存として必要(ドキュメントなし)
88
+
89
+ - **摩擦**: `pymysql` を追加しただけでは MySQL 8 の `caching_sha2_password` 認証が失敗する
90
+ ```
91
+ OperationalError: Authentication plugin 'caching_sha2_password' is not supported
92
+ ```
93
+ - `cryptography` パッケージを別途追加する必要があるが、エラーメッセージが分かりにくい
94
+ - **深刻度**: MEDIUM(初見では原因が pymysql の依存問題と気づきにくい)
95
+ - **解決策**: how-to ガイドに `cryptography` を必須依存として明記(ドキュメント対応)
96
+
97
+ ### FT10-2: `PaginationResponse[T].to_dict()` が items を直列化しない
98
+
99
+ - **摩擦**: `PaginationResponse[ProductOutput].to_dict()` の `items` フィールドが
100
+ `ProductOutput` dataclass インスタンスのまま返る → `json.dumps` が失敗
101
+ ```
102
+ TypeError: Object of type ProductOutput is not JSON serializable
103
+ ```
104
+ - `slots=True` の dataclass は `__dict__` を持たないため `JSONResponse(result.__dict__)` も使えない
105
+ - **回避策**: ハンドラーで `[dataclasses.asdict(item) for item in result.items]` に変換
106
+ ```python
107
+ return JSONResponse({
108
+ "items": [dataclasses.asdict(item) for item in result.items],
109
+ "total": result.total,
110
+ "limit": result.limit,
111
+ "offset": result.offset,
112
+ })
113
+ ```
114
+ - **深刻度**: HIGH(LIST エンドポイントを書くたびに同じ回避策が必要)
115
+ - **解決策候補**: `PaginationResponse.to_dict()` が dataclass items を自動変換する、
116
+ または `to_json_dict()` メソッドを追加する
117
+
118
+ ### FT10-3: `ValidationError` の `code` 引数がドキュメントに不足
119
+
120
+ - **摩擦**: `ValidationError("field", "message")` → `TypeError: missing 1 required argument: 'code'`
121
+ - FT1〜FT9 の既存プロジェクトのコードを参考に 2 引数で書くと失敗する
122
+ - **深刻度**: MEDIUM(他のFTのコードがすべて3引数になっていれば問題ない)
123
+ - **解決策**: how-to ガイドのサンプルコードに `code` 引数を必ず含める
124
+
125
+ ### FT10-4: `DatabaseHealthCheck(executor)` vs `DatabaseHealthCheck(engine)` が紛らわしい
126
+
127
+ - **摩擦**: `DatabaseHealthCheck` のコンストラクタは `executor` を受け取るが、
128
+ `create_engine()` の直後に書くと `engine` を渡してしまいやすい
129
+ ```python
130
+ engine = create_engine(url)
131
+ executor = SqlAlchemyQueryExecutor(engine)
132
+ # 間違い: DatabaseHealthCheck(engine) ← AttributeError: 'Engine' has no 'fetch_one'
133
+ # 正しい: DatabaseHealthCheck(executor)
134
+ ```
135
+ - **深刻度**: LOW(エラーメッセージから原因は特定できる)
136
+ - **解決策**: ドキュメントの使用例を正確に記述する
137
+
138
+ ### FT10-5: `PaginationQueryParser` を FastAPI のパラメータ型として使えない
139
+
140
+ - **摩擦**: `def list_products(pagination: PaginationQueryParser)` と書くと
141
+ `FastAPIError: Invalid args for response field` が発生
142
+ - `PaginationQueryParser` は Pydantic モデルではないため、FastAPI の Depends として使えない
143
+ - **回避策**: `request: Request` を受け取り `PaginationQueryParser.parse(request)` を呼ぶ
144
+ - **深刻度**: MEDIUM(直感的な書き方が失敗する)
145
+ - **解決策候補**: `PaginationQueryParser` を `Depends` 互換にする、または
146
+ `Annotated[PaginationQueryParser, Depends(PaginationQueryParser.parse)]` パターンを提供
147
+
148
+ ---
149
+
150
+ ## Summary
151
+
152
+ | ID | 摩擦 | 深刻度 | 解決策 |
153
+ |---------|-------------------------------------------------------|--------|-----------------------------------------------------------|
154
+ | FT10-1 | `cryptography` が暗黙依存(MySQL 8 認証失敗) | MEDIUM | how-to ガイドに記載 |
155
+ | FT10-2 | `PaginationResponse.to_dict()` が items を直列化しない | HIGH | `to_dict()` に dataclass 自動変換を追加 |
156
+ | FT10-3 | `ValidationError` の `code` 引数がドキュメント不足 | MEDIUM | how-to サンプルコードを修正 |
157
+ | FT10-4 | `DatabaseHealthCheck(executor)` vs `engine)` が紛らわしい | LOW | ドキュメント修正 |
158
+ | FT10-5 | `PaginationQueryParser` が FastAPI パラメータ型不可 | MEDIUM | `Depends` 互換パターンを提供 |
159
+
160
+ **MySQL 固有の問題は FT10-1 のみ**。他は SQLite でも同様に起きる問題(FT1〜9 でたまたま発覚しなかった)。
161
+ `parse_db_datetime()` は MySQL の naive datetime を期待通り UTC-aware に変換できた。
162
+
163
+ FT11 候補:
164
+ - **FT10-2 の修正**: `PaginationResponse.to_dict()` dataclass 自動変換
165
+ - **FT10-5 の修正**: `PaginationQueryParser` を `Depends` 互換に
166
+ - **PostgreSQL アダプター**: `RETURNING` 句が使えるかを検証
167
+ - **`BearerTokenMiddleware` + `HttpxMcpClient`**: 認証付き API を MCP から呼ぶ
@@ -0,0 +1,116 @@
1
+ # Field Trial 11 — journal: BearerTokenMiddleware + HttpxMcpClient DX 検証
2
+
3
+ ## Date
4
+
5
+ 2026-05-20
6
+
7
+ ## Baseline
8
+
9
+ - nene2-python v1.3.0(PyPI 経由)
10
+ - Python 3.14(uv managed)
11
+ - プロジェクト: **journal** — 日記管理 API
12
+ - エンティティ: `Entry(id, title, body, created_at)`
13
+ - HTTP API: ポート 8120(BearerTokenMiddleware 付き、InMemory)
14
+ - MCP サーバー: ポート 8121(streamable-http)
15
+
16
+ ## Goal
17
+
18
+ FT9 で `HttpxMcpClient` の基本(認証なし)は確認済み。
19
+ 今回は `BearerTokenMiddleware` で保護した HTTP API に対して MCP ツールが認証付きリクエストを送るパターンを検証する。
20
+
21
+ ---
22
+
23
+ ## Steps Taken
24
+
25
+ ### 1. BearerTokenMiddleware で HTTP API を保護
26
+
27
+ ```python
28
+ tokens = [t.strip() for t in os.getenv("BEARER_TOKENS", "").split(",") if t.strip()]
29
+ if tokens:
30
+ app.add_middleware(BearerTokenMiddleware, verifier=LocalTokenVerifier(tokens))
31
+ ```
32
+
33
+ 環境変数 `BEARER_TOKENS=secret-token-1,secret-token-2` でカンマ区切りの複数トークンを設定。
34
+
35
+ ### 2. MCP サーバーから認証付きで呼び出す
36
+
37
+ ```python
38
+ token = os.getenv("MCP_BEARER_TOKEN", "")
39
+ client = HttpxMcpClient(token if token else None)
40
+
41
+ @server.tool("List all journal entries.")
42
+ def list_entries(limit: int = 20, offset: int = 0) -> str:
43
+ response = client.get(API_BASE, f"/entries?limit={limit}&offset={offset}")
44
+ response.raise_for_error()
45
+ return response.body
46
+ ```
47
+
48
+ `MCP_BEARER_TOKEN=secret-token-1` を MCP サーバーの環境変数に設定。
49
+ `HttpxMcpClient(token)` が `Authorization: Bearer <token>` ヘッダーを自動付与。
50
+
51
+ ### 3. 動作確認
52
+
53
+ ```
54
+ # MCP → 認証付き API → 成功
55
+ create_entry("First Entry", "Hello from MCP!") → {"id":1,...}
56
+ list_entries() → {"items":[{"id":1,...}],...}
57
+
58
+ # 誤トークン → 401 → raise_for_error() → isError: true
59
+ HTTP 401: {"type":"...unauthorized","detail":"The provided token is invalid or expired."}
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Friction Points
65
+
66
+ ### FT11-1: `BearerTokenMiddleware` が `/docs`・`/openapi.json` まで保護する
67
+
68
+ - **摩擦**: ミドルウェアがすべてのパスに適用されるため、
69
+ FastAPI の Swagger UI (`/docs`) や OpenAPI スキーマ (`/openapi.json`) にアクセスできなくなる
70
+ ```
71
+ GET /docs → 401 Unauthorized
72
+ GET /openapi.json → 401 Unauthorized
73
+ ```
74
+ - ロードバランサーの `/health` チェックも同様にブロックされる
75
+ - **現状の回避策**: 開発時は `BEARER_TOKENS=` を空にして認証を無効化
76
+ - **深刻度**: HIGH(開発・運用の両方で問題になる。特にヘルスチェックのブロックは本番障害につながる)
77
+ - **解決策**: `BearerTokenMiddleware(app, verifier=..., exclude_paths=["/docs", "/openapi.json", "/health"])` で除外パスを指定できるようにする
78
+
79
+ ### FT11-2: `MCP_BEARER_TOKEN` 未設定時に分かりにくい 401 エラー
80
+
81
+ - **摩擦**: MCP サーバー起動時に `MCP_BEARER_TOKEN` が未設定の場合、
82
+ `HttpxMcpClient(None)` になって認証ヘッダーなしで API を呼ぶ
83
+ - MCP ツール呼び出しが `HTTP 401` で失敗するが、エラーメッセージからトークン未設定が原因と気づきにくい
84
+ ```
85
+ McpHttpError: HTTP 401: {"detail":"A valid Bearer token is required."}
86
+ ```
87
+ - **深刻度**: LOW(エラーメッセージを読めば原因はわかる)
88
+ - **解決策**: `mcp_server.py` でトークン未設定時に起動時警告を出す(フレームワーク対応不要、パターン文書化)
89
+
90
+ ### FT11-3: `LocalTokenVerifier` がカンマ区切り文字列の分割を要求する
91
+
92
+ - **摩擦**: 複数トークンを環境変数で渡す標準パターンがなく、アプリ側でカンマ分割処理を書く必要がある
93
+ ```python
94
+ tokens = [t.strip() for t in os.getenv("BEARER_TOKENS", "").split(",") if t.strip()]
95
+ ```
96
+ - FT3 でも同様のコードを書いていた(重複パターン)
97
+ - **深刻度**: LOW(数行のコードだが、毎回書く必要がある)
98
+ - **解決策**: `LocalTokenVerifier.from_env("BEARER_TOKENS")` クラスメソッドを追加
99
+
100
+ ---
101
+
102
+ ## Summary
103
+
104
+ | ID | 摩擦 | 深刻度 | 解決策 |
105
+ |--------|----------------------------------------------------------|--------|-------------------------------------------------|
106
+ | FT11-1 | `BearerTokenMiddleware` が `/docs`・`/health` もブロック | HIGH | `exclude_paths` 引数を追加 |
107
+ | FT11-2 | `MCP_BEARER_TOKEN` 未設定時の 401 が原因不明に見える | LOW | 起動時警告パターンを文書化 |
108
+ | FT11-3 | `LocalTokenVerifier` の複数トークン設定にボイラープレート | LOW | `LocalTokenVerifier.from_env()` クラスメソッド |
109
+
110
+ **`HttpxMcpClient(bearer_token)` + `raise_for_error()` の組み合わせは期待通りに動作した。**
111
+ 認証エラー(401)は `McpHttpError` として raise され、MCP の `isError: true` に正しく変換される。
112
+
113
+ FT12 候補:
114
+ - **FT11-1 の修正**: `BearerTokenMiddleware` に `exclude_paths` を追加
115
+ - **PostgreSQL アダプター**: `RETURNING` 句が使えるかを検証
116
+ - **SSE トランスポート**: `streamable-http` との差異を確認
@@ -81,6 +81,55 @@ class JwtTokenVerifier:
81
81
 
82
82
  Pass your verifier directly to `BearerTokenMiddleware`.
83
83
 
84
+ ## Loading tokens from environment variables
85
+
86
+ Use `LocalTokenVerifier.from_env()` to avoid writing the split-and-strip boilerplate every time:
87
+
88
+ ```dotenv
89
+ # .env
90
+ BEARER_TOKENS=token-a,token-b,token-c
91
+ ```
92
+
93
+ ```python
94
+ from nene2.auth import BearerTokenMiddleware, LocalTokenVerifier
95
+
96
+ app.add_middleware(
97
+ BearerTokenMiddleware,
98
+ verifier=LocalTokenVerifier.from_env("BEARER_TOKENS"),
99
+ )
100
+ ```
101
+
102
+ An unset or empty variable produces an empty allowlist — all requests are denied.
103
+
104
+ ## Excluding paths from authentication
105
+
106
+ Use `exclude_paths` to bypass auth for health checks and API docs:
107
+
108
+ ```python
109
+ app.add_middleware(
110
+ BearerTokenMiddleware,
111
+ verifier=LocalTokenVerifier.from_env("BEARER_TOKENS"),
112
+ exclude_paths=["/docs", "/openapi.json", "/redoc", "/health"],
113
+ )
114
+ ```
115
+
116
+ `ApiKeyAuthMiddleware` supports the same parameter.
117
+
118
+ ## MCP server — fail fast on missing token
119
+
120
+ When an MCP server calls a Bearer-protected API via `HttpxMcpClient`, validate the token
121
+ at startup rather than discovering a missing token at call time:
122
+
123
+ ```python
124
+ import os
125
+ from nene2.mcp.http_client import HttpxMcpClient
126
+
127
+ token = os.getenv("MCP_BEARER_TOKEN")
128
+ if not token:
129
+ raise RuntimeError("MCP_BEARER_TOKEN is not set — cannot call the authenticated API.")
130
+ client = HttpxMcpClient(token)
131
+ ```
132
+
84
133
  ## Custom TokenIssuer (e.g. JWT)
85
134
 
86
135
  Implement `TokenIssuerProtocol` to issue tokens (e.g. for a login endpoint).
@@ -500,3 +500,81 @@ class InMemoryTransactionManager(DatabaseTransactionManagerInterface):
500
500
  ```
501
501
 
502
502
  > **Rollback on exception**: `SqlAlchemyTransactionManager.transactional()` uses `engine.begin()` — any exception inside the callback triggers an automatic rollback. Domain exceptions (`AccountNotFoundException`, etc.) propagate normally after rollback.
503
+
504
+ ---
505
+
506
+ ## 6. Using MySQL 8
507
+
508
+ ### Required packages
509
+
510
+ MySQL 8 uses `caching_sha2_password` authentication by default.
511
+ Install **both** `pymysql` and `cryptography` — without `cryptography` the connection fails with
512
+ `Authentication plugin 'caching_sha2_password' is not supported`.
513
+
514
+ ```bash
515
+ uv add pymysql cryptography
516
+ ```
517
+
518
+ ### Connection URL
519
+
520
+ ```python
521
+ url = f"mysql+pymysql://{user}:{password}@{host}:{port}/{name}"
522
+ engine = create_engine(url, pool_pre_ping=True)
523
+ ```
524
+
525
+ `pool_pre_ping=True` is recommended for MySQL — it tests the connection before use to handle
526
+ stale connections after the server's `wait_timeout`.
527
+
528
+ ### Health check
529
+
530
+ `DatabaseHealthCheck` takes a **`SqlAlchemyQueryExecutor`**, not the engine directly:
531
+
532
+ ```python
533
+ from nene2.database import DatabaseHealthCheck, SqlAlchemyQueryExecutor
534
+
535
+ executor = SqlAlchemyQueryExecutor(engine)
536
+ health = DatabaseHealthCheck(executor) # ← executor, not engine
537
+
538
+ app.add_api_route("/health", health.check, methods=["GET"])
539
+ ```
540
+
541
+ ### `CURRENT_TIMESTAMP` type difference
542
+
543
+ SQLite returns `CURRENT_TIMESTAMP` as a `str`; MySQL returns a naive `datetime` object.
544
+ Use `parse_db_datetime()` in `_to_entity()` to handle both transparently:
545
+
546
+ ```python
547
+ from nene2.database import parse_db_datetime
548
+
549
+ def _to_entity(row: dict[str, Any]) -> Product:
550
+ return Product(
551
+ id=int(row["id"]),
552
+ created_at=parse_db_datetime(row["created_at"]), # works for both SQLite and MySQL
553
+ )
554
+ ```
555
+
556
+ ### Docker Compose example
557
+
558
+ ```yaml
559
+ services:
560
+ mysql:
561
+ image: mysql:8.0
562
+ environment:
563
+ MYSQL_ROOT_PASSWORD: rootpass
564
+ MYSQL_DATABASE: mydb
565
+ MYSQL_USER: appuser
566
+ MYSQL_PASSWORD: apppass
567
+ ports:
568
+ - "3310:3306"
569
+ healthcheck:
570
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uappuser", "-papppass"]
571
+ interval: 5s
572
+ timeout: 5s
573
+ retries: 10
574
+
575
+ app:
576
+ build: .
577
+ depends_on:
578
+ mysql:
579
+ condition: service_healthy
580
+ ```
@@ -10,6 +10,20 @@ Public API of the `nene2` package.
10
10
 
11
11
  Parses `limit` and `offset` query parameters.
12
12
 
13
+ **FastAPI Depends (recommended)**:
14
+
15
+ ```python
16
+ from typing import Annotated
17
+ from fastapi import Depends
18
+ from nene2.http import PaginationQueryParser
19
+
20
+ @router.get("/items")
21
+ def list_items(pagination: Annotated[PaginationQueryParser, Depends()]) -> JSONResponse:
22
+ result = use_case.execute(pagination.limit, pagination.offset)
23
+ ```
24
+
25
+ **Legacy (Request-based)**:
26
+
13
27
  ```python
14
28
  from nene2.http import PaginationQueryParser
15
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nene2-python"
3
- version = "1.2.0"
3
+ version = "1.4.0"
4
4
  description = "NENE2 Python — minimal API framework following NENE2's design philosophy"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -17,13 +17,32 @@ _API_KEY_HEADER = "X-Api-Key"
17
17
 
18
18
 
19
19
  class ApiKeyAuthMiddleware(BaseHTTPMiddleware):
20
- """Require a valid X-Api-Key header on every request."""
21
-
22
- def __init__(self, app: object, *, verifier: TokenVerifierProtocol) -> None:
20
+ """Require a valid X-Api-Key header on every request.
21
+
22
+ Use ``exclude_paths`` to skip authentication for specific paths such as
23
+ health-check endpoints or API documentation::
24
+
25
+ app.add_middleware(
26
+ ApiKeyAuthMiddleware,
27
+ verifier=LocalTokenVerifier(api_keys),
28
+ exclude_paths=["/docs", "/openapi.json", "/redoc", "/health"],
29
+ )
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ app: object,
35
+ *,
36
+ verifier: TokenVerifierProtocol,
37
+ exclude_paths: list[str] | None = None,
38
+ ) -> None:
23
39
  super().__init__(app) # type: ignore[arg-type]
24
40
  self._verifier = verifier
41
+ self._exclude_paths = set(exclude_paths or [])
25
42
 
26
43
  async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
44
+ if request.url.path in self._exclude_paths:
45
+ return await call_next(request)
27
46
  api_key = request.headers.get(_API_KEY_HEADER, "")
28
47
  try:
29
48
  verified = bool(api_key) and self._verifier.verify(api_key)
@@ -17,13 +17,32 @@ _WWW_AUTH = 'Bearer realm="api"'
17
17
 
18
18
 
19
19
  class BearerTokenMiddleware(BaseHTTPMiddleware):
20
- """Require a valid Bearer token on every request."""
20
+ """Require a valid Bearer token on every request.
21
21
 
22
- def __init__(self, app: object, *, verifier: TokenVerifierProtocol) -> None:
22
+ Use ``exclude_paths`` to skip authentication for specific paths such as
23
+ health-check endpoints or API documentation::
24
+
25
+ app.add_middleware(
26
+ BearerTokenMiddleware,
27
+ verifier=LocalTokenVerifier(tokens),
28
+ exclude_paths=["/docs", "/openapi.json", "/redoc", "/health"],
29
+ )
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ app: object,
35
+ *,
36
+ verifier: TokenVerifierProtocol,
37
+ exclude_paths: list[str] | None = None,
38
+ ) -> None:
23
39
  super().__init__(app) # type: ignore[arg-type]
24
40
  self._verifier = verifier
41
+ self._exclude_paths = set(exclude_paths or [])
25
42
 
26
43
  async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
44
+ if request.url.path in self._exclude_paths:
45
+ return await call_next(request)
27
46
  auth = request.headers.get("Authorization", "")
28
47
  if not auth.startswith("Bearer "):
29
48
  response = problem_details_response(
@@ -0,0 +1,36 @@
1
+ """Local token verifier — compares against a fixed set of allowed tokens.
2
+
3
+ For development and testing only. In production, implement TokenVerifierProtocol
4
+ against your actual auth backend (database, external IdP, JWT, etc.).
5
+ """
6
+
7
+ import os
8
+ import secrets
9
+
10
+
11
+ class LocalTokenVerifier:
12
+ """Verify tokens against a fixed allowlist using constant-time comparison."""
13
+
14
+ def __init__(self, allowed_tokens: list[str]) -> None:
15
+ self._allowed = allowed_tokens
16
+
17
+ @classmethod
18
+ def from_env(cls, env_var: str, *, separator: str = ",") -> "LocalTokenVerifier":
19
+ """Create a verifier from a separator-delimited environment variable.
20
+
21
+ Example .env::
22
+
23
+ BEARER_TOKENS = token - a, token - b, token - c
24
+
25
+ Usage::
26
+
27
+ verifier = LocalTokenVerifier.from_env("BEARER_TOKENS")
28
+
29
+ An unset or empty variable results in an empty allowlist (all requests denied).
30
+ """
31
+ raw = os.getenv(env_var, "")
32
+ tokens = [t.strip() for t in raw.split(separator) if t.strip()]
33
+ return cls(tokens)
34
+
35
+ def verify(self, token: str) -> bool:
36
+ return any(secrets.compare_digest(token, allowed) for allowed in self._allowed)
@@ -3,10 +3,11 @@
3
3
  Equivalent to PHP Nene2\\Http\\PaginationQueryParser, PaginationQuery, PaginationResponse.
4
4
  """
5
5
 
6
+ import dataclasses
6
7
  from dataclasses import dataclass, field
7
- from typing import Any
8
+ from typing import Annotated, Any
8
9
 
9
- from fastapi import Request
10
+ from fastapi import Query, Request
10
11
 
11
12
  from nene2.validation.exceptions import ValidationError, ValidationException
12
13
 
@@ -20,7 +21,33 @@ class PaginationQuery:
20
21
 
21
22
 
22
23
  class PaginationQueryParser:
23
- """Parses and validates pagination query parameters from a FastAPI Request."""
24
+ """Parses and validates pagination query parameters.
25
+
26
+ Two usage patterns:
27
+
28
+ **FastAPI Depends (recommended)**::
29
+
30
+ from typing import Annotated
31
+ from fastapi import Depends
32
+
33
+
34
+ def list_items(pagination: Annotated[PaginationQueryParser, Depends()]) -> JSONResponse:
35
+ result = use_case.execute(pagination.limit, pagination.offset)
36
+
37
+ **Manual parse from Request (legacy)**::
38
+
39
+ def list_items(request: Request) -> JSONResponse:
40
+ pagination = PaginationQueryParser.parse(request)
41
+ result = use_case.execute(pagination.limit, pagination.offset)
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ limit: Annotated[int, Query(ge=1, le=100, description="Items per page (1–100)")] = 20,
47
+ offset: Annotated[int, Query(ge=0, description="Items to skip")] = 0,
48
+ ) -> None:
49
+ self.limit = limit
50
+ self.offset = offset
24
51
 
25
52
  @staticmethod
26
53
  def parse(
@@ -83,8 +110,18 @@ class PaginationResponse:
83
110
  total: int | None = field(default=None)
84
111
 
85
112
  def to_dict(self) -> dict[str, Any]:
113
+ """Return a JSON-serializable dict.
114
+
115
+ Items that are dataclass instances are converted via ``dataclasses.asdict()``
116
+ so that ``JSONResponse(result.to_dict())`` works without extra steps.
117
+ """
86
118
  data: dict[str, Any] = {
87
- "items": self.items,
119
+ "items": [
120
+ dataclasses.asdict(item)
121
+ if dataclasses.is_dataclass(item) and not isinstance(item, type)
122
+ else item
123
+ for item in self.items
124
+ ],
88
125
  "limit": self.limit,
89
126
  "offset": self.offset,
90
127
  }
@@ -46,6 +46,27 @@ def test_multiple_allowed_keys() -> None:
46
46
  assert client.get("/secret", headers={"X-Api-Key": "key-c"}).status_code == 401
47
47
 
48
48
 
49
+ def test_exclude_paths_bypasses_auth() -> None:
50
+ app = FastAPI()
51
+ app.add_middleware(
52
+ ApiKeyAuthMiddleware,
53
+ verifier=LocalTokenVerifier(["key"]),
54
+ exclude_paths=["/health"],
55
+ )
56
+
57
+ @app.get("/health")
58
+ async def health() -> JSONResponse:
59
+ return JSONResponse({"status": "ok"})
60
+
61
+ @app.get("/secret")
62
+ async def secret() -> JSONResponse:
63
+ return JSONResponse({"ok": True})
64
+
65
+ client = TestClient(app)
66
+ assert client.get("/health").status_code == 200
67
+ assert client.get("/secret").status_code == 401
68
+
69
+
49
70
  def test_verifier_raises_token_verification_exception_returns_401() -> None:
50
71
  """TokenVerificationException from verifier must return 401, not 500."""
51
72