導入: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 /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 記事 の §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())
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__ に渡るコンストラクタ引数です。
# 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 違反です。公式はこれを 登録ファクトリ関数にまとめる形を示しています。
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 のほとんどのリソースには認証が要ります。MethodView に login_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_api を app ではなく 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 の name(api_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
| 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 の回避)は 大規模アプリ構成ガイド を前提とします。本記事はその上に「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 | 浮動小数点数 | 価格・座標 |
uuid | UUID 文字列 | 公開 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.get は type= で型変換と既定値を一度に扱えます。
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 に委ねます。
# 本記事のスコープ:パラメータを取り出し、リソースを設計する
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/json の Response を返します。Flask 2.2 以降は、ビューから dict や list を返すだけでも自動で 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)
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": {...}} に揃えるのが対称で読みやすい形です。
{
"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 のハンドラ解決順序、構造化ログとの連携——は、エラー処理・ロギング・可観測性ガイド で詳説しています。本記事は「エラーの封筒の形(規約)」を、その記事が「封筒を生成するハンドラの仕組み」を担います。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. いつ手書きをやめ、フレームワークに移るか
ここまで 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)ガイド で扱います。本記事の規約は、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=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 記事 の境界設計とそのまま接続します。本記事はリソースの骨格と契約を、その記事が境界の中身を担う——この分業が、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 つに決め、それを構造として強制することです。本記事の要点を再掲します。
- 良い API は CRUD の先にある。リソースモデリング(名詞 URL)・正しい HTTP セマンティクス(動詞・ステータスコード・冪等性)・ステートレス性が土台。
MethodViewは item / collection の二クラス構成が公式現行版の正解。as_view+add_url_ruleをregister_apiファクトリに集約し、多数リソースを 1 行で DRY に登録する。init_every_request=Falseならリクエスト状態はgに置く(selfへの書き込みは共有インスタンスへの競合)。認証はdecoratorsクラス属性で「認証 → 認可」の順に適用する。- API バージョニングは
Blueprintのurl_prefix="/api/v1"で表現し、Deprecation/Sunsetヘッダで v1 を段階的に畳む規律をセットで持つ。 - ページングは offset/cursor を用途で使い分け、
{data, meta}エンベロープかLink/X-Total-Countヘッダで全エンドポイントを一貫させ、per_pageに上限を設ける。 - 検証は marshmallow へ、ハンドラの集約はエラー処理記事へ、自動ドキュメントは flask-smorest へ——本記事は「API の骨格と規約」に責務を絞り、各論を専用記事に委ねる。
「動く REST API」と「221 エンドポイントを何年も保守できる REST API」を分けるのは、フレームワークでも ORM でもなく、規約をどれだけ一貫させ、構造(ファクトリ・二クラス・Blueprint)として強制できたかです。Flask は「核だけ」を提供し、この規約はあなたが設計します。その自由に責任を持つための物差しが、本記事のチェックリストです。
API の境界(入力検証・レスポンス整形)は marshmallow × Flask × SQLAlchemy で本番 REST API を設計する へ、自動ドキュメント化は flask-smorest ガイド へ、全体地図は Flask 本番運用ガイド へ——本記事の骨格を、各論で肉付けしてください。