nene2-python 1.3.0__tar.gz → 1.5.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.3.0 → nene2_python-1.5.0}/.github/workflows/ci.yml +6 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.github/workflows/publish.yml +10 -1
- {nene2_python-1.3.0 → nene2_python-1.5.0}/CHANGELOG.md +57 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/PKG-INFO +1 -1
- nene2_python-1.5.0/docs/adr/0011-mcp-as-core-dependency.md +63 -0
- nene2_python-1.5.0/docs/field-trials/2026-05-field-trial-11.md +116 -0
- nene2_python-1.5.0/docs/field-trials/2026-05-field-trial-12.md +58 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/how-to/configure-auth.md +49 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/pyproject.toml +1 -1
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/auth/api_key.py +22 -3
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/auth/bearer_token.py +21 -2
- nene2_python-1.5.0/src/nene2/auth/local_verifier.py +36 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/request_size_limit.py +19 -1
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/throttle.py +15 -1
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/auth/test_api_key.py +21 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/auth/test_bearer_token.py +55 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/test_request_size_limit.py +21 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/test_throttle.py +32 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/uv.lock +1 -1
- nene2_python-1.3.0/src/nene2/auth/local_verifier.py +0 -17
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.env.example +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.github/workflows/docs.yml +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.gitignore +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.vitepress/config.mts +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.vitepress/theme/custom.css +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/.vitepress/theme/index.ts +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/AGENTS.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/CLAUDE.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/Dockerfile +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/LICENSE +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/README.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/alembic/README +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/alembic/env.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/alembic/script.py.mako +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/alembic.ini +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/compose.yaml +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0001-toolchain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0002-clean-architecture.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0003-security-first.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0004-ai-first-design.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0005-logging.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0006-rate-limiting.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0009-mcp-design.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/adr/0010-async-use-case.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/de/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/de/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/explanation/architecture.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/explanation/design-philosophy.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-10.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-7.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-8.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/field-trials/2026-05-field-trial-9.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/fr/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/fr/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/how-to/add-new-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/how-to/new-project.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/how-to/run-tests.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/howto/mcp-setup.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/explanation/architecture.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/explanation/design-philosophy.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/how-to/add-new-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/how-to/configure-auth.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/how-to/new-project.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/how-to/run-tests.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/howto/mcp-setup.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/reference/api.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/reference/configuration.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/reference/framework-modules.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/tutorials/first-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/ja/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/pt-br/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/pt-br/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/reference/api.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/reference/configuration.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/reference/framework-modules.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/roadmap.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/todo/current.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/tutorials/first-domain.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/zh/index.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/docs/zh/tutorials/getting-started.md +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/package-lock.json +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/package.json +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/__main__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/app.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/entity.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/sqlalchemy_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/comment/use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/mcp.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/async_use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/entity.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/sqlalchemy_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/note/use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/schema.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/entity.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/sqlalchemy_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/example/tag/use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/auth/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/auth/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/auth/interfaces.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/config/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/config/settings.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/database/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/database/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/database/health.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/database/interfaces.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/database/sqlalchemy_executor.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/database/utils.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/http/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/http/health.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/http/pagination.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/http/problem_details.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/log/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/log/setup.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/mcp/http_client.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/mcp/server.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/domain_exception.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/error_handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/request_id.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/request_logging.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/middleware/security_headers.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/py.typed +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/use_case/protocols.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/validation/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/nene2/validation/exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/scripts/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/src/scripts/export_openapi.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/comment/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/comment/test_comment_http.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/comment/test_comment_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/comment/test_comment_use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/conftest.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/note/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/note/test_async_note_use_case.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/note/test_list_notes.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/note/test_note_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/tag/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/tag/test_tag_repository.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/tag/test_tags.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/test_cors.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/example/test_mcp.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/auth/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/auth/test_token_issuer.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/database/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/database/test_transaction.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/database/test_utils.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/http/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/http/test_pagination.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/mcp/test_http_client.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/test_error_handler.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/test_request_id.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/test_request_logging.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/middleware/test_security_headers.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/use_case/test_protocols.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/validation/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/nene2/validation/test_exceptions.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/scripts/__init__.py +0 -0
- {nene2_python-1.3.0 → nene2_python-1.5.0}/tests/scripts/test_export_openapi.py +0 -0
|
@@ -31,6 +31,12 @@ jobs:
|
|
|
31
31
|
- name: pytest (with coverage)
|
|
32
32
|
run: uv run pytest
|
|
33
33
|
|
|
34
|
+
- name: coverage gate — domain/use_case layers (90%)
|
|
35
|
+
run: |
|
|
36
|
+
uv run coverage report \
|
|
37
|
+
--include="src/example/*/use_case.py,src/example/*/async_use_case.py,src/example/*/entity.py" \
|
|
38
|
+
--fail-under=90
|
|
39
|
+
|
|
34
40
|
- name: mypy
|
|
35
41
|
run: uv run mypy src/
|
|
36
42
|
|
|
@@ -89,8 +89,17 @@ jobs:
|
|
|
89
89
|
name: dist
|
|
90
90
|
path: dist/
|
|
91
91
|
|
|
92
|
+
- name: Extract release notes from CHANGELOG
|
|
93
|
+
id: changelog
|
|
94
|
+
run: |
|
|
95
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
96
|
+
NOTES=$(awk "/^## \[$VERSION\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md | sed '/^[[:space:]]*$/d;/^---$/d' | sed '1{/^[[:space:]]*$/d}')
|
|
97
|
+
echo "notes<<EOF" >> "$GITHUB_OUTPUT"
|
|
98
|
+
echo "$NOTES" >> "$GITHUB_OUTPUT"
|
|
99
|
+
echo "EOF" >> "$GITHUB_OUTPUT"
|
|
100
|
+
|
|
92
101
|
- name: Create GitHub Release
|
|
93
102
|
uses: softprops/action-gh-release@v2
|
|
94
103
|
with:
|
|
95
104
|
files: dist/*
|
|
96
|
-
|
|
105
|
+
body: ${{ steps.changelog.outputs.notes }}
|
|
@@ -5,6 +5,63 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
+
## [1.5.0] — 2026-05-20
|
|
9
|
+
|
|
10
|
+
FT12 (ThrottleMiddleware + RequestSizeLimitMiddleware) field trial — middleware exclude_paths consistency.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `ThrottleMiddleware` — `exclude_paths` parameter to bypass rate limiting for `/health`, `/docs`, etc.
|
|
14
|
+
- `RequestSizeLimitMiddleware` — same `exclude_paths` parameter for consistency with other middleware
|
|
15
|
+
- Field trial report: `docs/field-trials/2026-05-field-trial-12.md`
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## [1.4.0] — 2026-05-20
|
|
20
|
+
|
|
21
|
+
FT11 (BearerTokenMiddleware + HttpxMcpClient) field trial — auth usability improvements.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `BearerTokenMiddleware` — `exclude_paths` parameter to bypass auth for `/docs`, `/openapi.json`, `/health`, etc.
|
|
25
|
+
- `ApiKeyAuthMiddleware` — same `exclude_paths` parameter
|
|
26
|
+
- `LocalTokenVerifier.from_env(env_var, *, separator=",")` — create a verifier from a comma-delimited environment variable, with whitespace trimming and custom separator support
|
|
27
|
+
- `docs/how-to/configure-auth.md` — three new sections: `from_env` usage, `exclude_paths` usage, MCP server fail-fast token check pattern
|
|
28
|
+
- Field trial report: `docs/field-trials/2026-05-field-trial-11.md`
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## [1.3.0] — 2026-05-20
|
|
33
|
+
|
|
34
|
+
FT10 (MySQL adapter) field trial — pagination and serialization improvements.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `PaginationQueryParser.__init__` — makes the class usable as a FastAPI `Depends()` parameter directly: `Annotated[PaginationQueryParser, Depends()]`
|
|
38
|
+
- `PaginationResponse.to_dict()` — auto-serializes `dataclass(frozen=True, slots=True)` items via `dataclasses.asdict()` (previously raised `TypeError` for slotted dataclasses)
|
|
39
|
+
- Field trial report: `docs/field-trials/2026-05-field-trial-10.md`
|
|
40
|
+
- How-to guide: MySQL adapter setup (`docs/how-to/use-mysql.md`)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## [1.2.0] — 2026-05-19
|
|
45
|
+
|
|
46
|
+
FT9 (MCP server standalone) field trial — MCP server and HTTP client improvements.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- `LocalMcpServer` — `port` and `host` constructor parameters (previously hardcoded)
|
|
50
|
+
- `McpHttpError` — raised by `HttpxMcpClient.raise_for_error()` on 4xx/5xx responses, maps to MCP `isError: true`
|
|
51
|
+
- Field trial report: `docs/field-trials/2026-05-field-trial-9.md`
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## [1.1.0] — 2026-05-19
|
|
56
|
+
|
|
57
|
+
FT8 (nested resources + datetime) field trial — database datetime handling.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
- `nene2.database.utils.parse_db_datetime(value)` — normalises SQLite string timestamps and MySQL naive `datetime` objects to UTC-aware `datetime`; handles both adapters transparently
|
|
61
|
+
- Field trial report: `docs/field-trials/2026-05-field-trial-8.md`
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
8
65
|
## [1.0.0] — 2026-05-19
|
|
9
66
|
|
|
10
67
|
First stable release. Feature parity with PHP NENE2 v1.4.0.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nene2-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.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,63 @@
|
|
|
1
|
+
# ADR-0011: MCP をコア依存として含める
|
|
2
|
+
|
|
3
|
+
## ステータス
|
|
4
|
+
|
|
5
|
+
承認済み (2026-05-20)
|
|
6
|
+
|
|
7
|
+
## コンテキスト
|
|
8
|
+
|
|
9
|
+
外部レビューで「`mcp>=1.0` をコア依存に含めると、MCP を使わない利用者まで FastMCP の依存ツリーを背負う」という指摘を受けた。`nene2[mcp]` optional extras に分離する案が提示された。
|
|
10
|
+
|
|
11
|
+
## 決定
|
|
12
|
+
|
|
13
|
+
`mcp>=1.0` はコア依存として `pyproject.toml` の `dependencies` に置く。optional extras には分離しない。
|
|
14
|
+
|
|
15
|
+
### 理由
|
|
16
|
+
|
|
17
|
+
**1. MCP はフレームワークの設計思想の中核**
|
|
18
|
+
|
|
19
|
+
`CLAUDE.md` の設計哲学に「LLM delivery ready: API・MCP・認証・DB・引き継ぎドキュメントを整合させる」と明記している。MCP は認証・DB と同列のファーストクラス機能であり、"optional な付加機能" ではない。
|
|
20
|
+
|
|
21
|
+
**2. UseCase アーキテクチャとの直結**
|
|
22
|
+
|
|
23
|
+
`nene2.use_case` の `UseCaseProtocol[I, O]` は HTTP と MCP の両方から呼ばれることを前提に設計されている(ADR-0002)。UseCase を MCP ツールとして公開する経路が常に存在することがフレームワークの価値のひとつであり、その経路を extras 依存にすることはアーキテクチャの前提に反する。
|
|
24
|
+
|
|
25
|
+
**3. extras 化のコスト**
|
|
26
|
+
|
|
27
|
+
optional extras にすると `nene2.mcp` モジュール全体の import が条件分岐になる:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
try:
|
|
31
|
+
from fastmcp import FastMCP
|
|
32
|
+
except ImportError:
|
|
33
|
+
raise ImportError("Install nene2[mcp] to use MCP features.")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
フィールドトライアルのたびに MCP を使うため、このパターンを随所に書くことは単なる負債になる。
|
|
37
|
+
|
|
38
|
+
**4. 現在の採用者像**
|
|
39
|
+
|
|
40
|
+
nene2-python の想定ユーザーは「AI エージェント連携を見据えた API バックエンドを構築したい開発者」であり、MCP を使わない利用者は現時点で想定外のユースケースに属する。
|
|
41
|
+
|
|
42
|
+
## 将来の見直し条件
|
|
43
|
+
|
|
44
|
+
以下のいずれかが発生した場合、optional extras 化を検討する:
|
|
45
|
+
|
|
46
|
+
- FastMCP の依存ツリーが著しく肥大化し、インストール時間・容量が問題視される
|
|
47
|
+
- 「認証・DB・ページネーションだけ使いたい、MCP は不要」という実際の利用者フィードバックが複数件寄せられる
|
|
48
|
+
- MCP SDK のライセンスや破壊的変更がコア依存として許容できなくなる
|
|
49
|
+
|
|
50
|
+
その際は `nene2[mcp]` extras として分離し、`nene2.mcp` モジュールのインポートを条件分岐化する。
|
|
51
|
+
|
|
52
|
+
## 代替案
|
|
53
|
+
|
|
54
|
+
| 案 | 却下理由 |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `nene2[mcp]` optional extras | 設計思想と不整合・extras 化のコストが現状では割に合わない |
|
|
57
|
+
| MCP モジュールを別パッケージ (`nene2-mcp`) に分離 | フレームワークの一体感が失われる・インストール手順が増える |
|
|
58
|
+
|
|
59
|
+
## 結果
|
|
60
|
+
|
|
61
|
+
- `uv add nene2-python` 一発で MCP 機能を含む完全なスタックが手に入る
|
|
62
|
+
- フィールドトライアルで MCP を毎回インストールする手間がない
|
|
63
|
+
- 将来的に extras 分離が必要になった場合は ADR を更新してこの決定を覆す
|
|
@@ -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` との差異を確認
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Field Trial 12 — ThrottleMiddleware + RequestSizeLimitMiddleware 実運用
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-20
|
|
4
|
+
**App:** Chirp API(短文投稿 API、レート制限 + ペイロード制限付き)
|
|
5
|
+
**Directory:** `/home/xi/docker/nene2-python-FT/ft12-throttle/`
|
|
6
|
+
**nene2-python version:** v1.4.0
|
|
7
|
+
|
|
8
|
+
## 概要
|
|
9
|
+
|
|
10
|
+
`ThrottleMiddleware`(固定ウィンドウレート制限)と `RequestSizeLimitMiddleware`(リクエストボディ制限)を実際のアプリに組み込み、動作と摩擦点を確認した。
|
|
11
|
+
|
|
12
|
+
## 動作確認結果
|
|
13
|
+
|
|
14
|
+
- `ThrottleMiddleware(limit=3, window=60)` で3リクエスト後に 429 + `Retry-After` ヘッダーが返ること ✓
|
|
15
|
+
- `RequestSizeLimitMiddleware(max_bytes=100)` でペイロード超過時に 413 が返ること ✓
|
|
16
|
+
- 両ミドルウェアとも Problem Details (RFC 9457) 形式でエラーレスポンスが返ること ✓
|
|
17
|
+
- `AppSettings` に `throttle_limit`, `throttle_window`, `max_body_size` が揃っていること ✓
|
|
18
|
+
|
|
19
|
+
## 摩擦点
|
|
20
|
+
|
|
21
|
+
### FT12-F1 (MEDIUM): ThrottleMiddleware に exclude_paths がない
|
|
22
|
+
|
|
23
|
+
BearerTokenMiddleware・ApiKeyAuthMiddleware は FT11 で `exclude_paths` を追加したが、
|
|
24
|
+
`ThrottleMiddleware` には同パラメータがない。
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# やりたいこと: /health はレート制限の対象外にしたい
|
|
28
|
+
app.add_middleware(
|
|
29
|
+
ThrottleMiddleware,
|
|
30
|
+
limit=60,
|
|
31
|
+
window=60,
|
|
32
|
+
exclude_paths=["/health", "/docs"], # ← 存在しない
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
実際には `/health` も 60req/min のレート制限にかかる。ロードバランサーのヘルスチェックが
|
|
37
|
+
高頻度で叩く環境では 429 が返り、インスタンスがダウン扱いになるリスクがある。
|
|
38
|
+
|
|
39
|
+
**再現コード:**
|
|
40
|
+
```python
|
|
41
|
+
app.add_middleware(ThrottleMiddleware, limit=2, window=60)
|
|
42
|
+
# /health を3回叩くと3回目が 429
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### FT12-F2 (MEDIUM): RequestSizeLimitMiddleware に exclude_paths がない
|
|
46
|
+
|
|
47
|
+
同様に `RequestSizeLimitMiddleware` にも `exclude_paths` がない。
|
|
48
|
+
実用上の影響は ThrottleMiddleware より小さいが(GET リクエストはボディなしなので通常問題ない)、
|
|
49
|
+
一貫性の観点から揃えるべき。
|
|
50
|
+
|
|
51
|
+
BearerTokenMiddleware, ApiKeyAuthMiddleware, ThrottleMiddleware がすべて `exclude_paths` を
|
|
52
|
+
持つのに RequestSizeLimitMiddleware だけ持たない状態は混乱を招く。
|
|
53
|
+
|
|
54
|
+
## まとめ
|
|
55
|
+
|
|
56
|
+
基本動作は問題なし。FT11 で auth 系ミドルウェアに `exclude_paths` を追加したが、
|
|
57
|
+
同じ修正が `ThrottleMiddleware` と `RequestSizeLimitMiddleware` にも必要。
|
|
58
|
+
3つのミドルウェアで `exclude_paths` の有無が揃っていないことが主な摩擦。
|
|
@@ -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).
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -22,13 +22,31 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|
|
22
22
|
Checks the Content-Length header first for a fast pre-flight reject,
|
|
23
23
|
then reads the actual body to catch chunked-transfer requests that
|
|
24
24
|
omit Content-Length entirely.
|
|
25
|
+
|
|
26
|
+
Use ``exclude_paths`` to bypass the size limit for specific endpoints::
|
|
27
|
+
|
|
28
|
+
app.add_middleware(
|
|
29
|
+
RequestSizeLimitMiddleware,
|
|
30
|
+
max_bytes=1_048_576,
|
|
31
|
+
exclude_paths=["/upload/multipart"],
|
|
32
|
+
)
|
|
25
33
|
"""
|
|
26
34
|
|
|
27
|
-
def __init__(
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
app: object,
|
|
38
|
+
*,
|
|
39
|
+
max_bytes: int = _DEFAULT_MAX_BYTES,
|
|
40
|
+
exclude_paths: list[str] | None = None,
|
|
41
|
+
) -> None:
|
|
28
42
|
super().__init__(app) # type: ignore[arg-type]
|
|
29
43
|
self._max_bytes = max_bytes
|
|
44
|
+
self._exclude_paths = set(exclude_paths or [])
|
|
30
45
|
|
|
31
46
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
47
|
+
if request.url.path in self._exclude_paths:
|
|
48
|
+
return await call_next(request)
|
|
49
|
+
|
|
32
50
|
content_length = request.headers.get("Content-Length")
|
|
33
51
|
if content_length is not None:
|
|
34
52
|
try:
|
|
@@ -25,7 +25,17 @@ _DEFAULT_WINDOW = 60 # seconds
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class ThrottleMiddleware(BaseHTTPMiddleware):
|
|
28
|
-
"""Fixed-window rate limiter keyed by client IP.
|
|
28
|
+
"""Fixed-window rate limiter keyed by client IP.
|
|
29
|
+
|
|
30
|
+
Use ``exclude_paths`` to bypass rate limiting for health checks and API docs::
|
|
31
|
+
|
|
32
|
+
app.add_middleware(
|
|
33
|
+
ThrottleMiddleware,
|
|
34
|
+
limit=60,
|
|
35
|
+
window=60,
|
|
36
|
+
exclude_paths=["/health", "/docs", "/openapi.json"],
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
29
39
|
|
|
30
40
|
def __init__(
|
|
31
41
|
self,
|
|
@@ -33,10 +43,12 @@ class ThrottleMiddleware(BaseHTTPMiddleware):
|
|
|
33
43
|
*,
|
|
34
44
|
limit: int = _DEFAULT_LIMIT,
|
|
35
45
|
window: int = _DEFAULT_WINDOW,
|
|
46
|
+
exclude_paths: list[str] | None = None,
|
|
36
47
|
) -> None:
|
|
37
48
|
super().__init__(app) # type: ignore[arg-type]
|
|
38
49
|
self._limit = limit
|
|
39
50
|
self._window = window
|
|
51
|
+
self._exclude_paths = set(exclude_paths or [])
|
|
40
52
|
self._counts: dict[str, tuple[int, float]] = {}
|
|
41
53
|
self._lock = threading.Lock()
|
|
42
54
|
|
|
@@ -58,6 +70,8 @@ class ThrottleMiddleware(BaseHTTPMiddleware):
|
|
|
58
70
|
return count <= self._limit, remaining
|
|
59
71
|
|
|
60
72
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
73
|
+
if request.url.path in self._exclude_paths:
|
|
74
|
+
return await call_next(request)
|
|
61
75
|
key = self._client_key(request)
|
|
62
76
|
allowed, retry_after = self._is_allowed(key)
|
|
63
77
|
if not allowed:
|
|
@@ -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
|
|