# FlaskのREST API設計：MethodView（クラスベースビュー）・Blueprintによるリソース設計・APIバージョニング・ページネーション・HTTPセマンティクス

> Flask 3.1系でREST APIを本番品質に設計する実践ガイド。MethodViewのitem/collection二クラス構成、as_view+add_url_ruleとregister_apiファクトリ、Blueprintによるリソース分割と/api/v1バージョニング、HTTPセマンティクス（冪等性・ステータスコード）、ページネーション・フィルタの規約、JSONエラーエンベロープを公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Flask, REST API, MethodView, Blueprint, アーキテクチャ設計, バックエンド
- URL: https://tomodahinata.com/blog/flask-rest-api-design-methodview-blueprint-versioning-guide

## 要点

- 良いREST APIはCRUDの寄せ集めではなく、リソースモデリング・正しいHTTPセマンティクス（動詞/ステータスコード/冪等性）・ステートレス性で決まる
- MethodViewは公式現行版が示すitem/collectionの二クラス構成が正解。as_view+add_url_ruleとregister_apiファクトリで多数のリソースをDRYに登録する
- init_every_request=Falseでインスタンスを再利用するなら、リクエスト中のデータはselfではなくgに置く。認証はdecoratorsクラス属性でas_viewの戻り値に適用する
- APIバージョニングはBlueprintのurl_prefix（/api/v1, /api/v2）で表現し、v1を非推奨化しながらv2へ段階移行する。バリデーションは境界（marshmallow）に、自動ドキュメントはflask-smorestに委ねる
- ページネーションはoffset/cursorを使い分け、{data, meta}エンベロープかLink/X-Total-Countヘッダで一貫させる。経済産業大臣賞B2B SaaSの221エンドポイント設計の知見を根拠に解説

---

## **導入：REST API の品質は「CRUD が動くこと」では決まらない**

Flask で REST API を書くのは簡単です。`@app.route` を並べ、`request.get_json()` を受け、`jsonify` で返す——これだけで「動く API」はできます。しかし、**動く API と、何年も拡張に耐える API は別物**です。後者を分けるのは、フレームワークの選定でも ORM の速さでもなく、**リソースをどうモデリングし、HTTP のセマンティクスをどこまで正しく守り、バージョンと構造をどう設計したか**です。

筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを **Python / Flask / SQLAlchemy / PostgreSQL** で設計・実装し、**221 エンドポイントの REST API** を API Gateway → ALB → ECS(Fargate) 上で本番運用してきました。エンドポイントが 10 を超え、100 を超え、200 を超えていく中で、痛感したことが 1 つあります——**API は「個々のエンドポイントの実装」ではなく「全体としての規約（コンベンション）」で生き死にする**、ということです。URL の付け方、ステータスコードの選び方、ページングの形、エラーの封筒、バージョンの切り方が 200 箇所でバラバラなら、それはもうメンテナンス不能です。

本記事は、その規約を **Flask 3.1 系（現行安定版）の公式ドキュメントに忠実**に、本番品質で設計するためのスポークです。スコープは **API の「構造と規約」**——`MethodView` によるリソース設計、`Blueprint` によるバージョニング、HTTP セマンティクス、ページネーション、エラーエンベロープ——に絞ります。**入力検証とレスポンス整形（シリアライゼーション）そのものは扱いません**。それは境界の責務として、対になる [marshmallow × Flask × SQLAlchemy で本番 REST API を設計する](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) に分けてあります。本記事は「API の骨格」を、その記事が「境界の門番」を担います。

> 💡 **この記事で扱うバージョン**：**Flask 3.1 系**（本稿執筆時点の最新安定版は **3.1.3**）を前提とします。Flask 3.1 は **Python 3.9 以上**が必要です。本稿のコードは公式ドキュメントのパターンに基づきます。アプリ構成（`create_app` / `Blueprint` の循環 import 回避）の前提知識は [大規模アプリ構成ガイド](/blog/flask-application-factory-blueprints-large-app-structure-guide) を、全体地図は [Flask 本番運用ガイド](/blog/flask-production-guide) を参照してください。

---

## **1. 良い REST API とは何か：CRUD の先にある 4 つの設計原則**

`MethodView` の話に入る前に、**「何を良いとするか」**の物差しを先に固めます。良い REST API は、次の 4 つを構造として満たしています。

### 1.1 リソースモデリング：URL は「動詞」ではなく「名詞」

REST の中心概念は **リソース**です。URL はリソースを指す名詞であり、それに対する操作は HTTP 動詞が表現します。`/getUsers` や `/createUser` のような「URL に動詞を埋め込む」設計は、RPC であって REST ではありません。

| ❌ アンチパターン（RPC 的） | ✅ REST 的 |
|---|---|
| `GET /getUserList` | `GET /users` |
| `POST /createUser` | `POST /users` |
| `POST /deleteUser?id=5` | `DELETE /users/5` |
| `POST /updateUserName` | `PATCH /users/5` |

リソースは **コレクション**（`/users`）と **個別アイテム**（`/users/5`）の 2 段で考えます。この 2 段構造が、後述する `MethodView` の二クラス設計（§2）にそのまま対応します。

### 1.2 正しい HTTP セマンティクス：動詞とステータスコード

各 HTTP 動詞には**意味（セマンティクス）**があり、それに従うことでクライアントは挙動を予測できます。

| 動詞 | 意味 | 冪等性 | 主なステータス |
|---|---|---|---|
| `GET` | 取得（副作用なし・安全） | 冪等 | 200 / 404 |
| `POST` | コレクションへ新規作成 | **非冪等** | 201（Location 付き）/ 400 / 422 / 409 |
| `PUT` | 完全置換（または冪等な作成） | 冪等 | 200 / 204 / 404 |
| `PATCH` | 部分更新 | （実装依存） | 200 / 404 / 422 |
| `DELETE` | 削除 | 冪等 | 204 / 404 |

ステータスコードは「だいたい 200 か 500」で済ませず、**意味に合わせて使い分ける**のが規約の核心です。

| コード | 名前 | いつ返すか |
|---|---|---|
| `200 OK` | 成功 | GET / PATCH / PUT の成功（ボディあり） |
| `201 Created` | 作成成功 | POST でリソース新規作成（`Location` ヘッダに新 URL） |
| `204 No Content` | 成功・本文なし | DELETE 成功、本文を返さない更新 |
| `400 Bad Request` | 不正リクエスト | JSON 構文エラー・必須パラメータ欠落など「構文」の問題 |
| `404 Not Found` | 不在 | 指定 ID のリソースが存在しない |
| `409 Conflict` | 競合 | 一意制約違反・楽観ロック衝突・重複作成 |
| `422 Unprocessable Entity` | 検証失敗 | 構文は正しいが**意味的に**処理できない（バリデーション失敗） |

> 💡 **400 と 422 の使い分け**：「JSON が壊れている／必須キーが無い」は **400**（構文レベル）、「JSON は正しいがメールアドレスの形式が不正・値域外」は **422**（意味レベル）と切り分けるのが実務的な合意です。marshmallow の `ValidationError` を 422 に集約する設計は [marshmallow × Flask 記事](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) の §6 で詳説しています。本記事はステータスコードの**選び方の規約**までを担い、検証ロジックそのものはそちらに委ねます。

### 1.3 冪等性：PUT / DELETE は「何回呼んでも同じ」

**冪等（idempotent）**とは「同じリクエストを 1 回送っても N 回送っても、サーバーの状態が同じになる」性質です。

- `DELETE /users/5` を 2 回送っても、結果は「user 5 が存在しない」状態で同じ → 冪等。
- `PUT /users/5`（完全置換）を 2 回送っても、最終状態は同じ → 冪等。
- `POST /users`（新規作成）を 2 回送ると、ユーザーが 2 人できる → **非冪等**。

この性質が決定的に効くのは**ネットワークが信頼できない**ときです。クライアントがタイムアウトでリトライしたとき、冪等な操作なら安全に再送できます。だからこそ「POST の冪等化」（重複作成の防止）が、決済のような場面で重要になります（§7.4 で扱う冪等性キー）。

### 1.4 ステートレス：サーバーはリクエスト間の状態を持たない

REST はステートレスです。各リクエストは、それ単独で処理に必要な情報をすべて含みます。サーバー側に「前のリクエストの続き」を持たない——これが水平スケール（ECS でタスク数を増やすだけで捌ける）の前提になります。認証情報も、セッション Cookie ではなく **`Authorization` ヘッダのトークン**でリクエストごとに運ぶのが、API では標準です。

> ⚠️ **アンチパターン**：API でサーバーサイドセッション（`session["user_id"] = ...`）に状態を持たせる。これはステートレス性を壊し、ワーカー間で session ストアを共有する必要を生みます。API 認証はトークンベース（Bearer / API キー）にして、リクエストを自己完結させてください。

---

## **2. `MethodView`：リソースをクラスで設計する**

`@app.route` の関数ベースは、1 つの URL に複数の動詞を載せると `if request.method == ...` の分岐が膨らみます。Flask は **クラスベースビュー `MethodView`** を提供しており、**HTTP メソッドをクラスの同名メソッドにマッピング**します。公式の言葉では「各 HTTP メソッドは、クラスの同名（小文字）のメソッドに対応する」。これがリソース指向の設計と噛み合います。

### 2.1 公式現行版の正解：item / collection の「二クラス構成」

ここが最重要かつ、世間のサンプルと食い違う点です。**Flask の現行公式ドキュメントは、1 つのクラスに全動詞を詰める設計ではなく、「個別アイテム用」と「コレクション用」の 2 クラスに分ける構成**を示しています。理由は §1.1 のリソース構造そのまま——`/users/<id>`（アイテム）と `/users/`（コレクション）は、扱う対象が違うからです。

```python
from flask.views import MethodView


class ItemAPI(MethodView):
    init_every_request = False

    def __init__(self, model):
        self.model = model
        self.validator = generate_validator(model)

    def _get_item(self, id):
        return self.model.query.get_or_404(id)

    def get(self, id):
        item = self._get_item(id)
        return jsonify(item.to_json())

    def patch(self, id):
        item = self._get_item(id)
        errors = self.validator.validate(item, request.json)
        if errors:
            return jsonify(errors), 400
        item.update_from_json(request.json)
        db.session.commit()
        return jsonify(item.to_json())

    def delete(self, id):
        item = self._get_item(id)
        db.session.delete(item)
        db.session.commit()
        return "", 204


class GroupAPI(MethodView):
    init_every_request = False

    def __init__(self, model):
        self.model = model
        self.validator = generate_validator(model, create=True)

    def get(self):
        items = self.model.query.all()
        return jsonify([item.to_json() for item in items])

    def post(self):
        errors = self.validator.validate(request.json)
        if errors:
            return jsonify(errors), 400
        db.session.add(self.model.from_json(request.json))
        db.session.commit()
        return jsonify(item.to_json())
```

- **`ItemAPI`** は `get` / `patch` / `delete` を持ち、すべて `id` を引数に取ります（個別アイテムの操作）。
- **`GroupAPI`** は `get`（一覧）と `post`（新規作成）を持ち、`id` を取りません（コレクションの操作）。

`methods` クラス属性は**定義したメソッドから自動設定**されます。`get` を定義すれば `GET`（と `HEAD`）が、`post` を定義すれば `POST` が、明示せずとも有効になります。

> 💡 **なぜ二クラスなのか**：「コレクションへの POST（作成）」と「アイテムの PATCH（更新）」では、**検証ルールが異なります**。上のコードで `GroupAPI` が `generate_validator(model, create=True)` を、`ItemAPI` が `generate_validator(model)` を使い分けているのがその表れです。作成時は全必須フィールドが要りますが、更新時は部分更新を許す——この差を「同じクラスの同じバリデータ」で表現しようとすると分岐だらけになります。二クラスは、リソースの構造（コレクション vs アイテム）と検証の差を、クラス境界で素直に分離します。

### 2.2 `as_view` と `add_url_rule`：クラスを URL に束ねる

`MethodView` のサブクラスは、そのままでは URL に紐づきません。**`as_view(name, *ctor_args)` でビュー関数に変換し、`add_url_rule` で URL に登録**します。`as_view` の第 1 引数は **endpoint 名**（`url_for` で使う）、それ以降の引数は **`__init__` に渡るコンストラクタ引数**です。

```python
# item は /users/<int:id> に、group は /users/ に束ねる
item = ItemAPI.as_view("user-item", User)    # User が __init__(self, model) に渡る
group = GroupAPI.as_view("user-group", User)

app.add_url_rule("/users/<int:id>", view_func=item)
app.add_url_rule("/users/", view_func=group)
```

この結果、ルートはこう振り分けられます。

| メソッド + URL | 対応するクラス・メソッド |
|---|---|
| `GET /users/` | `GroupAPI.get`（一覧） |
| `POST /users/` | `GroupAPI.post`（作成 → 201） |
| `GET /users/<id>` | `ItemAPI.get`（取得） |
| `PATCH /users/<id>` | `ItemAPI.patch`（部分更新） |
| `DELETE /users/<id>` | `ItemAPI.delete`（削除 → 204） |

`as_view` に `User` を渡したことで、同じ `ItemAPI` クラスを `Post` や `Order` でも再利用できます——コンストラクタ引数でモデルを差し替えるだけです。これが次の `register_api` ファクトリにつながります。

### 2.3 `register_api` ファクトリ：多数のリソースを DRY に登録する

API が 221 エンドポイントある、ということは数十のリソースがある、ということです。リソースごとに `as_view` を 2 回・`add_url_rule` を 2 回手で書くのは、**同じ知識（リソース登録の手順）の繰り返し**——典型的な DRY 違反です。公式はこれを **登録ファクトリ関数**にまとめる形を示しています。

```python
def register_api(app, model, name):
    item = ItemAPI.as_view(f"{name}-item", model)
    group = GroupAPI.as_view(f"{name}-group", model)
    app.add_url_rule(f"/{name}/<int:id>", view_func=item)
    app.add_url_rule(f"/{name}/", view_func=group)


register_api(app, User, "users")
register_api(app, Order, "orders")
register_api(app, Product, "products")
```

`register_api(app, User, "users")` の 1 行が、`/users/` と `/users/<id>` の 5 ルートをまとめて生成します。**新しいリソースを足すのは 1 行**。これが、エンドポイントが数十・数百になっても破綻しない登録の型です。リソース固有の振る舞いが必要なら、`ItemAPI` / `GroupAPI` をサブクラス化して `register_api` に渡すモデルとともに差し替えます。

> 💡 **221 エンドポイントを支えたのはこの「型」**：筆者の B2B SaaS では、リソースの登録手順を 1 箇所（ファクトリ）に集約したことが、エンドポイント数のスケールに耐えた最大の理由でした。「新しいリソースをどう生やすか」が 1 行に決まっていれば、レビューで見るべきは「URL 規約に沿っているか」だけになり、登録のボイラープレートをレビューする必要が消えます。規約が型になると、認知負荷が件数に比例しなくなります。

### 2.4 `init_every_request = False`：インスタンス再利用と「self に書かない」鉄則

`init_every_request` は、**ビューインスタンスをリクエストごとに作り直すか**を制御します。

- **既定（`True`）**：リクエストのたびに新しいインスタンスを生成し、`__init__` を毎回呼ぶ。
- **`False`**：1 つのインスタンスを生成し、**全リクエストで使い回す**。`__init__` は登録時に 1 回だけ。

`init_every_request = False` は、上の例のように「モデルとバリデータをコンストラクタで 1 回だけ束ねる」用途で効率的です。ただし**決定的に重要な制約**があります。

> ⚠️ **`self` への書き込みは安全ではない**：`init_every_request = False` でインスタンスを使い回すと、そのインスタンスは**複数のリクエスト（複数スレッド）で共有**されます。公式は明言しています——「**`self` への書き込みは安全ではない。リクエスト中にデータを保存する必要があるなら、`self` ではなく `g` を使え**」。リクエストごとに変わる値（現在のユーザー、リクエスト ID 等）を `self.current_user = ...` のように保持すると、別のリクエストの値で上書きされる競合が起きます。**読み取り専用の設定（model, validator）は `self`、リクエストごとの可変状態は `g`** ——この境界を厳守してください。

```python
from flask import g


class ItemAPI(MethodView):
    init_every_request = False

    def __init__(self, model):
        self.model = model          # ✅ 読み取り専用。全リクエストで共有してよい

    def get(self, id):
        g.request_started = time.time()   # ✅ リクエスト固有の状態は g に置く
        # self.request_started = ... は ❌（共有インスタンスへの書き込みは競合）
        item = self.model.query.get_or_404(id)
        return jsonify(item.to_json())
```

`g` がなぜリクエストごとに独立するのか（コンテキストローカルの仕組み）は [Flask 本番運用ガイド](/blog/flask-production-guide) の §5 で触れています。

### 2.5 `decorators`：認証をクラス全体に適用する

API のほとんどのリソースには認証が要ります。`MethodView` に `login_required` のようなデコレータを掛けるとき、**クラス自体にデコレータを書いても効きません**。デコレータは `as_view` が返す**ビュー関数**に適用する必要があります。Flask はこれを `decorators` クラス属性で宣言的に解決します。

```python
from myapp.auth import login_required, require_scope


class OrderItemAPI(MethodView):
    init_every_request = False
    # as_view が返すビュー関数に、リスト順に適用される
    decorators = [login_required, require_scope("orders:read")]

    def __init__(self, model):
        self.model = model

    def get(self, id):
        ...
```

> 💡 **`decorators` の適用順序は「下から上（bottom-up）」**：`decorators = [login_required, require_scope(...)]` の場合、`require_scope` が先（内側）に、`login_required` が後（外側）に適用されます。リクエストは外側から通るので、**まず `login_required` が走り、次に `require_scope` が走る**——つまり「認証 → 認可」の順になります。この順序はセキュリティ上意味があり、未認証リクエストをスコープ判定の前に弾けます。リストの順序を逆にすると、未認証でもスコープ判定に到達してしまう恐れがあります。認証・認可のデコレータ実装そのものは [Flask 本番運用ガイド](/blog/flask-production-guide) のセキュリティ節を参照してください。

---

## **3. `Blueprint` でリソースを束ね、API をバージョニングする**

`MethodView` が「1 リソースの設計単位」なら、`Blueprint` は「リソース群とバージョンの設計単位」です。ここで `register_api` を `app` ではなく `Blueprint` に対して呼ぶように一般化します。

### 3.1 リソース／ドメイン単位の Blueprint

API では、**Blueprint を「ドメイン（関心事）」または「バージョン」の単位**で切るのが実務的です。`register_api` の第 1 引数を `app` から `Blueprint` に変えるだけで、リソース登録を Blueprint に閉じられます。

```python
from flask import Blueprint

api_v1 = Blueprint("api_v1", __name__)


def register_api(bp, view_item, view_group, model, name):
    item = view_item.as_view(f"{name}-item", model)
    group = view_group.as_view(f"{name}-group", model)
    bp.add_url_rule(f"/{name}/<int:id>", view_func=item)
    bp.add_url_rule(f"/{name}/", view_func=group)


register_api(api_v1, ItemAPI, GroupAPI, Order, "orders")
register_api(api_v1, ItemAPI, GroupAPI, Product, "products")
```

Blueprint の `name`（`api_v1`）は **endpoint の前綴り**であって URL ではない、という Flask の重要仕様は [大規模アプリ構成ガイド](/blog/flask-application-factory-blueprints-large-app-structure-guide) の §4 で詳説しています。URL は次の `url_prefix` が決めます。

### 3.2 URL パスによるバージョニング：`url_prefix="/api/v1"`

API バージョニングには複数の流儀（URL パス・ヘッダ・メディアタイプ）がありますが、**URL パス方式（`/api/v1/...`）が最も明示的で、デバッグ・キャッシュ・ドキュメント化のすべてで扱いやすい**——これが実務での主流の合意です。Flask では、これは Blueprint の `url_prefix` を登録時に与えるだけで実現します。

```python
# create_app 内（アプリケーションファクトリ）
def create_app(config=None):
    app = Flask(__name__)
    # ...設定・拡張の init_app...

    from .api.v1 import api_v1
    from .api.v2 import api_v2

    app.register_blueprint(api_v1, url_prefix="/api/v1")
    app.register_blueprint(api_v2, url_prefix="/api/v2")

    return app
```

| Blueprint | url_prefix | 生成される URL 例 |
|---|---|---|
| `api_v1` | `/api/v1` | `GET /api/v1/orders/`, `GET /api/v1/orders/5` |
| `api_v2` | `/api/v2` | `GET /api/v2/orders/`, `GET /api/v2/orders/5` |

`create_app` でファクトリと拡張をどう組むか（`extensions.py` の裸の拡張 → `init_app`、循環 import の回避）は [大規模アプリ構成ガイド](/blog/flask-application-factory-blueprints-large-app-structure-guide) を前提とします。本記事はその上に「API のバージョン層」を載せる形です。

### 3.3 非推奨化（deprecation）の段階的移行戦略

バージョニングの本質は「v2 を作ること」ではなく、**「v1 を安全に畳むこと」**です。クライアントは一斉に移行できないので、**両バージョンを並走させながら v1 を非推奨化**します。筆者が本番で採った段取りはこうです。

| フェーズ | 状態 | クライアントへのシグナル |
|---|---|---|
| 1. v2 公開 | v1 と v2 を並走。v1 はそのまま動く | （まだ何もしない） |
| 2. v1 非推奨化 | v1 に `Deprecation` / `Sunset` ヘッダを付与 | レスポンスヘッダで廃止予定日を通知 |
| 3. 移行期間 | アクセスログで v1 利用クライアントを特定し個別連絡 | ドキュメントに移行ガイド |
| 4. v1 廃止 | v1 Blueprint を登録解除（または 410 Gone を返す） | 事前告知済みの日付で停止 |

非推奨ヘッダは、Blueprint の `after_request` で一括付与すると DRY です。

```python
@api_v1.after_request
def add_deprecation_headers(response):
    # RFC 準拠の Deprecation / Sunset ヘッダで廃止予定をクライアントに伝える
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "Wed, 31 Dec 2026 23:59:59 GMT"
    response.headers["Link"] = '</api/v2/docs>; rel="successor-version"'
    return response
```

> 💡 **「バージョンを切る勇気より、畳む規律」**：API バージョニングで失敗するのは、v2 を作れないからではなく、**v1 を永遠に畳めず両方をメンテし続ける**からです。畳む規律（非推奨ヘッダ → 移行期間 → 廃止）を最初に決めておくこと、そして**そもそもバージョンを切らずに済む後方互換な変更（フィールドの追加は破壊的でない）を優先すること**が、本当のバージョニング戦略です。破壊的変更（フィールド削除・型変更・必須化）が避けられないときだけ、新バージョンを切ります。

---

## **4. リクエストの受け口：ルート変換子とクエリパラメータ**

リソースの形が決まったら、次は「リクエストから情報をどう取り出すか」です。これも規約として揃えます。

### 4.1 ルート変換子（converters）で URL を型付けする

Flask の URL ルールは `<converter:name>` で**パス変数を型付き**にできます。`/users/<id>` ではなく `/users/<int:id>` と書くことで、`id` は文字列ではなく `int` としてビューに届き、`/users/abc` は自動で 404 になります。

| 変換子 | 受け取るもの | 用途 |
|---|---|---|
| `string`（既定） | スラッシュを含まない文字列 | スラッグ等 |
| `int` | 整数 | 数値 ID（最頻出） |
| `float` | 浮動小数点数 | 価格・座標 |
| `uuid` | UUID 文字列 | 公開 ID に UUID を使う設計 |
| `path` | **スラッシュを含む**文字列 | ファイルパス的なリソース |

```python
app.add_url_rule("/orders/<int:id>", view_func=item)          # 数値 ID
app.add_url_rule("/orders/<uuid:public_id>", view_func=item)  # UUID を公開 ID に
```

> 💡 **公開 ID に連番整数を使わない選択**：`int` の連番 ID は「`/orders/41` の隣は `/orders/42`」と推測でき、件数や他社の注文番号が漏れる（情報列挙）リスクがあります。外部公開 API では `uuid` 変換子で UUID を公開 ID にし、内部の連番主キーと分けるのが堅牢です。これはセキュリティ（最小情報開示）とリソースモデリングが交わる設計判断です。

メソッドの明示は `methods=["GET", "POST"]`、ショートカットは `@app.get` / `@app.post` です。公式仕様として **`GET` を定義すると `HEAD` が自動追加され、`OPTIONS` も自動実装**されます。CORS プリフライト（`OPTIONS`）を自前で書かなくてよいのはこのためです。

### 4.2 クエリパラメータ：フィルタ・ソート・ページング

絞り込み・並べ替え・ページングは、**URL パスではなくクエリパラメータ**で表現します（リソースの同一性は変えず、その「見え方」を変えるため）。`request.args.get` は `type=` で型変換と既定値を一度に扱えます。

```python
from flask import request


class OrderGroupAPI(MethodView):
    init_every_request = False

    def get(self):
        # 型付きで取り出し、既定値を与える
        status = request.args.get("status", type=str)            # ?status=paid
        sort = request.args.get("sort", default="-created_at")   # ?sort=-created_at
        page = request.args.get("page", default=1, type=int)
        per_page = request.args.get("per_page", default=20, type=int)
        per_page = min(per_page, 100)   # 上限で DoS を防ぐ（§5.4）

        stmt = build_order_query(status=status, sort=sort)
        pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
        return jsonify(paginated_envelope(pagination))
```

ソートの規約は **`sort=-created_at`（先頭の `-` で降順）** が広く使われます。複数キーは `sort=-created_at,name` のようにカンマ区切りで受けます。フィルタは `?status=paid&min_total=1000` のようにフィールド名をそのままキーにするのが直感的です。

### 4.3 検証はどこに置くか：本記事の「縫い目」

ここが本記事と marshmallow 記事の**縫い目（seam）**です。上のコードで `status` や `per_page` を取り出したあと、**「その値が許可された値か」「型と値域が正しいか」を検証する責務は、ビューではなく境界（スキーマ）にあります**。本記事はクエリパラメータを**取り出すところまで**を扱い、**検証してエラーに変換する部分は marshmallow に委ねます**。

```python
# 本記事のスコープ：パラメータを取り出し、リソースを設計する
page = request.args.get("page", default=1, type=int)

# marshmallow 記事のスコープ：取り出した値を検証し、不正なら 422 に変換する
#   → load() で QueryArgsSchema を通し、ValidationError を errorhandler が 422 へ
```

ビュー関数に `if not 1 <= page <= 1000:` のような検証 `if` を散らかすのは技術的負債です。**取り出し（本記事）と検証（marshmallow 記事）の責務を分ける**——この層分離が、200 エンドポイントを保守可能に保ちます。検証スキーマの組み方は [marshmallow × Flask 記事](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) を参照してください。

### 4.4 コンテンツネゴシエーション：JSON を返す

`jsonify(...)` は `Content-Type: application/json` の `Response` を返します。Flask 2.2 以降は、**ビューから `dict` や `list` を返すだけでも自動で JSON レスポンスに変換**されます。ヘッダやステータスを細かく制御したいときは `make_response(...)` を使います。

```python
from flask import jsonify, make_response

# dict / list を返すだけで JSON 化される（201 など明示したいときはタプル）
def get(self, id):
    return self.model.query.get_or_404(id).to_json()      # → 200 + application/json

# Location ヘッダ付きの 201 を返す
def post(self):
    order = create_order(request.json)
    resp = make_response(jsonify(order.to_json()), 201)
    resp.headers["Location"] = url_for("api_v1.orders-item", id=order.id)
    return resp
```

POST で 201 を返すときは、**`Location` ヘッダに作成されたリソースの URL を入れる**のが HTTP の作法です（§1.2）。`url_for` で endpoint から URL を逆引きすれば、パスをハードコードせずに済みます。

---

## **5. ページネーションとフィルタの規約**

一覧 API は、件数が増えれば必ずページングが要ります。問題は「どの方式で、どんな形で返すか」を**全エンドポイントで揃える**ことです。

### 5.1 offset/page 方式 vs cursor 方式

ページングには大きく 2 方式あり、トレードオフが明確です。

| 方式 | 仕組み | 長所 | 短所 |
|---|---|---|---|
| **offset / page** | `?page=3&per_page=20`（= `OFFSET 40`） | 実装が単純、任意ページへジャンプ可、総件数が出せる | 深いページで `OFFSET` が遅い、挿入で項目がずれる |
| **cursor（keyset）** | `?after=<cursor>`（= `WHERE id > ?`） | 深いページでも高速、挿入でずれない | 任意ページにジャンプ不可、総件数が出しにくい |

判断基準はシンプルです。**管理画面のように「総数とページ番号」が要るなら offset、無限スクロールや大量データの順次取得なら cursor**。筆者の B2B SaaS では、管理画面の一覧は offset、モバイル向けの活動フィードは cursor、と用途で使い分けました。

### 5.2 offset 方式の実装：`db.paginate`

Flask-SQLAlchemy は `db.paginate()` で offset ページングを提供します。`Pagination` オブジェクトから件数・ページ数を取り出せます。

```python
def get(self):
    page = request.args.get("page", default=1, type=int)
    per_page = min(request.args.get("per_page", default=20, type=int), 100)

    stmt = db.select(Order).order_by(Order.created_at.desc())
    pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)

    return jsonify({
        "data": [o.to_json() for o in pagination.items],
        "meta": {
            "page": pagination.page,
            "per_page": pagination.per_page,
            "total": pagination.total,
            "pages": pagination.pages,
        },
    })
```

`error_out=False` で、存在しないページ番号でも 404 を投げず**空配列**を返します（末尾を超えても安全）。`db.paginate` の詳細・N+1 を踏まない先読み（`selectinload`）は [Flask-SQLAlchemy / Flask-Migrate ガイド](/blog/flask-sqlalchemy-flask-migrate-database-production-guide) を参照してください。`to_json()` を marshmallow の `dump()` に置き換える接続は [marshmallow × Flask 記事](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) の §9 に対応します。

### 5.3 レスポンス形：エンベロープ か ヘッダ か

ページ情報の運び方は 2 流派あり、**どちらかに統一する**ことが規約の要点です。

**(A) エンベロープ方式**：本文を `{"data": [...], "meta": {...}}` で包む。ページ情報がボディに乗るので扱いやすく、ブラウザの開発者ツールでも見える。上の例がこれです。

**(B) ヘッダ方式**：本文は配列そのまま（`[...]`）で返し、ページ情報を `Link` / `X-Total-Count` ヘッダに載せる。GitHub API などが採る方式。

```python
def get(self):
    pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)
    resp = make_response(jsonify([o.to_json() for o in pagination.items]))
    resp.headers["X-Total-Count"] = str(pagination.total)
    # 次/前ページへの URL を RFC 5988 の Link ヘッダで提供
    links = []
    if pagination.has_next:
        links.append(f'<{url_for("api_v1.orders-group", page=page + 1, _external=True)}>; rel="next"')
    if pagination.has_prev:
        links.append(f'<{url_for("api_v1.orders-group", page=page - 1, _external=True)}>; rel="prev"')
    if links:
        resp.headers["Link"] = ", ".join(links)
    return resp
```

> 💡 **エンベロープを推す理由**：筆者はエンベロープ方式（A）を推します。理由は 3 つ——(1) ページ情報がボディに乗るので、ヘッダを読まないクライアントでも確実に届く、(2) `meta` に `total` 以外の情報（適用したフィルタ・サーバー時刻など）を後から足せる、(3) 単体取得と一覧取得でレスポンスの「封筒」を `{"data": ...}` に統一でき、クライアントのパース処理が 1 種類で済む。ヘッダ方式は「本文が純粋な配列で美しい」利点がありますが、メタ情報の拡張余地が乏しいのが実務での難点です。**重要なのは美しさより一貫性**——どちらを選んでも、全エンドポイントで同じ形にしてください。

### 5.4 ページングは「上限」で守る

`per_page` に上限を設けない API は、`?per_page=1000000` の一撃で DB とメモリを枯渇させられます（DoS）。**`per_page = min(requested, 100)` のような上限**を、全一覧エンドポイントの規約にしてください。外部入力を境界で検証する原則（§4.3）は、ページングパラメータにも等しく適用されます。

---

## **6. エラー設計：一貫した JSON エンベロープ**

エラーレスポンスの形が 200 箇所でバラバラだと、クライアントは「どのフィールドにエラー理由が入っているか」を URL ごとに調べる羽目になります。**エラーは 1 つの封筒に統一**します。

### 6.1 一貫したエラーエンベロープ

成功レスポンスが `{"data": ...}` なら、エラーは `{"error": {...}}` に揃えるのが対称で読みやすい形です。

```json
{
  "error": {
    "code": "validation_error",
    "message": "リクエストの検証に失敗しました。",
    "details": {
      "email": ["メールアドレスの形式が正しくありません。"],
      "total": ["0 より大きい値である必要があります。"]
    }
  }
}
```

- `code`：機械可読なエラー種別（クライアントが分岐に使う）。
- `message`：人間可読な要約。
- `details`：フィールドごとの詳細（検証エラーで使う。422 のとき marshmallow の `err.messages` を入れる）。

### 6.2 ステータスコードとエラーの対応

§1.2 のステータスコード規約を、エラー設計に落とすとこうなります。

| 状況 | コード | `code` の例 |
|---|---|---|
| JSON 構文エラー・必須パラメータ欠落 | 400 | `bad_request` |
| 認証なし／トークン無効 | 401 | `unauthorized` |
| 権限不足 | 403 | `forbidden` |
| リソース不在 | 404 | `not_found` |
| 一意制約違反・楽観ロック衝突・重複作成 | 409 | `conflict` |
| バリデーション失敗（値域・形式） | 422 | `validation_error` |

> ⚠️ **409 Conflict を 500 で握り潰さない**：一意制約違反（`UniqueViolation`）や楽観ロックの衝突を `try/except` せず素通しすると、DB 例外が 500 として漏れます。これは「サーバーのバグ」ではなく「クライアントが解決可能な競合」なので、**409 を返してリトライ可能であることを伝える**のが正しい設計です。重複作成（同じメールでのサインアップ等）も 409 が適切です。冪等性キー（§7.4）と組み合わせると、リトライ時の二重作成を 409 ではなく「前回の結果を返す」形にもできます。

### 6.3 ハンドラの集約は別記事へ

このエラーエンベロープを **`@app.errorhandler` でアプリ全体に 1 箇所登録する**仕組み——`HTTPException` の一括ハンドリング、Blueprint と app のハンドラ解決順序、構造化ログとの連携——は、[エラー処理・ロギング・可観測性ガイド](/blog/flask-error-handling-logging-observability-guide) で詳説しています。本記事は「**エラーの封筒の形（規約）**」を、その記事が「**封筒を生成するハンドラの仕組み**」を担います。marshmallow の `ValidationError` を 422 に集約する具体は [marshmallow × Flask 記事](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) の §6 です。

---

## **7. API 設計コンベンション表：迷わないための規約集**

ここまでの判断を、レビューで参照できる**規約表**に凝縮します。チームで API を設計するとき、この表を「合意済みの既定」にしておくと、エンドポイントごとの議論が消えます。

| 項目 | 規約 | 理由 |
|---|---|---|
| URL の名詞 | **複数形**（`/orders`、`/users`） | コレクションとアイテムを `/orders` / `/orders/5` で一貫表現 |
| 動詞 | URL に動詞を入れない（HTTP 動詞で表現） | RPC ではなく REST |
| ネスト深さ | **1 段まで**（`/orders/5/items`）。2 段以上は避ける | 深いネストは URL が脆くなる。`/items?order_id=5` を検討 |
| ケース | URL は kebab-case、JSON キーは snake_case か camelCase に統一 | 混在が最大の保守コスト |
| ページング | 全一覧で同じ方式・同じ封筒、`per_page` に上限 | クライアントのパースを 1 種類に |
| エラー | `{"error": {code, message, details}}` で統一 | クライアントの分岐を機械化 |
| バージョン | `/api/v{n}` の URL パス方式 | 明示的・キャッシュとドキュメントに優しい |
| 日時 | ISO 8601・UTC（`2026-06-26T09:00:00Z`） | タイムゾーンの曖昧さを排除 |

### 7.1 HATEOAS への現実的なスタンス

REST の理論的完成形である **HATEOAS**（レスポンスに次の操作へのリンクを埋め込む）は、理想的ですが、**多くの内部 API では過剰**です。クライアントが URL を組み立てる手間は減りますが、リンク生成のコストと、結局クライアントが URL をハードコードしてしまう現実を考えると、費用対効果が合わないことが多い。

筆者の現実的なスタンスは——**ページングの `next` / `prev` リンク（§5.3 の `Link` ヘッダ）だけは入れる価値がある**が、全リソースに `_links` を生やす完全な HATEOAS は、公開 API でクライアント実装を疎結合にしたい明確な要件があるときに限る、というものです。YAGNI を適用すべき典型例です。

### 7.2 冪等性キー：POST を安全にリトライ可能にする

§1.3 の通り POST は非冪等です。決済や注文作成のように「二重実行が致命的」な POST には、**冪等性キー（Idempotency-Key）**を導入します。クライアントが生成した一意キーをヘッダで送り、サーバーは「同じキーの POST は一度だけ処理し、2 回目以降は前回の結果を返す」ようにします。

```python
class OrderGroupAPI(MethodView):
    init_every_request = False

    def post(self):
        key = request.headers.get("Idempotency-Key")
        if key:
            cached = get_idempotent_result(key)   # 既存キーなら前回の結果を返す
            if cached is not None:
                return jsonify(cached), 200
        order = create_order(request.json)
        db.session.commit()
        body = order.to_json()
        if key:
            store_idempotent_result(key, body)    # キーと結果を紐付けて保存
        return jsonify(body), 201
```

これにより、クライアントがタイムアウトでリトライしても注文が二重に作られません。**「ネットワークは必ず失敗する」前提で、非冪等な操作を冪等化する**——これは信頼性設計（リトライの安全性）の核心であり、決済を扱う B2B SaaS では必須でした。冪等性は本サイトを貫くテーマです。

---

## **8. いつ手書きをやめ、フレームワークに移るか**

ここまで `MethodView` と `Blueprint` で API を手で組んできました。しかし、API が育つと**避けられない要求**が出てきます——**OpenAPI（Swagger）ドキュメントの自動生成**です。

エンドポイントが 200 を超えると、ドキュメントを手で書いて同期し続けるのは現実的に不可能です。ここで判断が分かれます。

| アプローチ | 得られるもの | コスト |
|---|---|---|
| 素の `MethodView` + marshmallow | 最大の制御・最小の依存 | OpenAPI を自前で書く／別ツールで生成する |
| **flask-smorest**（`MethodView` 拡張） | スキーマから **OpenAPI/Swagger UI を自動生成**、ページング・エラーの規約も提供 | 規約をフレームワークに合わせる |

**flask-smorest は `MethodView` を土台にした拡張**で、本記事で組んだ二クラス構成・marshmallow スキーマ・Blueprint をほぼそのまま活かしながら、`@blp.response(...)` / `@blp.arguments(...)` デコレータでスキーマを宣言すると **OpenAPI 仕様と Swagger UI を自動生成**してくれます。本記事で築いた規約（リソース設計・ステータスコード・ページング・エラー封筒）が頭に入っていれば、smorest はそれを**ボイラープレートなしで実装する近道**になります。

> 💡 **判断の分水嶺**：エンドポイントが十数個で、ドキュメントを README で足りる規模なら、素の `MethodView` の透明性（魔法がない）が勝ります。しかし**外部公開 API・チーム開発・エンドポイント数十以上**になったら、ドキュメントの自動同期という要求が手書きのコストを上回ります。そこが smorest へ移る分水嶺です。移行の具体（`Blueprint` の差し替え・スキーマ宣言・Swagger UI）は [Flask の OpenAPI / Swagger（flask-smorest）ガイド](/blog/flask-openapi-swagger-flask-smorest-api-documentation-guide) で扱います。本記事の規約は、smorest を使う場合でもそのまま「設計の物差し」として効きます。

そもそも「Flask で手組みするか、自動検証・自動ドキュメントが標準装備の FastAPI を選ぶか」という上流の技術選定は、[Flask vs FastAPI vs Django 技術選定ガイド](/blog/flask-vs-fastapi-vs-django-comparison-guide) にまとめています。FastAPI は Pydantic による型ヒントからの**自動バリデーションと自動 OpenAPI** が標準で、本記事で手当てした規約の多くがフレームワーク既定で得られます。その対比を知った上で Flask を選ぶなら、本記事の規律が活きます。

---

## **9. 実例：バージョン付き・ページング付き `/api/v1/orders`**

最後に、ここまでの設計を 1 つのリソースに統合します。**注文（Order）**を題材に、二クラス構成・Blueprint バージョニング・ページング・ステータスコードを通しで組みます（検証は marshmallow に委ね、ここでは縫い目だけ示します）。

```python
# api/v1/orders.py
import time

from flask import Blueprint, g, jsonify, make_response, request, url_for
from flask.views import MethodView

from myapp.extensions import db
from myapp.models import Order
from myapp.auth import login_required, require_scope

api_v1 = Blueprint("api_v1", __name__)


class OrderItemAPI(MethodView):
    init_every_request = False
    decorators = [login_required]

    def __init__(self, model):
        self.model = model

    def get(self, id):
        # 不在なら 404（errorhandler が JSON エンベロープ化）
        order = db.get_or_404(self.model, id)
        return jsonify({"data": order.to_json()})

    def patch(self, id):
        order = db.get_or_404(self.model, id)
        # 検証は境界（marshmallow）へ。失敗時の 422 化は errorhandler に集約
        order.update_from(request.get_json())
        db.session.commit()
        return jsonify({"data": order.to_json()})

    def delete(self, id):
        order = db.get_or_404(self.model, id)
        db.session.delete(order)
        db.session.commit()
        return "", 204   # 削除成功は本文なしの 204


class OrderGroupAPI(MethodView):
    init_every_request = False
    decorators = [login_required, require_scope("orders:write")]

    def __init__(self, model):
        self.model = model

    def get(self):
        g.t0 = time.time()   # リクエスト固有の状態は self ではなく g（§2.4）
        status = request.args.get("status", type=str)
        page = request.args.get("page", default=1, type=int)
        per_page = min(request.args.get("per_page", default=20, type=int), 100)

        stmt = db.select(self.model).order_by(self.model.created_at.desc())
        if status:
            stmt = stmt.where(self.model.status == status)
        pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False)

        return jsonify({
            "data": [o.to_json() for o in pagination.items],
            "meta": {
                "page": pagination.page,
                "per_page": pagination.per_page,
                "total": pagination.total,
                "pages": pagination.pages,
            },
        })

    def post(self):
        # 冪等性キーで二重作成を防ぐ（§7.2）
        key = request.headers.get("Idempotency-Key")
        if key and (cached := get_idempotent_result(key)) is not None:
            return jsonify({"data": cached}), 200

        order = Order.create_from(request.get_json())
        db.session.add(order)
        db.session.commit()
        body = order.to_json()
        if key:
            store_idempotent_result(key, body)

        # 201 + Location ヘッダに新リソースの URL（§4.4）
        resp = make_response(jsonify({"data": body}), 201)
        resp.headers["Location"] = url_for("api_v1.orders-item", id=order.id)
        return resp


def register_api(bp, view_item, view_group, model, name):
    item = view_item.as_view(f"{name}-item", model)
    group = view_group.as_view(f"{name}-group", model)
    bp.add_url_rule(f"/{name}/<int:id>", view_func=item)
    bp.add_url_rule(f"/{name}/", view_func=group)


register_api(api_v1, OrderItemAPI, OrderGroupAPI, Order, "orders")
```

```python
# app/__init__.py（create_app 内で v1 をマウント）
from .api.v1.orders import api_v1

app.register_blueprint(api_v1, url_prefix="/api/v1")
```

このリソースが応える契約は、規約通りに揃っています。

| リクエスト | 成功 | 失敗 |
|---|---|---|
| `GET /api/v1/orders/?status=paid&page=2` | 200 + `{data, meta}` | 401（未認証） |
| `GET /api/v1/orders/5` | 200 + `{data}` | 404 / 401 |
| `POST /api/v1/orders/`（`Idempotency-Key` 付き） | 201 + `Location` / 再送は 200 | 422（検証）/ 409（競合）/ 403（スコープ不足） |
| `PATCH /api/v1/orders/5` | 200 + `{data}` | 404 / 422 |
| `DELETE /api/v1/orders/5` | 204（本文なし） | 404 / 401 |

`to_json()` を marshmallow の `dump()` に、`update_from` / `create_from` を `load()` に置き換えれば、[marshmallow × Flask 記事](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) の境界設計とそのまま接続します。本記事はリソースの**骨格と契約**を、その記事が**境界の中身**を担う——この分業が、221 エンドポイントを保守可能に保った設計です。

---

## **10. REST API 設計チェックリスト**

レビューやコードレビュー（Boy Scout Rule）で参照するための、本記事の規約を凝縮したチェックリストです。

| 観点 | チェック項目 | 本記事の節 |
|---|---|---|
| リソース | URL は複数形の名詞で、動詞を含まない | §1.1, §7 |
| リソース | コレクション（`/orders`）とアイテム（`/orders/5`）を 2 段で表現 | §1.1, §2.1 |
| 構造 | `MethodView` の item / collection 二クラス構成になっている | §2.1 |
| 構造 | 多数リソースは `register_api` ファクトリで DRY に登録 | §2.3 |
| 構造 | `init_every_request=False` 時、リクエスト状態は `self` でなく `g` | §2.4 |
| 認証 | 認証/認可は `decorators` クラス属性で（順序は認証→認可） | §2.5 |
| HTTP | 動詞のセマンティクスとステータスコードが正しい（201/204/409/422） | §1.2, §6.2 |
| HTTP | PUT / DELETE が冪等。POST の二重実行を冪等性キーで防ぐ | §1.3, §7.2 |
| HTTP | POST の 201 に `Location` ヘッダを付ける | §4.4 |
| バージョン | `/api/v{n}` の URL パス方式。畳む規律（Deprecation/Sunset）がある | §3.2, §3.3 |
| 入力 | パス変数は型付き変換子（`<int:id>` / `<uuid:>`） | §4.1 |
| 入力 | クエリのフィルタ・ソート・ページングを規約で統一 | §4.2 |
| 境界 | 検証はビューでなく marshmallow（スキーマ）に置く | §4.3 |
| ページング | offset/cursor を用途で使い分け、`per_page` に上限 | §5.1, §5.4 |
| ページング | 全エンドポイントで同じ封筒（`{data, meta}` か Link/X-Total-Count） | §5.3 |
| エラー | `{"error": {code, message, details}}` で統一 | §6.1 |
| ステートレス | API 認証はトークンで自己完結。サーバーセッションに状態を持たない | §1.4 |
| ドキュメント | 規模が育ったら flask-smorest で OpenAPI 自動生成へ移行 | §8 |

---

## **まとめ：REST API は「規約の一貫性」で生き死にする**

Flask で REST API を設計する本質は、個々のエンドポイントを巧みに実装することではなく、**全体を貫く規約を 1 つに決め、それを構造として強制する**ことです。本記事の要点を再掲します。

1. **良い API は CRUD の先**にある。リソースモデリング（名詞 URL）・正しい HTTP セマンティクス（動詞・ステータスコード・冪等性）・ステートレス性が土台。
2. **`MethodView` は item / collection の二クラス構成**が公式現行版の正解。`as_view` + `add_url_rule` を `register_api` ファクトリに集約し、多数リソースを 1 行で DRY に登録する。
3. **`init_every_request=False` ならリクエスト状態は `g` に置く**（`self` への書き込みは共有インスタンスへの競合）。認証は `decorators` クラス属性で「認証 → 認可」の順に適用する。
4. **API バージョニングは `Blueprint` の `url_prefix="/api/v1"`** で表現し、`Deprecation` / `Sunset` ヘッダで v1 を段階的に畳む規律をセットで持つ。
5. **ページングは offset/cursor を用途で使い分け**、`{data, meta}` エンベロープか `Link`/`X-Total-Count` ヘッダで全エンドポイントを一貫させ、`per_page` に上限を設ける。
6. **検証は marshmallow へ、ハンドラの集約はエラー処理記事へ、自動ドキュメントは flask-smorest へ**——本記事は「API の骨格と規約」に責務を絞り、各論を専用記事に委ねる。

「動く REST API」と「221 エンドポイントを何年も保守できる REST API」を分けるのは、フレームワークでも ORM でもなく、**規約をどれだけ一貫させ、構造（ファクトリ・二クラス・Blueprint）として強制できたか**です。Flask は「核だけ」を提供し、この規約はあなたが設計します。その自由に責任を持つための物差しが、本記事のチェックリストです。

API の境界（入力検証・レスポンス整形）は [marshmallow × Flask × SQLAlchemy で本番 REST API を設計する](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) へ、自動ドキュメント化は [flask-smorest ガイド](/blog/flask-openapi-swagger-flask-smorest-api-documentation-guide) へ、全体地図は [Flask 本番運用ガイド](/blog/flask-production-guide) へ——本記事の骨格を、各論で肉付けしてください。
