nene2-python 1.1.0__tar.gz → 1.3.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.1.0 → nene2_python-1.3.0}/PKG-INFO +1 -1
- nene2_python-1.3.0/docs/field-trials/2026-05-field-trial-10.md +167 -0
- nene2_python-1.3.0/docs/field-trials/2026-05-field-trial-9.md +151 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/how-to/sqlalchemy-repository.md +78 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/reference/framework-modules.md +14 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/pyproject.toml +1 -1
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/database/utils.py +1 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/http/pagination.py +41 -4
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/mcp/__init__.py +2 -1
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/mcp/http_client.py +22 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/mcp/server.py +9 -2
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/http/test_pagination.py +40 -1
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/mcp/test_http_client.py +25 -1
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.env.example +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.github/workflows/ci.yml +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.github/workflows/docs.yml +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.github/workflows/publish.yml +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.gitignore +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.vitepress/config.mts +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.vitepress/theme/custom.css +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/.vitepress/theme/index.ts +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/AGENTS.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/CHANGELOG.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/CLAUDE.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/Dockerfile +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/LICENSE +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/README.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/alembic/README +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/alembic/env.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/alembic/script.py.mako +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/alembic.ini +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/compose.yaml +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0001-toolchain.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0002-clean-architecture.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0003-security-first.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0004-ai-first-design.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0005-logging.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0006-rate-limiting.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0009-mcp-design.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/adr/0010-async-use-case.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/de/index.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/de/tutorials/getting-started.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/explanation/architecture.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/explanation/design-philosophy.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-7.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/field-trials/2026-05-field-trial-8.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/fr/index.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/fr/tutorials/getting-started.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/how-to/add-new-domain.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/how-to/configure-auth.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/how-to/new-project.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/how-to/run-tests.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/howto/mcp-setup.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/index.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/explanation/architecture.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/explanation/design-philosophy.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/how-to/add-new-domain.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/how-to/configure-auth.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/how-to/new-project.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/how-to/run-tests.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/howto/mcp-setup.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/index.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/reference/api.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/reference/configuration.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/reference/framework-modules.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/tutorials/first-domain.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/ja/tutorials/getting-started.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/pt-br/index.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/pt-br/tutorials/getting-started.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/reference/api.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/reference/configuration.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/roadmap.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/todo/current.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/tutorials/first-domain.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/tutorials/getting-started.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/zh/index.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/docs/zh/tutorials/getting-started.md +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/package-lock.json +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/package.json +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/__main__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/app.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/entity.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/handler.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/sqlalchemy_repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/comment/use_case.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/mcp.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/async_use_case.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/entity.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/handler.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/sqlalchemy_repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/note/use_case.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/schema.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/entity.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/handler.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/sqlalchemy_repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/example/tag/use_case.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/auth/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/auth/api_key.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/auth/bearer_token.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/auth/exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/auth/interfaces.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/auth/local_verifier.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/config/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/config/settings.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/database/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/database/exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/database/health.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/database/interfaces.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/database/sqlalchemy_executor.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/http/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/http/health.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/http/problem_details.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/log/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/log/setup.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/domain_exception.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/error_handler.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/request_id.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/request_logging.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/request_size_limit.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/security_headers.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/middleware/throttle.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/py.typed +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/use_case/protocols.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/validation/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/nene2/validation/exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/scripts/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/src/scripts/export_openapi.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/comment/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/comment/test_comment_http.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/comment/test_comment_repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/comment/test_comment_use_case.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/conftest.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/note/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/note/test_async_note_use_case.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/note/test_list_notes.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/note/test_note_repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/tag/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/tag/test_tag_repository.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/tag/test_tags.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/test_cors.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/example/test_mcp.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/auth/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/auth/test_api_key.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/auth/test_bearer_token.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/auth/test_token_issuer.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/database/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/database/test_transaction.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/database/test_utils.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/http/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/test_error_handler.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/test_request_id.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/test_request_logging.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/test_security_headers.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/middleware/test_throttle.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/use_case/test_protocols.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/validation/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/nene2/validation/test_exceptions.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/scripts/__init__.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/tests/scripts/test_export_openapi.py +0 -0
- {nene2_python-1.1.0 → nene2_python-1.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nene2-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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,151 @@
|
|
|
1
|
+
# Field Trial 9 — recipe: HttpxMcpClient + streamable-http トランスポート DX 検証
|
|
2
|
+
|
|
3
|
+
## Date
|
|
4
|
+
|
|
5
|
+
2026-05-20
|
|
6
|
+
|
|
7
|
+
## Baseline
|
|
8
|
+
|
|
9
|
+
- nene2-python v1.1.0(PyPI 経由)→ LocalMcpServer 修正後は local editable
|
|
10
|
+
- Python 3.14(uv managed)
|
|
11
|
+
- プロジェクト: **recipe** — レシピ管理 API
|
|
12
|
+
- エンティティ: `Recipe(id, title, description, servings)`
|
|
13
|
+
- HTTP API: ポート 8100(FastAPI + InMemory)
|
|
14
|
+
- MCP サーバー: ポート 8101(`streamable-http` トランスポート)
|
|
15
|
+
- MCP ツール: `HttpxMcpClient` で HTTP API を呼び出してデータ共有
|
|
16
|
+
|
|
17
|
+
## Goal
|
|
18
|
+
|
|
19
|
+
FT1〜FT8 で未探索のパターンを検証:
|
|
20
|
+
|
|
21
|
+
1. **`LocalMcpServer.run(transport="streamable-http")`** — HTTP モード MCP の起動
|
|
22
|
+
2. **`HttpxMcpClient`** — MCP ツールハンドラーから HTTP API を呼び出してデータ共有
|
|
23
|
+
3. FT3-F2(MCP と HTTP API のメモリ非共有問題)の正しい解決パターンを実証
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Steps Taken
|
|
28
|
+
|
|
29
|
+
### 1. HTTP API + MCP サーバーの二段構成
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
HTTP API(app.py, port 8100)
|
|
33
|
+
↑ HTTP calls
|
|
34
|
+
MCP Server(mcp_server.py, port 8101)
|
|
35
|
+
↓ streamable-http
|
|
36
|
+
Claude / MCP Client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
MCP ツールが `HttpxMcpClient` で HTTP API を叩くため、どちらの経路から書いても同じデータが見える。
|
|
40
|
+
|
|
41
|
+
### 2. HttpxMcpClient でのツール実装
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from nene2.mcp import LocalMcpServer
|
|
45
|
+
from nene2.mcp.http_client import HttpxMcpClient
|
|
46
|
+
|
|
47
|
+
client = HttpxMcpClient()
|
|
48
|
+
server = LocalMcpServer("recipe-api", instructions="...", port=8101)
|
|
49
|
+
|
|
50
|
+
@server.tool("List all recipes. Returns paginated results.")
|
|
51
|
+
def list_recipes(limit: int = 20, offset: int = 0) -> str:
|
|
52
|
+
response = client.get(API_BASE, f"/recipes?limit={limit}&offset={offset}")
|
|
53
|
+
return response.body
|
|
54
|
+
|
|
55
|
+
@server.tool("Create a new recipe.")
|
|
56
|
+
def create_recipe(title: str, description: str, servings: int) -> str:
|
|
57
|
+
response = client.post(API_BASE, "/recipes",
|
|
58
|
+
{"title": title, "description": description, "servings": servings})
|
|
59
|
+
return response.body
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 3. 動作確認
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# HTTP API で作成 → MCP ツールから見える
|
|
66
|
+
curl -X POST http://localhost:8100/recipes -d '{"title":"Ramen",...}' → {"id":1,...}
|
|
67
|
+
MCP: list_recipes() → {"items":[{"id":1,"title":"Ramen",...}],...}
|
|
68
|
+
|
|
69
|
+
# MCP ツールで作成 → HTTP API から見える
|
|
70
|
+
MCP: create_recipe("Sushi",...) → {"id":2,...}
|
|
71
|
+
curl GET http://localhost:8100/recipes → [Ramen, Sushi]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**FT3-F2 の解決確認**: HTTP API と MCP が `HttpxMcpClient` 経由で完全にデータを共有。
|
|
75
|
+
|
|
76
|
+
### 4. streamable-http プロトコルのフロー
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
POST /mcp (initialize) → Session-ID: {uuid} を取得
|
|
80
|
+
POST /mcp + Mcp-Session-Id: {uuid} (tools/list, tools/call)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
stdio と異なり、MCP セッション管理が必要。HTTP クライアントライブラリや Claude Desktop が処理するため、手動操作は検証時のみ。
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Friction Points
|
|
88
|
+
|
|
89
|
+
### FT9-1: `LocalMcpServer` にポート指定がない
|
|
90
|
+
|
|
91
|
+
- **摩擦**: `LocalMcpServer.run(transport="streamable-http")` のデフォルトポートが 8000 で変更不可
|
|
92
|
+
- `FastMCP.__init__` は `host`/`port` を受け取るが、`LocalMcpServer.__init__` が渡していなかった
|
|
93
|
+
- HTTP API(8100)と MCP(8101)を同一マシンで動かす際にポート衝突が起きる
|
|
94
|
+
- **深刻度**: HIGH(2サービス構成で必ずぶつかる)
|
|
95
|
+
- **解決策**: `LocalMcpServer.__init__(name, instructions, *, host, port)` に追加済み(PR #174)
|
|
96
|
+
|
|
97
|
+
### FT9-2: HTTP エラー(404 等)が `isError: false` で返る
|
|
98
|
+
|
|
99
|
+
- **摩擦**: MCP ツールが `return response.body` で 404 レスポンスをそのまま返すと、
|
|
100
|
+
MCP プロトコルの `isError` フラグが `false` になる
|
|
101
|
+
- AI クライアントがエラーを成功として解釈する可能性がある
|
|
102
|
+
- **深刻度**: MEDIUM(AI がエラーを無視して誤った操作を続けるリスク)
|
|
103
|
+
- **解決策**: `McpHttpResponse.raise_for_error()` を追加 → FastMCP が例外を `isError: true` に変換(PR #174)
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
def get_recipe(recipe_id: int) -> str:
|
|
107
|
+
response = client.get(API_BASE, f"/recipes/{recipe_id}")
|
|
108
|
+
response.raise_for_error() # ← 追加: 4xx/5xx を例外に変換
|
|
109
|
+
return response.body
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### FT9-3: DELETE が 204 No Content を返し `response.body` が空
|
|
113
|
+
|
|
114
|
+
- **摩擦**: DELETE エンドポイントは 204 を返すため `response.body` が空文字列 `""`
|
|
115
|
+
- MCP ツールの戻り値が `""` になり、AI が「削除完了」を確認できない
|
|
116
|
+
- **解決策**: 明示的に確認メッセージを組み立てる(フレームワークは解決不要、パターンとして文書化)
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
def delete_recipe(recipe_id: int) -> str:
|
|
120
|
+
response = client.delete(API_BASE, f"/recipes/{recipe_id}")
|
|
121
|
+
response.raise_for_error()
|
|
122
|
+
if response.status_code == 204:
|
|
123
|
+
return json.dumps({"deleted": True, "recipe_id": recipe_id})
|
|
124
|
+
return response.body
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### FT9-4: streamable-http はセッション管理が必要(stdio より複雑)
|
|
128
|
+
|
|
129
|
+
- **摩擦**: stdio はプロセス間通信で自動管理されるが、HTTP モードは `initialize` → Session ID → ツール呼び出し の2ステップ
|
|
130
|
+
- Claude Desktop や MCP SDK クライアントが透過的に処理するため、**実際の利用では問題にならない**
|
|
131
|
+
- ただしデバッグ時(curl で直接叩く)には手順が煩雑
|
|
132
|
+
- **深刻度**: LOW(ツール側の問題ではなく、デバッグ時の利便性の問題)
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Summary
|
|
137
|
+
|
|
138
|
+
| ID | 摩擦 | 深刻度 | 解決策 |
|
|
139
|
+
|--------|-------------------------------------------------|--------|-----------------------------------------------|
|
|
140
|
+
| FT9-1 | LocalMcpServer にポート指定がない | HIGH | `host`/`port` 引数追加済み(PR #174) |
|
|
141
|
+
| FT9-2 | HTTP エラーが isError: false で返る | MEDIUM | `raise_for_error()` 追加済み(PR #174) |
|
|
142
|
+
| FT9-3 | DELETE 204 で body が空 → AI が確認できない | LOW | パターンとして文書化(json.dumps で補完) |
|
|
143
|
+
| FT9-4 | streamable-http のセッション管理が手動で煩雑 | LOW | クライアント SDK が透過処理するので運用上は問題なし |
|
|
144
|
+
|
|
145
|
+
**`HttpxMcpClient` + `streamable-http` の組み合わせは FT3-F2 の正しい解決策として機能した。**
|
|
146
|
+
HTTP API を中継することで、MCP と HTTP クライアントが完全にデータを共有できる。
|
|
147
|
+
|
|
148
|
+
FT10 候補:
|
|
149
|
+
- **MySQL/PostgreSQL アダプター**: SQLite 以外の DB を初めて FT で使用
|
|
150
|
+
- **`BearerTokenMiddleware` + `HttpxMcpClient`**: 認証付き API に対して MCP から呼び出す
|
|
151
|
+
- **`LocalMcpServer` の SSE トランスポート**: `streamable-http` との差異を確認
|
|
@@ -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
|
|
|
@@ -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
|
|
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":
|
|
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
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""NENE2 MCP integration — expose UseCases as MCP tools."""
|
|
2
2
|
|
|
3
|
-
from .http_client import HttpxMcpClient, McpHttpClientProtocol, McpHttpResponse
|
|
3
|
+
from .http_client import HttpxMcpClient, McpHttpClientProtocol, McpHttpError, McpHttpResponse
|
|
4
4
|
from .server import LocalMcpServer
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
7
|
"HttpxMcpClient",
|
|
8
8
|
"LocalMcpServer",
|
|
9
9
|
"McpHttpClientProtocol",
|
|
10
|
+
"McpHttpError",
|
|
10
11
|
"McpHttpResponse",
|
|
11
12
|
]
|
|
@@ -11,6 +11,15 @@ import httpx
|
|
|
11
11
|
from httpx import BaseTransport
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class McpHttpError(Exception):
|
|
15
|
+
"""Raised by McpHttpResponse.raise_for_error() on 4xx / 5xx responses."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, body: str) -> None:
|
|
18
|
+
super().__init__(f"HTTP {status_code}: {body}")
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.body = body
|
|
21
|
+
|
|
22
|
+
|
|
14
23
|
@dataclass(frozen=True, slots=True)
|
|
15
24
|
class McpHttpResponse:
|
|
16
25
|
"""HTTP response value object returned by McpHttpClientProtocol."""
|
|
@@ -22,6 +31,19 @@ class McpHttpResponse:
|
|
|
22
31
|
def is_successful(self) -> bool:
|
|
23
32
|
return 200 <= self.status_code < 300
|
|
24
33
|
|
|
34
|
+
def raise_for_error(self) -> None:
|
|
35
|
+
"""Raise McpHttpError if the response status code indicates an error (4xx / 5xx).
|
|
36
|
+
|
|
37
|
+
Use this in MCP tool handlers to propagate HTTP errors as tool errors::
|
|
38
|
+
|
|
39
|
+
def get_recipe(recipe_id: int) -> str:
|
|
40
|
+
response = client.get(API_BASE, f"/recipes/{recipe_id}")
|
|
41
|
+
response.raise_for_error()
|
|
42
|
+
return response.body
|
|
43
|
+
"""
|
|
44
|
+
if not self.is_successful():
|
|
45
|
+
raise McpHttpError(self.status_code, self.body)
|
|
46
|
+
|
|
25
47
|
def request_id(self) -> str | None:
|
|
26
48
|
return self.headers.get("x-request-id")
|
|
27
49
|
|
|
@@ -13,8 +13,15 @@ from mcp.server.fastmcp import FastMCP
|
|
|
13
13
|
class LocalMcpServer:
|
|
14
14
|
"""MCP server with sensible defaults for local / stdio transport."""
|
|
15
15
|
|
|
16
|
-
def __init__(
|
|
17
|
-
self
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
name: str,
|
|
19
|
+
instructions: str = "",
|
|
20
|
+
*,
|
|
21
|
+
host: str = "127.0.0.1",
|
|
22
|
+
port: int = 8000,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._mcp = FastMCP(name, instructions=instructions, host=host, port=port)
|
|
18
25
|
|
|
19
26
|
def tool(self, description: str = "") -> Callable[[Any], Any]:
|
|
20
27
|
"""Register a function as an MCP tool."""
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Tests for PaginationQueryParser and PaginationResponse."""
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Annotated
|
|
3
5
|
from unittest.mock import MagicMock
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
|
-
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi import Depends, FastAPI, Request
|
|
7
9
|
from fastapi.responses import JSONResponse
|
|
8
10
|
from fastapi.testclient import TestClient
|
|
9
11
|
|
|
@@ -19,6 +21,12 @@ def _make_app() -> FastAPI:
|
|
|
19
21
|
pagination = PaginationQueryParser.parse(request)
|
|
20
22
|
return JSONResponse({"limit": pagination.limit, "offset": pagination.offset})
|
|
21
23
|
|
|
24
|
+
@app.get("/items-depends")
|
|
25
|
+
async def items_depends(
|
|
26
|
+
pagination: Annotated[PaginationQueryParser, Depends()],
|
|
27
|
+
) -> JSONResponse:
|
|
28
|
+
return JSONResponse({"limit": pagination.limit, "offset": pagination.offset})
|
|
29
|
+
|
|
22
30
|
return app
|
|
23
31
|
|
|
24
32
|
|
|
@@ -86,3 +94,34 @@ def test_pagination_response_with_total() -> None:
|
|
|
86
94
|
def test_pagination_response_total_zero_is_included() -> None:
|
|
87
95
|
r = PaginationResponse(items=[], limit=10, offset=0, total=0)
|
|
88
96
|
assert r.to_dict()["total"] == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_pagination_query_parser_as_depends_default() -> None:
|
|
100
|
+
r = client.get("/items-depends")
|
|
101
|
+
assert r.json() == {"limit": 20, "offset": 0}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_pagination_query_parser_as_depends_custom() -> None:
|
|
105
|
+
r = client.get("/items-depends?limit=5&offset=10")
|
|
106
|
+
assert r.json() == {"limit": 5, "offset": 10}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_pagination_query_parser_as_depends_out_of_range_returns_422() -> None:
|
|
110
|
+
r = client.get("/items-depends?limit=0")
|
|
111
|
+
assert r.status_code == 422
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_pagination_response_to_dict_serializes_dataclass_items() -> None:
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class Item:
|
|
117
|
+
id: int
|
|
118
|
+
name: str
|
|
119
|
+
|
|
120
|
+
r = PaginationResponse(items=[Item(1, "foo"), Item(2, "bar")], limit=20, offset=0, total=2)
|
|
121
|
+
data = r.to_dict()
|
|
122
|
+
assert data["items"] == [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_pagination_response_to_dict_passes_through_dict_items() -> None:
|
|
126
|
+
r = PaginationResponse(items=[{"id": 1}, {"id": 2}], limit=20, offset=0, total=2)
|
|
127
|
+
assert r.to_dict()["items"] == [{"id": 1}, {"id": 2}]
|