Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 21 additions & 25 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,40 +45,36 @@ PHP 版 NENE2 との機能同等性を達成し、さらに Python 固有の強

## ロードマップ

### v0.2.0 — Write Operations & Domain Exceptions
### v0.2.0 — Write Operations & Domain Exceptions ✅ DONE

**ゴール**: CRUD を完成させ、ドメイン例外パターンを確立する。PHP 版の Note フルセットに追いつく。

- [ ] Note: `UpdateNoteUseCase` + `UpdateNoteHandler` (PUT)
- [ ] Note: `DeleteNoteUseCase` + `DeleteNoteHandler` (DELETE → 204)
- [ ] `NoteNotFoundException` + `DomainExceptionHandlerInterface` パターン実装
- [ ] Note: `NoteRepositoryInterface` を Protocol として整備
- [ ] Tag: `TagEntity`, `TagRepositoryInterface`, `InMemoryTagRepository`
- [ ] Tag: `ListTagsUseCase`, `GetTagUseCase`, `CreateTagUseCase`, `UpdateTagUseCase`, `DeleteTagUseCase`
- [ ] Tag: 5 handler (List, Get, Create, Update, Delete)
- [ ] `TagNotFoundException`
- [ ] `/health` エンドポイント (`HealthCheckInterface` + 基本応答)
- [ ] GitHub Actions CI(pytest + mypy + ruff + pip-audit)
- [ ] `.env.example`

**完了定義**: `GET/POST/PUT/DELETE /notes` と `GET/POST/PUT/DELETE /tags` が InMemory で動作し、CI が green になること。
- [x] Note: `UpdateNoteUseCase` + `UpdateNoteHandler` (PUT)
- [x] Note: `DeleteNoteUseCase` + `DeleteNoteHandler` (DELETE → 204)
- [x] `NoteNotFoundException` + `DomainExceptionHandlerInterface` パターン実装
- [x] Note: `NoteRepositoryInterface` を Protocol として整備
- [x] Tag: `TagEntity`, `TagRepositoryInterface`, `InMemoryTagRepository`
- [x] Tag: `ListTagsUseCase`, `GetTagUseCase`, `CreateTagUseCase`, `UpdateTagUseCase`, `DeleteTagUseCase`
- [x] Tag: 5 handler (List, Get, Create, Update, Delete)
- [x] `TagNotFoundException`
- [x] `/health` エンドポイント (`HealthCheckInterface` + 基本応答)
- [x] GitHub Actions CI(pytest + mypy + ruff + pip-audit)
- [x] `.env.example`

---

### v0.3.0 — Real Database
### v0.3.0 — Real Database ✅ DONE

**ゴール**: InMemory を SQLite に差し替えて本番利用可能な状態にする。

- [ ] `nene2.database`: `DatabaseConnectionInterface`, `DatabaseQueryExecutorInterface`, `DatabaseTransactionManagerInterface`
- [ ] `SqliteDatabaseConnection` 実装
- [ ] `SqliteNoteRepository`(CRUD 全操作)
- [ ] `SqliteTagRepository`(CRUD 全操作)
- [ ] Alembic セットアップ(`alembic init`, initial migration)
- [ ] `DatabaseHealthCheck`(接続確認 → `/health` に統合)
- [ ] テスト: DB テストを InMemory と SQLite の両方で実行する戦略を確立
- [ ] `DB_ADAPTER=sqlite` の場合の環境変数ドキュメント

**完了定義**: `DB_ADAPTER=sqlite DB_NAME=./data/nene2.db` で起動し、Note/Tag の CRUD が永続化されること。
- [x] `nene2.database`: `DatabaseQueryExecutorInterface`, `DatabaseTransactionManagerInterface`
- [x] `SqlAlchemyQueryExecutor` 実装
- [x] `SqliteNoteRepository`(CRUD 全操作)
- [x] `SqliteTagRepository`(CRUD 全操作)
- [x] Alembic セットアップ(`alembic init`, initial migration)
- [x] `DatabaseHealthCheck`(接続確認 → `/health` に統合)
- [x] テスト: DB テストを InMemory と SQLite の両方で実行する戦略を確立
- [x] `DB_ADAPTER=sqlite` の場合の環境変数ドキュメント

---

Expand Down
4 changes: 3 additions & 1 deletion src/example/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
SqlAlchemyQueryExecutor,
)
from nene2.http import HealthStatus
from nene2.middleware import ErrorHandlerMiddleware
from nene2.middleware import ErrorHandlerMiddleware, SecurityHeadersMiddleware
from nene2.validation.exceptions import ValidationException

from .note.exceptions import NoteNotFoundExceptionHandler
Expand Down Expand Up @@ -67,6 +67,8 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
openapi_url="/openapi.json",
)

if cfg.security_headers_enabled:
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(
ErrorHandlerMiddleware,
debug=cfg.app_debug,
Expand Down
1 change: 1 addition & 0 deletions src/nene2/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class AppSettings(BaseSettings):
app_env: str = "local"
app_debug: bool = False
app_name: str = "nene2-python"
security_headers_enabled: bool = True

db_adapter: str = "sqlite"
db_name: str = ":memory:"
Expand Down
3 changes: 2 additions & 1 deletion src/nene2/middleware/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

from .domain_exception import DomainExceptionHandlerProtocol
from .error_handler import ErrorHandlerMiddleware
from .security_headers import SecurityHeadersMiddleware

__all__ = ["DomainExceptionHandlerProtocol", "ErrorHandlerMiddleware"]
__all__ = ["DomainExceptionHandlerProtocol", "ErrorHandlerMiddleware", "SecurityHeadersMiddleware"]
26 changes: 26 additions & 0 deletions src/nene2/middleware/security_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Security headers middleware.

Adds defensive HTTP headers to every response.
"""

from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response

_HEADERS: dict[str, str] = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'",
"Permissions-Policy": "geolocation=(), microphone=()",
}


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Attach security headers to every HTTP response."""

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
for header, value in _HEADERS.items():
response.headers[header] = value
return response
43 changes: 43 additions & 0 deletions tests/nene2/middleware/test_security_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Tests for SecurityHeadersMiddleware."""

from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient

from nene2.middleware import SecurityHeadersMiddleware


def _make_app() -> FastAPI:
app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)

@app.get("/ping")
async def ping() -> JSONResponse:
return JSONResponse({"ok": True})

return app


def test_security_headers_present() -> None:
client = TestClient(_make_app())
response = client.get("/ping")
assert response.status_code == 200
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
assert "Content-Security-Policy" in response.headers
assert "Permissions-Policy" in response.headers


def test_security_headers_on_error_response() -> None:
app = FastAPI()
app.add_middleware(SecurityHeadersMiddleware)

@app.get("/boom")
async def boom() -> JSONResponse:
return JSONResponse({}, status_code=404)

client = TestClient(app)
response = client.get("/boom")
assert response.status_code == 404
assert response.headers["X-Content-Type-Options"] == "nosniff"
Loading