REST エンドポイントの切り方
マダミス.jp は GraphQL からドメイン単位で REST(backend/src/rest/、Hono)へ移行している。 REST のエンドポイントを作るときの決まりと、その理由をまとめる。 個々のエンドポイントがなぜその形なのかは、各モジュールの routes.ts の冒頭のコメントにある。
エンドポイントの単位
- 1 キャッシュ単位 = 1 エンドポイント
- キャッシュ単位とは、フロントエンドで
'use cache'を付ける関数のこと - まだキャッシュしていない取得も「将来
'use cache'を付けるならどの関数になるか」で数える
粒度がずれると、片方のデータの都合でもう片方のキャッシュまで巻き添えで落ちたり、逆に 1 回で済む取得が往復に分かれたりする。 1 つのエンドポイントが 2 つのキャッシュ単位を相手にすると、鮮度の要求がきつい方に引きずられる。
1 つのキャッシュ単位を複数の入口が使うのは普通で、その場合もエンドポイントは 1 本のまま。 たとえば page.tsx と generateMetadata が同じ関数を呼ぶ場合、ストリーミングのために RSC を 2 つに割っても呼ぶ関数が同じ場合、無限スクロールの続きをクライアントコンポーネントが取りに行く場合がそれにあたる。
分割の基準
同じエンドポイントに載せてよいのは、次が全部一致するデータに限る。 一度公開したエンドポイントを分けるのは破壊的変更になって高くつくので、作るときに決める。
| 観点 | 揃っていないと起きること |
|---|---|
| キャッシュキー | キーの掛け算でエントリが増える |
鮮度(cacheLife) | 短い方に引きずられ、長くてよいデータまで取り直す |
無効化(cacheTag) | 関係ない更新でキャッシュが落ちる |
| リクエストごとに変わる値か | cookies() / headers() / searchParams は 'use cache' に入れられない |
例: rest/game_master_profile/routes.ts(キャッシュキーの違いで分けた)、rest/shop_profile/routes.ts(鮮度の違いで分けた)。
命名
- エンドポイント名 = キャッシュ単位の名前
- ケバブケースとキャメルケースを入れ替えるだけで往復できる形にする
- 揃える対象はキャッシュ単位そのもの。関数なら関数名、コンポーネントが自分で取得を持つならコンポーネント名
- バックエンドのモジュール名とサービス層の関数名も揃える
- 書き込みのエンドポイントは、同じものを読むエンドポイントと同じ名前にし、HTTP のメソッドで分ける(読み取りが
GET /api/game-master-profile/:displayIdなら、更新はPUT /api/game-master-profile) 'use cache'を使わないアプリ(店舗管理画面・管理画面)では、画面が呼ぶ取得関数を 1 つの単位として同じように名付ける
名前だけで両者を往復できれば、「このエンドポイントを叩いているのはどこか」を探すのに検索もコメントも要らない。 キャッシュ単位から受け取って描画するだけのコンポーネントは対象外にする。描画の都合で割るたびに名前が嘘になるため。
例: /api/game-master-profile ⇄ fetchGameMasterProfile ⇄ rest/game_master_profile/ の findGameMasterProfile。
displayId と数値 ID を同じ階層で受けない
displayId を直下に置くパス(/:displayId)に、数値 ID の経路(/:numericId{[0-9]+})を足さない。 displayId には数字だけの値が実在するので、数値側が先に一致してそのページが静かに壊れる。 両方が要るなら、displayId を /by-display-id/:displayId の下に分ける。
レスポンス
- レスポンスは必ず
rest/<module>/schema.tsの Zod スキーマのparseを通して返す(ESLint が止める) - そのスキーマを、そのままコンポーネントの
propsの型として使う - GraphQL の入れ子は画面が使う形に平らにしてよい(
user { id }をuserIdに) - 値の正規化はサーバー、文言の組み立ては画面側
- GraphQL と共通の整形は
backend/src/logic/に置いて両方から呼ぶ
parse を通すのは 2 つの理由による。 スキーマに無いキーが落ちるので、Prisma の行にある内部用の列が外に出ない。 また、Prisma の結果をそのまま返すと列挙型などが Prisma の型への参照のまま .d.ts に書き出され、フロントエンドが Prisma の生成型まで読み込む(rest/clientTypes.spec.ts が検査する)。
フロントエンドは Hono RPC の InferResponseType で型を導出するので、スキーマを直せば画面の型も動く。
エラーの返し方
- 本文は
{ error: '<理由>', message?: '<利用者向けの文言>' }。理由はスネークケース - ステータスは HTTP の意味どおりに使い、理由の分類は GraphQL の
extensions.code(backend/src/errors.ts)と 1 対 1 に揃える - 画面が出し分けるものだけ理由を分ける
messageは、サーバーでしか決められない文言(権限エラーなど)と、logic/などが投げた GraphQL と共有の文言だけに付ける。画面はあればそのまま出す- REST のハンドラは
errors.tsの例外を自分で投げず、c.json({ error, message }, status)で返す - 想定外のエラーはハンドラで握らずに投げる。
rest/handleRestError.tsが記録して 500 を返す
| 状況 | ステータス | 理由 | GraphQL |
|---|---|---|---|
| 未認証 | 401 | unauthorized | UNAUTHENTICATED |
| 権限が無い | 403 | forbidden | FORBIDDEN |
| リクエストの形が不正 | 400 | bad_request | BAD_USER_INPUT |
| 対象が無い | 404 | not_found | (なし) |
| 一意制約に先を越された | 409 | display_id_taken など個別の理由 | (なし) |
| 本文が大きすぎる | 413 | payload_too_large | (なし) |
| 想定外 | 500 | internal_server_error | INTERNAL_SERVER_ERROR |
理由を細かく分けるのは、画面が違う文言を出すときだけにする。 たとえば ID の重複は画面で事前に検証できず、利用者が直しようがないので分ける。 必須項目の不足は画面側の検証で先に止まるため、bad_request の一括でよい。
message を GraphQL の notifyMessage と同じ文言にそろえるのは、取得経路が REST でも GraphQL でも、利用者に同じ文言が見えるようにするため(GraphQL ではフロントエンドの useNotifyErrorLink が notifyMessage をそのまま表示する)。
「対象が無い」を 404 にするのは呼び出し側の分岐を減らすため。 フロントエンドの取得関数は、本文が { error: 'not_found' } の 404 だけを null に変換し、それ以外は例外にする(判定は frontend/src/lib/isNotFoundResponse.ts の isNotFoundResponse)。 ステータスだけで判定しないのは、ルートが無いとき(リリース中に古い backend に当たった、パスの設定を誤った)も Hono 既定のテキストの 404 が返るため。これを「対象が無い」と読むと、編集画面では既存のデータの代わりに空のフォームが出て、そのまま保存すると登録済みの内容が消える。
想定外のエラーの記録と、ログに載せないものは rest/handleRestError.ts のコメントにある。
モジュール構成
backend/src/rest/<module>/{routes,schema,service,client}.tsの 4 点セットroutes.tsはふつうの Hono で書き、prismaとサービス層を直接呼ぶ。export const <module>Routes = new Hono()...の形にするrest/index.tsでrestRoutes.route()に載せ、モジュール名 = パスの接頭辞 を保つclient.tsはhc<typeof <module>Routes>で型を作るclient.tsはpackage.jsonのexportsとtsup.client-types.config.tsのエントリにも追加する- frontend が backend から読み込むのは
client.ts(API の型)だけにする。backend の検証の決まり(Zod の schema、正規表現、上限値)はexportsに載せない service.tsには DB からの取得とレスポンスの形への組み立てだけを書く。検証・判定・整形はlogic/に置く(GraphQL との共通化)- ルートのテストを
routes.spec.tsに書き、restRoutes.request()で叩く。書き込みとログイン必須のエンドポイントでは必須にする(認証が validator より前にあること、入力の検証、404)。本体のprismaはvi.mock('../../prisma', () => import('../../lib/vitest/appPrisma.js'))でテスト用のトランザクションに向ける
どのエンドポイントも同じ形なら、初めて触るモジュールでも探さずに読める。
フロントエンド向けの型(.d.ts)は pnpm run build:client-types で書き出す。 関数の中身で使うだけの import は .d.ts に残らないので、routes.ts が Prisma を読み込んでもフロントエンドには届かない。 届くのはレスポンスの型だけなので、レスポンスの parse が守られていればよい。
frontend のフォームの入力の決まりと、backend が受け取った値の検証の決まりは、目的が違うので別々に持つ。入力の決まりは利用者に何をどう入力させるかの都合で決まり、受け取った値の検証は旧画面や直接のリクエストからも値を守るためにある。共有すると、片方の都合で変えたい値をもう片方に引きずられる。frontend の ESLint が、mdms_jp_backend から rest/*/client 以外を読み込むのを止める。
exports とエントリへの追加を忘れると、フロントエンドから型だけが解決できない状態になる。 型を書き出す設定とフロントエンドの設定が食い違わないことは rest/clientTypes.spec.ts が検査する。
GraphQL との共通化
- 検証・保存・判定・整形は
backend/src/logic/に関数として置き、GraphQL の resolver と REST の両方から呼ぶ - resolver とハンドラに残すのは入口の仕事だけ(認証、入力の受け取り、トランザクションの開始、エラーの変換)
- テストは
logic/の関数に書く。両方の経路を 1 回で守れる - GraphQL に新しい処理や修正を入れるときも
logic/に入れ、REST が同じ関数を呼ぶ形にする - REST に新しい処理を足すときは、移行が終わるまで旧 GraphQL からも同じ関数を呼ぶ
- 挙動を変える変更(検証を厳しくするなど)は、移植と別の PR にする。同じ PR に入れるときは、PR の説明に「挙動の変更」として何が変わるかを列挙する
- GraphQL(
src/schema)と REST(src/rest)はお互いを読み込まない(ESLint が止める)。両方で使うものはlogic/に移す - 旧 GraphQL からも呼ぶ義務は、ドメインごとに、そのドメインの GraphQL の query と mutation が呼ばれなくなり(Apollo のリクエストログの
operationNameで確かめる)、削除した時点で終わる
移行中は同じデータを扱う経路が 2 本並ぶ。 片方だけを直すと、経路によって結果が変わり、検証もすり抜けられる。 また backend と frontend は独立にリリースされ、ブラウザに残った旧画面は GraphQL を呼び続けるので、REST にだけ入った処理はその利用者には効かない。
例: logic/scenarioImageOrDefaultUrl.ts(GraphQL の Scenario.portraitImageOrDefaultUrl と REST の GM 一覧が同じ関数を呼ぶ)。
更新系エンドポイント
- 1 つの保存操作 = 1 エンドポイント。画面の「保存」で起きることを 1 リクエストにまとめる
- リクエストは差分ではなく、保存後にこうなっていてほしい全量を送る。増えた・消えた・並びが変わったの解決はサーバーでする
- 複数のテーブルに書くならトランザクションで囲む
- 子リソースの同一性は業務的なキーで見る。クライアントに主キーを持ち回らせない
- 並び順を持つ子リソースは、配列の順序をそのまま並び順の値に振り直す。順序の値をクライアントに計算させない
- 配列と文字列に上限を付け、本文にも
hono/body-limitで上限を付ける - 保存した内容を画面が読まないなら、204 で本文を返さない
読み取りの「1 キャッシュ単位 = 1 エンドポイント」と対になる決まり。画面の保存操作を GraphQL の mutation の粒度のまま写すと、往復の数とデータの一貫性がクライアント任せのまま残る。
全量を送るのは、差分の計算をクライアントに置くと、その正しさをサーバーから検証できないため。主キーを持ち回らせないのは、受け取った id をそのまま操作対象にすると他人の行を指せるため。ログインユーザーが今持っている行と業務的なキーで突き合わせれば、触る範囲がその利用者の内側に閉じる。
上限が要るのは、1 回の保存が 1 トランザクションで行数分を書き込むため。上限が無いと、1 リクエストで DB の接続とロックをトランザクションの timeout まで握れる。本文の上限は、検証より前に本文全体をメモリに読み込むので、巨大な本文 1 本で Pod のメモリを使い切らせないため。
保存後に読み直して返さないのは、トランザクションの中で入れ子の SELECT をすることになり、ロックを握る時間が延びるだけで得るものが無いため。
例: rest/game_master_compose/routes.ts(GM プロフィール編集)。
ページング
- オフセット方式(
?page=N)。perPageはサーバー側で固定し、クエリで可変にしない - 並び順は第 2 キーに
idを入れる(priority asc, id asc) - レスポンスは
items/totalCount/page/perPageで揃える - 範囲外のページは
items: []の 200。リソース自体が無い場合だけ 404
ページをまたいだときの取りこぼしと重複は画面から気付けないので、方式とレスポンスの形を固定して防ぐ。 第 2 キーが要るのは、利用者が編集する値などは同値が普通にあり、第 1 キーだけでは順序が不定になるため。
キャッシュ
- フロントエンドの RSC 側(
'use cache'+cacheLife+cacheTag)に置く - バックエンドのエンドポイントに、途中のキャッシュに載せる
Cache-Control(public/s-maxage)を付けない 'use cache: remote'は使わない(.claude/rules/mdms-jp-frontend-cache.mdの「'use cache: remote'への個人データ混入防止」)
Cache-Control を付けると途中のキャッシュに焼き付いて updateTag で剥がせなくなり、編集した本人にも更新前の内容が返り続ける。
認証
- ログイン必須のエンドポイントには
rest/requireRequester.tsのrequireRequesterを validator より前に付ける - 操作対象は
c.get('requester')から引く。リクエストに入っている id を操作対象にしない - 他人のリソースを id で受け取るとき(店舗の管理など)は、
logic/authorization.tsの判定をc.get('ctx')で呼び、権限が無ければ 403forbidden - 公開エンドポイントは、ログイン状態で中身を変えない。利用者ごとの情報(自分の予約、興味ありなど)は、ログイン必須の別エンドポイントに分け、ブラウザから
lib/apiBrowserAuthorized.tsで呼ぶ - クライアント種別で表示が変わる公開エンドポイントは、
rest/viewer.tsのresolveViewerで種別を解釈する。それ以外の公開エンドポイントはcontext()を通さない - リクエストのログは
restRoutes全体のミドルウェア(rest/index.ts)が残す。エンドポイントごとに足さない
エンドポイントごとに認証を手で書くと、書き漏らしが「誰でも読める・誰でも書ける」として表に出ないまま残る。 操作対象を requester から引けば、「他人の id なら弾く」判定ごと要らなくなり、書き漏らしようがない。
利用者ごとの情報を公開エンドポイントに混ぜないのは、利用者ごとに中身が変わるとキャッシュ単位が分かれるうえ、frontend がサーバーから呼ぶ経路(lib/api.ts)は Authorization を付けないので、混ぜても常に空になるため。
公開エンドポイントが context() を通さないのは、トークンを Firebase で検証するコストが毎回乗るうえ、ログイン状態で応答が変わる余地が生まれて 'use cache' に載せられなくなるため。
セキュリティの確認項目
REST のエンドポイントを足す・変えるときに確かめる。
| 項目 | 確かめること |
|---|---|
| ログインの確認漏れ | ログイン必須なら requireRequester を validator より前に付けている |
| 他人のリソースの操作 | 操作対象を requester から引いている。id を受け取るなら logic/authorization.ts の判定を通し、だめなら 403 |
| 管理者の扱い | ハンドラで requester.isAdmin の分岐を自分で書いていない(logic/authorization.ts の判定が冒頭で管理者を通す) |
| 他人のデータの読み出し | 任意の id を受け付けるなら、閲覧者自身のデータだけを返している。ある id から別の id を引いて返していない(非公開のものとの対応が漏れる) |
| 公開範囲 | 他人に見せる一覧で、非公開・隠した項目の絞り込みを一覧ごとに書かず、共通の where を使っている |
| 内部用の列の漏れ | レスポンスを schema の parse に通している |
| 書き込み量 | 配列と文字列に上限があり、本文に hono/body-limit を付けている(更新系エンドポイント) |
| 検証の抜け道 | 同じ行を書ける GraphQL の mutation が残っていれば、同じ検証(logic/)を通している |
| 途中のキャッシュ | 公開の応答に public / s-maxage の Cache-Control を付けていない(ログイン必須の応答は requireRequester が private, no-store を付ける) |
'use cache' への個人データ混入 | サーバー側の lib/api.ts と ssrClient に Authorization を足していない |
| エラーからの情報漏れ | 応答に例外のメッセージを入れていない。ログにリクエストのヘッダーと本文を足していない |
| 認証方式の変更 | Cookie での認証に切り替えるなら、CSRF の対策と CORS の許可リスト(index.ts の ALLOWED_ORIGIN_PATTERNS)の絞り込みを同時に入れる。Bearer トークンと違い、Cookie は許可した出どころからのリクエストに自動で付く |
ブラウザからの取得
- ルートハンドラを挟まず、ブラウザからバックエンドを直接叩く
- ログインが要らず、クライアント種別で表示が変わらないものは
lib/apiBrowser.ts - ログインが要るもの、またはクライアント種別で表示が変わるものは
lib/apiBrowserAuthorized.ts
lib/api.ts | lib/apiBrowser.ts | lib/apiBrowserAuthorized.ts | |
|---|---|---|---|
| 呼ぶ場所 | RSC / ルートハンドラ | ブラウザ | ブラウザ |
X-RateLimit-Bypass | 付ける | 付けない | 付けない |
MDMS_JP-Client-Type | 付ける | 付けない | 付ける |
Authorization | 付けない | 付けない | 付ける |
中継しないのは、1 リクエストごとに Vercel の関数実行と転送量が乗る一方、バックエンドの URL は GraphQL で公開済みなので隠す利点が無いため。 ヘッダーの付け分けの理由は各ファイルの冒頭のコメントにある。
WARNING
X-RateLimit-Bypass は秘密ではない。Cloud Armor はこのヘッダーの有無だけを見て値を照合しないので、外から付けて送れば誰でもレート制限を外せる。サーバーの取得が 1 つの IP に集約されるのを避けるためのもので、守りの手段として扱わない。
破壊的変更
- フィールドの削除・改名は、追加先行の 2 リリース分割で進める(詳細は
.claude/rules/mdms-jp-rest-compat.md) - エンドポイントの改名・分割も同じく、新しいエンドポイントを足して frontend を切り替えたリリースと、旧版を消すリリースに分ける。旧版の
routes.tsには、消す条件をTODO:で書く - 旧版を消す前に、REST のアクセスログ(
rest/index.ts)で、旧版のパスへのアクセスがゼロになっていることを確かめる
リリース中は旧フロントエンドが新しいレスポンスを読む窓が必ず生まれるため、一度に変えない。
旧版を残す期間は、呼ぶ側で決まる。frontend は Skew Protection(deploymentId)を使っていないので、サーバーから(lib/api.ts)だけ呼ぶエンドポイントは、切り替えた frontend が本番に出た時点で、frontend を前の版に戻さない限り呼ばれなくなる。ブラウザから呼ぶエンドポイントは、開いたままの旧画面からも呼ばれるので、ログでアクセスが無くなるまで残す。
WordPress プラグイン API
/api/plugin/v1(rest/plugin/)は、マダミス.jp の画面ではなく店舗の WordPress から呼ばれる。導入されたプラグインは店舗任せの更新で何年も古いまま動くので、このページの決まりのうち次は当てはめない。
| 決まり | プラグイン API での扱い |
|---|---|
| パス = モジュール名 | パスにバージョン(v1)を入れる。互換を壊すときは v2 を足して段階的に移す |
| 4 点セット | レスポンスの型は javascript/packages/mdms-plugin-contract に置き、プラグインと backend が同じ定義を読む。client.ts は作らない |
| 認証 | requireRequester ではなく、店舗ごとの API キー(rest/plugin/pluginApiKeyAuth.ts) |
| エラーの理由 | GraphQL の分類にそろえず、プラグインが出し分ける理由(validation_error・catalog_not_found・rate_limited など)を返す。WordPress の画面に出すエラーには、そのまま出せる文言の message を付ける |
| 破壊的変更 | v1 は追加だけにする。応答項目の削除・型の変更などは CI(javascript_mdms_jp_plugin_api_compat.yml)が止める |