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.
- {nene2_python-1.5.0 → nene2_python-1.7.0}/CHANGELOG.md +31 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/PKG-INFO +1 -1
- nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-13.md +54 -0
- nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-14.md +45 -0
- nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-15.md +67 -0
- nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-16.md +62 -0
- nene2_python-1.7.0/docs/field-trials/2026-05-field-trial-17.md +66 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/pyproject.toml +1 -1
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/__init__.py +2 -1
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/exceptions.py +4 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/sqlalchemy_executor.py +17 -5
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/security_headers.py +24 -7
- nene2_python-1.7.0/src/nene2/use_case/protocols.py +51 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/validation/exceptions.py +20 -2
- nene2_python-1.7.0/tests/nene2/database/test_transaction.py +137 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_security_headers.py +41 -0
- nene2_python-1.7.0/tests/nene2/validation/test_exceptions.py +46 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/uv.lock +1 -1
- nene2_python-1.5.0/src/nene2/use_case/protocols.py +0 -24
- nene2_python-1.5.0/tests/nene2/database/test_transaction.py +0 -73
- nene2_python-1.5.0/tests/nene2/validation/test_exceptions.py +0 -22
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.env.example +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.github/workflows/ci.yml +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.github/workflows/docs.yml +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.github/workflows/publish.yml +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.gitignore +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.vitepress/config.mts +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.vitepress/theme/custom.css +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/.vitepress/theme/index.ts +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/AGENTS.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/CLAUDE.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/Dockerfile +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/LICENSE +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/README.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/README +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/env.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/script.py.mako +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/alembic.ini +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/compose.yaml +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0001-toolchain.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0002-clean-architecture.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0003-security-first.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0004-ai-first-design.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0005-logging.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0006-rate-limiting.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0009-mcp-design.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0010-async-use-case.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/adr/0011-mcp-as-core-dependency.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/de/index.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/de/tutorials/getting-started.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/explanation/architecture.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/explanation/design-philosophy.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-10.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-11.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-12.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-7.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-8.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/field-trials/2026-05-field-trial-9.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/fr/index.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/fr/tutorials/getting-started.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/add-new-domain.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/configure-auth.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/new-project.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/run-tests.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/howto/mcp-setup.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/index.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/explanation/architecture.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/explanation/design-philosophy.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/add-new-domain.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/configure-auth.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/new-project.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/run-tests.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/howto/mcp-setup.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/index.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/reference/api.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/reference/configuration.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/reference/framework-modules.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/tutorials/first-domain.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/ja/tutorials/getting-started.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/pt-br/index.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/pt-br/tutorials/getting-started.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/reference/api.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/reference/configuration.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/reference/framework-modules.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/roadmap.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/todo/current.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/tutorials/first-domain.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/tutorials/getting-started.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/zh/index.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/docs/zh/tutorials/getting-started.md +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/package-lock.json +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/package.json +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/__main__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/app.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/entity.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/exceptions.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/handler.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/sqlalchemy_repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/comment/use_case.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/mcp.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/async_use_case.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/entity.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/exceptions.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/handler.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/sqlalchemy_repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/note/use_case.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/schema.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/entity.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/exceptions.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/handler.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/sqlalchemy_repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/example/tag/use_case.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/api_key.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/bearer_token.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/exceptions.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/interfaces.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/auth/local_verifier.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/config/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/config/settings.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/health.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/interfaces.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/database/utils.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/health.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/pagination.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/http/problem_details.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/log/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/log/setup.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/mcp/http_client.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/mcp/server.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/domain_exception.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/error_handler.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/request_id.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/request_logging.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/request_size_limit.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/middleware/throttle.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/py.typed +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/nene2/validation/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/scripts/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/src/scripts/export_openapi.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/test_comment_http.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/test_comment_repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/comment/test_comment_use_case.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/conftest.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/test_async_note_use_case.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/test_list_notes.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/note/test_note_repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/tag/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/tag/test_tag_repository.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/tag/test_tags.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/test_cors.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/example/test_mcp.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/test_api_key.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/test_bearer_token.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/auth/test_token_issuer.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/database/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/database/test_utils.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/http/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/http/test_pagination.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/mcp/test_http_client.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_error_handler.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_request_id.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_request_logging.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/middleware/test_throttle.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/use_case/test_protocols.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/nene2/validation/__init__.py +0 -0
- {nene2_python-1.5.0 → nene2_python-1.7.0}/tests/scripts/__init__.py +0 -0
- {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.
|
|
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
|
"""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",
|
|
@@ -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 →
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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)])
|