メインコンテンツへスキップ
友田 陽大
Flask 本番運用
Python
Flask
REST API
MethodView
Blueprint
アーキテクチャ設計
バックエンド

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エラーエンベロープを公式ドキュメントに忠実な実コードで解説します。

公開日
読了時間
34分
著者
友田 陽大
シェア
目次

導入: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 を設計する に分けてあります。本記事は「API の骨格」を、その記事が「境界の門番」を担います。

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


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

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

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

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

❌ アンチパターン(RPC 的)✅ REST 的
GET /getUserListGET /users
POST /createUserPOST /users
POST /deleteUser?id=5DELETE /users/5
POST /updateUserNamePATCH /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 記事 の §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/(コレクション)は、扱う対象が違うからです。

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())
  • ItemAPIget / patch / delete を持ち、すべて id を引数に取ります(個別アイテムの操作)。
  • GroupAPIget(一覧)と post(新規作成)を持ち、id を取りません(コレクションの操作)。

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

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

2.2 as_viewadd_url_rule:クラスを URL に束ねる

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

# 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_viewUser を渡したことで、同じ ItemAPI クラスを PostOrder でも再利用できます——コンストラクタ引数でモデルを差し替えるだけです。これが次の register_api ファクトリにつながります。

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

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

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 ——この境界を厳守してください。

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 本番運用ガイド の §5 で触れています。

2.5 decorators:認証をクラス全体に適用する

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

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 本番運用ガイド のセキュリティ節を参照してください。


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

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

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

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

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 の nameapi_v1)は endpoint の前綴りであって URL ではない、という Flask の重要仕様は 大規模アプリ構成ガイド の §4 で詳説しています。URL は次の url_prefix が決めます。

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

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

# 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
Blueprinturl_prefix生成される URL 例
api_v1/api/v1GET /api/v1/orders/, GET /api/v1/orders/5
api_v2/api/v2GET /api/v2/orders/, GET /api/v2/orders/5

create_app でファクトリと拡張をどう組むか(extensions.py の裸の拡張 → init_app、循環 import の回避)は 大規模アプリ構成ガイド を前提とします。本記事はその上に「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 です。

@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浮動小数点数価格・座標
uuidUUID 文字列公開 ID に UUID を使う設計
pathスラッシュを含む文字列ファイルパス的なリソース
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.gettype= で型変換と既定値を一度に扱えます。

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)です。上のコードで statusper_page を取り出したあと、「その値が許可された値か」「型と値域が正しいか」を検証する責務は、ビューではなく境界(スキーマ)にあります。本記事はクエリパラメータを取り出すところまでを扱い、検証してエラーに変換する部分は marshmallow に委ねます

# 本記事のスコープ:パラメータを取り出し、リソースを設計する
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 記事 を参照してください。

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

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

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 オブジェクトから件数・ページ数を取り出せます。

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 ガイド を参照してください。to_json() を marshmallow の dump() に置き換える接続は marshmallow × Flask 記事 の §9 に対応します。

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

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

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

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

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) metatotal 以外の情報(適用したフィルタ・サーバー時刻など)を後から足せる、(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": {...}} に揃えるのが対称で読みやすい形です。

{
  "error": {
    "code": "validation_error",
    "message": "リクエストの検証に失敗しました。",
    "details": {
      "email": ["メールアドレスの形式が正しくありません。"],
      "total": ["0 より大きい値である必要があります。"]
    }
  }
}
  • code:機械可読なエラー種別(クライアントが分岐に使う)。
  • message:人間可読な要約。
  • details:フィールドごとの詳細(検証エラーで使う。422 のとき marshmallow の err.messages を入れる)。

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

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

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

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

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

このエラーエンベロープを @app.errorhandler でアプリ全体に 1 箇所登録する仕組み——HTTPException の一括ハンドリング、Blueprint と app のハンドラ解決順序、構造化ログとの連携——は、エラー処理・ロギング・可観測性ガイド で詳説しています。本記事は「エラーの封筒の形(規約)」を、その記事が「封筒を生成するハンドラの仕組み」を担います。marshmallow の ValidationError を 422 に集約する具体は marshmallow × Flask 記事 の §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 回目以降は前回の結果を返す」ようにします。

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. いつ手書きをやめ、フレームワークに移るか

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

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

アプローチ得られるものコスト
素の MethodView + marshmallow最大の制御・最小の依存OpenAPI を自前で書く/別ツールで生成する
flask-smorestMethodView 拡張)スキーマから 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)ガイド で扱います。本記事の規約は、smorest を使う場合でもそのまま「設計の物差し」として効きます。

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


9. 実例:バージョン付き・ページング付き /api/v1/orders

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

# 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")
# 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=2200 + {data, meta}401(未認証)
GET /api/v1/orders/5200 + {data}404 / 401
POST /api/v1/orders/Idempotency-Key 付き)201 + Location / 再送は 200422(検証)/ 409(競合)/ 403(スコープ不足)
PATCH /api/v1/orders/5200 + {data}404 / 422
DELETE /api/v1/orders/5204(本文なし)404 / 401

to_json() を marshmallow の dump() に、update_from / create_fromload() に置き換えれば、marshmallow × Flask 記事 の境界設計とそのまま接続します。本記事はリソースの骨格と契約を、その記事が境界の中身を担う——この分業が、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
HTTPPUT / DELETE が冪等。POST の二重実行を冪等性キーで防ぐ§1.3, §7.2
HTTPPOST の 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_ruleregister_api ファクトリに集約し、多数リソースを 1 行で DRY に登録する。
  3. init_every_request=False ならリクエスト状態は g に置くself への書き込みは共有インスタンスへの競合)。認証は decorators クラス属性で「認証 → 認可」の順に適用する。
  4. API バージョニングは Blueprinturl_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 を設計する へ、自動ドキュメント化は flask-smorest ガイド へ、全体地図は Flask 本番運用ガイド へ——本記事の骨格を、各論で肉付けしてください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る