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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ CORS_ORIGINS=[]
# DB_USER=nene2
# DB_PASSWORD=secret

# レートリミット(APP_ENV=local では未設定時オフ。有効化する場合のみ)
# THROTTLE_ENABLED=true
# THROTTLE_LIMIT=60
# THROTTLE_WINDOW=60

# デフォルト値
DB_ADAPTER=sqlite
DB_NAME=:memory:
Expand Down
189 changes: 189 additions & 0 deletions docs/field-trials/2026-05-field-trial-212.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# FT212: dataclasses モジュール — field / asdict / astuple / replace / __post_init__

**日付**: 2026-05-22
**テーマ**: Python `dataclasses` モジュールの field / asdict / astuple / replace / __post_init__ の実装と検証
**セキュリティ診断**: なし(212 % 3 = 2)
**クラッカーペンテスト**: あり(212 % 4 = 0)

---

## 概要

`dataclasses` モジュールは Python 3.7 以降標準の構造化データクラス生成ユーティリティ。今 FT では静的解析だけでなく「実行時のデータ変換・不変条件の強制」パターンを重点的に検証した。

| API | ユースケース |
|---|---|
| `@dataclass(frozen=True, slots=True)` | immutable value object(メモリ効率・ハッシュ可能) |
| `field(default_factory=list/dict)` | ミュータブルデフォルトの安全な管理 |
| `asdict()` | dataclass → dict 変換(深いコピー) |
| `astuple()` | dataclass → tuple 変換 |
| `replace()` | フィールドを置換した新インスタンス生成(immutable 更新) |
| `__post_init__` | 初期化後の不変条件チェック |

---

## 実装したサンプルアプリ

**場所**: `/home/xi/docker/nene2-python-FT/ft212-dataclasses/`

### 主要機能

| 関数/クラス | 概要 |
|---|---|
| `Vector3D` | `frozen=True, slots=True` + `__post_init__` で有限値・範囲チェック |
| `vector_to_dict` | `asdict(Vector3D(...))` で dict 変換・大きさ計算 |
| `vector_to_tuple` | `astuple(Vector3D(...))` で tuple 変換 |
| `translate_vector` | `replace(original, x=..., y=..., z=...)` で新インスタンスを生成 |
| `TaggedItem` | `field(default_factory=list/dict)` のミュータブルデフォルト管理 |
| `BoundedRange` | `__post_init__` で `low < high` の不変条件を強制 |

### エンドポイント

| メソッド | パス | 概要 |
|---|---|---|
| POST | `/dataclasses/vector-dict` | asdict 変換・大きさ計算 |
| POST | `/dataclasses/vector-tuple` | astuple 変換 |
| POST | `/dataclasses/vector-translate` | replace による並進移動 |
| POST | `/dataclasses/tagged-item` | field(default_factory) のデモ |
| POST | `/dataclasses/range` | __post_init__ で low < high を強制 |

---

## 摩擦点

### F-1: `astuple` の戻り値型は常に `tuple[object, ...]`

**観察**: `astuple(vector)` の戻り値型が `tuple[Any, ...]` となるため、型安全に `tuple[float, float, float]` として使いたい場合は別途キャストまたは型注釈が必要。

**対処**: `components = astuple(vector)` の後に `VectorTupleResult(components=components, ...)` で包んで型安全な返却を行うことで mypy エラーを回避。`astuple` は深いコピーの変換ユーティリティとして使い、型付きの結果は dataclass でラップするパターンが有効。

---

### F-2(HIGH): `Infinity`/`NaN` 非標準 JSON がエラーシリアライザーを 500 クラッシュさせる

**発見**: クラッカーペンテストで発見。非標準 JSON ボディを直接バイト列で送信した場合:

```
POST /dataclasses/vector-dict
Content-Type: application/json
Body: {"x": Infinity, "y": 0.0, "z": 0.0}
```

**挙動連鎖**:
1. Python 3.14 の JSON パーサーが `Infinity` を `float('inf')` として受け入れる(JSON spec 違反だが Python が許容)
2. Pydantic の `ge=-1000000, le=1000000` バリデーションが `inf` に対して False → `RequestValidationError` を発生
3. FastAPI のデフォルト `RequestValidationError` ハンドラーが `jsonable_encoder(exc.errors())` を呼ぶ
4. エラー詳細に `{'input': inf}` が含まれ、`json.dumps(inf)` が `ValueError: Out of range float values are not JSON compliant` で失敗
5. **結果: 422 ではなく 500** → DoS ベクター

**修正**: カスタム `RequestValidationError` ハンドラーを登録し、`_sanitize_value()` で非有限 float を文字列 `"inf"`/`"nan"` に変換してから 422 を返す。

```python
def _sanitize_value(value: object) -> object:
if isinstance(value, float) and not math.isfinite(value):
return str(value)
if isinstance(value, dict):
return {k: _sanitize_value(v) for k, v in value.items()}
if isinstance(value, list):
return [_sanitize_value(item) for item in value]
return value

@application.exception_handler(RequestValidationError)
async def _safe_validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
sanitized = [_sanitize_value(err) for err in exc.errors()]
return JSONResponse(status_code=422, content={"detail": sanitized})
```

**根本対策**: nene2 の `ErrorHandlerMiddleware` または `RequestValidationError` ハンドラーを標準化して非有限 float をサニタイズすべき(Issue #594 参照)。

---

## クラッカーペンテスト結果

| 攻撃シナリオ | 結果 | 対処 |
|---|---|---|
| `Infinity` を非標準 JSON で送信 | **修正済み (F-2)** → 422 | `_sanitize_value` + カスタムハンドラー |
| `NaN` を非標準 JSON で送信 | **修正済み (F-2)** → 422 | 同上 |
| `MAX_COORDINATE+1` の座標 | 422(Pydantic `le=` で遮断) | 対策済み |
| `MAX_COORDINATE` の座標 → 境界値 | 200(正常) | 問題なし |
| translate で合計が範囲超え | 422(`__post_init__` で遮断) | 対策済み |
| タグ 51 個(上限 50) | 422(Pydantic `max_length=50` で遮断) | 対策済み |
| タグ 200 個 | 422(同上) | 対策済み |
| タグ文字列 600 文字 | 422(`_BoundedStr max_length=500`) | 対策済み |
| range `NaN` 非標準 JSON | **修正済み (F-2)** → 422 | 同上 |
| 空文字列の name | 422(`min_length=1`) | 対策済み |

**総合評価: 堅牢**(F-2 修正後)

---

## テスト結果

```
25 passed in 0.44s
```

`pytest`, `mypy --strict`, `ruff check`, `ruff format --check` すべて通過。

---

## DX Review — 6 ペルソナ

### 1. 初心者(Python 歴1年・独学中・女性・バックエンド志望)

`@dataclass` は「属性と `__init__` を自動生成してくれる魔法」として理解できる。`frozen=True` が「変更できないクラス」という概念は分かりやすい。`field(default_factory=list)` の必要性(`tags: list = []` が危険な理由)は初見では気づきにくいが、`TaggedItem` のデモを見れば「なぜ必要か」が伝わる。

**ドキュメント理解**: `asdict` と `astuple` の違い(dict = 名前付き / tuple = 順序依存)は具体例があれば分かりやすい。

**事故リスク(中)**: `field(default_factory=list)` を知らないと `tags: list = []` を書いて全インスタンスがリストを共有するバグが起きる。nene2 の `dataclass(frozen=True, slots=True)` 強制はこのリスクを大幅に削減する(frozen だとリスト型フィールドを持てないので必然的に `field(default_factory)` を使う)。

**規約の使いやすさ**: `__post_init__` バリデーションは「コンストラクタ後に実行される」という説明があれば直感的。

### 2. ロースキル経験者(Python 歴3-4年・スクリプト系・男性・SES)

`asdict` で dict に変換して JSON に渡すパターンはよく使う。`replace` による immutable 更新は TypeScript の spread (`{...obj, x: 10}`) と類似概念で理解しやすい。`__post_init__` は Django の `clean()` に相当する。

**コピペ可能性**: `Vector3D + translate_vector` のパターンはほぼそのまま2Dゲームや座標計算に流用できる。

**拡張時の罠**: `astuple` はネストしたデータクラスも再帰的にタプル化する。`asdict` も同様。これを知らないと予期しない構造が返ってくる可能性がある。

**事故リスク(低)**: `__post_init__` でバリデーションしているため、無効な状態のオブジェクトが生成されない。

### 3. フロントエンド寄り(React/TS 歴4年・バックエンド転向中・ノンバイナリ)

TypeScript の `readonly interface` と `Object.freeze()` に相当する。`replace()` は TypeScript の `{...original, x: newX}` スプレッドと同じ意図。`field(default_factory)` は TypeScript では不要(参照型をクラスに持たせるとき React の state 更新パターンと類似する)。

**エラーレスポンスの質**: 422 の詳細が`{field, message, code}` 形式で一貫しており TS のバリデーションエラーと同様に扱える。

**Python 固有概念**: `__post_init__` はコンストラクタと別ファイルで定義できないが、TS のクラスコンストラクタよりシンプル。

**事故リスク(低)**: F-2 修正後は 422 を返すため、フロントエンドが適切にエラーを処理できる。

### 4. バックエンド経験者(Django/FastAPI 歴5-6年・男性・リードエンジニア)

`asdict` + `replace` パターンは Django の `model_to_dict` + `Model(instance=obj)` より軽量。`frozen=True, slots=True` の組み合わせは Python のベストプラクティスとして定着しており評価できる。

**他フレームワークとの差異**: Django ORM の `Model` はミュータブルだが、nene2 の domain 層は全て `frozen=True` で設計されており、値の変更は `replace()` を通じてのみ行う。これにより副作用が生じにくい。

**nene2 の薄さへの評価**: F-2 の修正が app.py の `create_app()` にカスタムハンドラーを追加する形になっているのは理想的ではない(フレームワーク側で解決すべき)。Issue として追跡していることは評価できる。

**事故リスク(低)**: 境界値チェックが Pydantic と `__post_init__` で二重に保護されており堅牢。

### 5. シニアエンジニア(設計・コードレビュー担当・女性・10-12年)

**コードレビューチェックポイント**:
- `dataclass` でリスト/dict フィールドを使う場合、`field(default_factory=...)` を使っているか
- `frozen=True` の dataclass では `__post_init__` のバリデーションに `object.__setattr__` が不要(`frozen=True` でも `__post_init__` で値を読むことはできる)
- `asdict` の戻り値が `dict[str, Any]` になる点と `astuple` の型消失を理解した上で使っているか

**チームでの安全なパターン**: `__post_init__` でバリデーションして `ValueError` を上げ、HTTP 層で `ValidationException` に変換するパターンは明確で良い。

**事故リスク(低)**: F-2 修正後は全攻撃シナリオで 422 を返す。

### 6. 設計者(nene2-python 設計ポリシー目線)

**CLAUDE.md ポリシー整合性**: `frozen=True, slots=True` を標準とするポリシーが今 FT でも一貫して適用されている。`field(default_factory)` パターンは Python の `dataclasses` ベストプラクティスとして CLAUDE.md に追記する価値がある。

**初心者でも安全な API 達成度**: `_Coord = Annotated[float, Field(ge=..., le=...)]` でバウンダリを型エイリアスにまとめ、全エンドポイントで一貫した境界チェックを実現。

**改善提案**: F-2 で発見した `Infinity`/`NaN` DoS 問題を nene2 フレームワークレベルで修正する(`ErrorHandlerMiddleware` に `RequestValidationError` の安全なシリアライズを統合)。これは全 FT に共通する問題であり、今後の FT でも再発する可能性がある。
5 changes: 3 additions & 2 deletions docs/field-trials/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@
| [FT209](2026-05-field-trial-209.md) | functools モジュール — partial / lru_cache / reduce / wraps | | |
| [FT210](2026-05-field-trial-210.md) | contextlib モジュール — contextmanager / suppress / ExitStack / nullcontext | 🔒 | |
| [FT211](2026-05-field-trial-211.md) | typing モジュール — TypedDict / Protocol / runtime_checkable / get_type_hints / Literal | | |
| [FT212](2026-05-field-trial-212.md) | dataclasses モジュール — field / asdict / astuple / replace / __post_init__ | 🔍 | |

---

Expand All @@ -256,8 +257,8 @@ FT3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60, 6

## クラッカーペンテスト実施済み一覧(🔍)

FT172, FT176, FT180, FT184, FT188, FT192, FT196, FT200, FT204, FT208
FT172, FT176, FT180, FT184, FT188, FT192, FT196, FT200, FT204, FT208, FT212

---

*最終更新: 2026-05-22 (FT211 / v1.8.88)*
*最終更新: 2026-05-22 (FT212 / v1.8.89)*
17 changes: 9 additions & 8 deletions docs/todo/current.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# TODO — current

最終更新: 2026-05-22
現状: **v1.8.88 安定版 / FT211(typing)完了**
現状: **v1.8.89 安定版 / FT212(dataclasses)完了**

---

## 状態サマリー

v1.8.88 完了済み。FT211(typingTypedDict / Protocol / runtime_checkable / get_type_hints / Literal / Annotated)完了。
`isinstance` 後も mypy は Protocol 型に絞り込まない(F-1)を確認
Pydantic フィールドに `Literal` 型を直接使えば手動バリデーション + `type: ignore` が不要(F-2)を実証。フィールドトライアルループは FT212 以降も継続中。
v1.8.89 完了済み。FT212(dataclassesfield / asdict / astuple / replace / __post_init__)完了。
クラッカーペンテストで `Infinity`/`NaN` 非標準 JSON → FastAPI エラーシリアライザー 500 DoS を発見・修正(F-2 HIGH)
`_sanitize_value()` + カスタム `RequestValidationError` ハンドラーで 422 に修正。フィールドトライアルループは FT213 以降も継続中。

---

Expand All @@ -33,6 +33,7 @@ Pydantic フィールドに `Literal` 型を直接使えば手動バリデーシ

| バージョン | 主な内容 |
|---|---|
| v1.8.89 | FT212: dataclasses — field / asdict / astuple / replace / __post_init__(Infinity/NaN 500 DoS 発見・修正)|
| v1.8.88 | FT211: typing — TypedDict / Protocol / get_type_hints / Literal(isinstance 後の型絞り込み・Literal+Pydantic で type:ignore 排除)|
| v1.8.87 | FT210: contextlib — contextmanager / suppress / ExitStack / nullcontext(__exit__ None 型・list[str] per-item length 制約)|
| v1.8.86 | FT209: functools — partial / lru_cache / reduce / wraps(@wraps ANN401 回避・Python 3.14 type: ignore 不要化) |
Expand Down Expand Up @@ -62,21 +63,21 @@ Pydantic フィールドに `Literal` 型を直接使えば手動バリデーシ

## フィールドトライアル進捗

**実施済み**: FT1〜FT211(全 211 件)
**実施済み**: FT1〜FT212(全 212 件)

索引: [`docs/field-trials/INDEX.md`](../field-trials/INDEX.md)

**次のアクション**:
- FT212 を開始(212 % 3 = 2セキュリティ診断なし、212 % 4 = 0クラッカーペンテストあり
- テーマ候補: `dataclasses` モジュール(field / asdict / astuple / replace / post_init
- FT213 を開始(213 % 3 = 0セキュリティ診断あり、213 % 4 = 1クラッカーペンテストなし
- テーマ候補: `abc` モジュール(ABC / abstractmethod / register / __subclasshook__

---

## 明日以降の優先タスク

| 優先度 | Issue | タスク | 種別 |
|---|---|---|---|
| 高 | — | FT212 実施(セキュリティ診断なし、クラッカーペンテストあり) | FT |
| 高 | — | FT213 実施(セキュリティ診断あり、クラッカーペンテストなし) | FT |
| 中 | [#539](https://github.com/hideyukiMORI/nene2-python/issues/539) | handler の response_model 統一 | enhancement |
| 中 | [#540](https://github.com/hideyukiMORI/nene2-python/issues/540) | FT ループの目的・終着点を明文化 | docs |
| 中 | [#541](https://github.com/hideyukiMORI/nene2-python/issues/541) | PyPI 公開フロー検証(uv publish) | enhancement |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "nene2-python"
version = "1.8.88"
version = "1.8.89"
description = "NENE2 Python — minimal API framework following NENE2's design philosophy"
readme = "README.md"
license = {text = "MIT"}
Expand Down
3 changes: 3 additions & 0 deletions src/example/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ def _build_repositories(cfg: AppSettings) -> _Repos:

def create_app(settings: AppSettings | None = None) -> FastAPI:
cfg = settings or AppSettings()
# Local dev / consumer FT: throttle off unless THROTTLE_ENABLED is set (nene2-python#592).
if cfg.app_env == "local" and os.getenv("THROTTLE_ENABLED") is None:
cfg = cfg.model_copy(update={"throttle_enabled": False})
setup_logging(cfg.app_env)

app = FastAPI(
Expand Down
15 changes: 15 additions & 0 deletions tests/example/test_local_throttle_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Local example app disables throttle by default (FT / dev ergonomics)."""

from fastapi.testclient import TestClient

from example.app import create_app
from nene2.config import AppSettings


def test_local_app_allows_burst_without_429() -> None:
cfg = AppSettings(app_env="local")
assert cfg.throttle_enabled is True # settings default; create_app adjusts
client = TestClient(create_app(cfg))
for _ in range(30):
r = client.get("/examples/ping")
assert r.status_code == 200, r.text
Loading