Claude Media
Claude CodeでFastifyのAPIを作る — injectとJSON Schemaで400を検証

Claude CodeでFastifyのAPIを作る — injectとJSON Schemaで400を検証

Claude CodeにFastifyのAPIを作らせ、app.injectでサーバを立てずにスキーマ違反の400を検証する手順。additionalPropertiesが400にならない落とし穴とCLAUDE.mdへの固定も示します。

Claude CodeでFastifyのAPIを作る — injectとJSON Schemaで400を検証

FastifyのAPIは、ポートを開かなくても検証できます。app.inject() にメソッドとURLとpayloadを渡せば、HTTPリクエストを模した呼び出しでレスポンスが返るためです。Claude Codeに任せる開発では、この性質が効きます。devサーバの起動も curl の許可も要らず、テストコマンド1つで「スキーマ違反なら400」まで確かめられるからです。

この記事では、JSON Schemaを付けたルートを inject() でテストする流れを、手元で動かした出力つきで説明します。途中に、additionalProperties: false を書いても400にならない落とし穴があります。最後に、検証コマンドをCLAUDE.mdと権限設定に固定する例を示します。

なぜinject()はClaude Codeの検証ループに向くのか

Fastifyのテストガイドは、inject を軽量なHTTPインジェクションとして紹介しています。内部ではlight-my-requestというライブラリが使われ、inject は登録済みのプラグインが起動し終わってからリクエストを処理します。

起動待ちのコードを自分で書かなくて済みます。検証方法をClaude Codeに要る許可の面から並べると、差が出ます。ガイドにあるのは前の2つで、curl は比較のために足した行です。

検証方法サーバ起動Claude Codeに要る許可
app.inject()サーバ起動不要Claude Codeに要る許可テストコマンド(npm test)のみ
listen() + fetch / undiciサーバ起動必要(ポートを使う)Claude Codeに要る許可テストコマンドのみ。ポート衝突に注意
devサーバを起動して curlサーバ起動必要Claude Codeに要る許可サーバ起動と curl の両方

listen() を使う方法もガイドに載っていますが、ポートを占有します。検証だけが目的なら inject() が最短です。

アプリとサーバを分ける

inject() を使うには、アプリを組み立てる処理とサーバを起動する処理を別のファイルに分けます。テストガイドも、最初にこの分離を勧めています。

// app.js
'use strict'
const fastify = require('fastify')
 
function build (opts = {}) {
  const app = fastify(opts)
  // ここにルートとプラグインを登録する
  return app
}
 
module.exports = build

server.js 側は build() を呼んで listen() するだけにします。テストは build() を直接呼ぶので、ポートを開きません。Claude Codeに「新しいルートはすべて app.js の build() の中で登録する」と指示しておくと、inject() で検証できない構成を避けられます。

スキーマ違反の400をテストで固定する

ユーザー作成のルートで試します。リクエストボディに必須項目と文字数・数値の範囲を課し、レスポンスにもスキーマを付けます。

// app.js(ルート部分)
const createUserSchema = {
  body: {
    type: 'object',
    required: ['name', 'age'],
    additionalProperties: false,
    properties: {
      name: { type: 'string', minLength: 1 },
      age: { type: 'integer', minimum: 0 }
    }
  },
  response: {
    201: {
      type: 'object',
      properties: { id: { type: 'integer' }, name: { type: 'string' } }
    }
  }
}
 
app.post('/users', { schema: createUserSchema }, async (request, reply) => {
  reply.code(201)
  return { id: 1, name: request.body.name, secret: 'x' }
})

テストは node:test で書きます。Fastifyのテストガイドの例と同じランナーです。

// test/users.test.js
'use strict'
const { test } = require('node:test')
const build = require('../app')
 
test('必須項目が欠けると400', async t => {
  const app = build()
  t.after(() => app.close())
 
  const res = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { name: 'Taro' }
  })
  t.assert.strictEqual(res.statusCode, 400)
})

t.after(() => app.close()) は、テストガイドが「テストの最後に .close() を呼ぶことを強く勧める」としている後始末です。

age を省いたこのリクエストを手元で実行すると、次のレスポンスが返りました(Fastify 5.12.6、Node.js 22.22.2)。

400 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'age'"}

スキーマ違反では、Fastifyが検証結果を載せた400を自動で返します。ルートのハンドラは呼ばれません。

本文のメッセージではなくステータスと code を見る

エラーメッセージの文面は、ドキュメントの中でも揺れています。Error Handlingの節にある例は「body should have required property 'name'」ですが、attachValidation の説明では「body must have required property 'name'」です。手元の出力は後者の must でした。

文面に一致させるテストはバージョン更新で壊れやすくなります。固定するのは statusCode が400であることと、code が FST_ERR_VALIDATION であることに絞るのが安全です。後者は attachValidation の節で、検証エラーのコードとして書かれています。

t.assert.strictEqual(res.statusCode, 400)
t.assert.strictEqual(res.json().code, 'FST_ERR_VALIDATION')

additionalProperties: falseでも400にならない

ここで、スキーマに余計なプロパティを禁じる additionalProperties: false を書いたのだから、role を足したリクエストは弾かれるはずだと考えるのが自然です。次のテストを書きました。

test('未定義のプロパティを足すと400', async t => {
  const app = build()
  t.after(() => app.close())
 
  const res = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { name: 'Taro', age: 30, role: 'admin' }
  })
  t.assert.strictEqual(res.statusCode, 400)
})

手元で実行すると、このテストは失敗しました。レスポンスは 201 {"id":1,"name":"Taro"} で、検証は通っています。

落とし穴

余計なプロパティは、弾かれずに黙って取り除かれる

Fastifyが使うAjvの基本設定では removeAdditional: true です。additionalProperties: false のスキーマに対しては、違反を報告する代わりに、余分なプロパティを取り除いて検証を通します。手元の結果もこの設定の説明どおりでした。余計な項目を拒否したいなら、ajv.customOptions で removeAdditional: false にします。

設定は fastify() の生成時に渡します。

function build (opts = {}) {
  const app = fastify({
    ajv: { customOptions: { removeAdditional: false } },
    ...opts
  })
  // ...
  return app
}

この変更の後で同じテストを実行すると、次のように400が返り、全テストが通りました。

400 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must NOT have additional properties"}

Claude Codeに任せるとき、ここは必ず人間が方針を決める箇所です。取り除く挙動を意図して使う設計もあるため、AIが勝手に切り替えると別のクライアントが壊れます。方針をCLAUDE.mdに書いておけば、テストの期待値と設定がずれません。

型の自動変換も同じ設定に含まれる

Ajvの基本設定には coerceTypes: 'array' も入っています。age に文字列の "30" を送るテストを足すと、手元ではそのまま 201 になりました。スキーマが integer でも、変換できる値は変換されてハンドラに届きます。

「型が違えば400」という期待をテストにするなら、body だけ coerceTypes: false にする方法が、Validationの節にカスタムコンパイラの例として載っています。厳密さを求めるAPIか、寛容なAPIかは設計判断です。どちらを選ぶにしても、選んだ結果をテストで固定しておくと、後から設定が変わったときに気づけます。

レスポンススキーマが漏れを止めている

先ほどのハンドラは secret: 'x' を返していますが、実際のレスポンスには含まれませんでした。response にスキーマがあると、Fastifyは fast-json-stringify でスキーマに載ったプロパティだけを出力するためです。Serializationの節も、出力スキーマが情報の意図しない露出の防止に役立つと説明しています。

この挙動もテストにしておくと、レスポンススキーマの削除や緩めすぎを検出できます。

test('スキーマにない項目はレスポンスに出ない', async t => {
  const app = build()
  t.after(() => app.close())
 
  const res = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { name: 'Taro', age: 30 }
  })
  t.assert.strictEqual(res.statusCode, 201)
  t.assert.deepStrictEqual(res.json(), { id: 1, name: 'Taro' })
})

Node.jsのバージョンで inject 自体が落ちることがある

手元の最初の実行は、テスト本体に入る前に失敗しました。Node.js 20.18.0で同じテストを動かすと、inject の呼び出しが ERR_REQUIRE_ESM で落ちたのです。メッセージは、light-my-requestの lib/cookies.js が、ESMとして配布されている cookie を require() できないというものでした。

同じ依存関係のままNode.js 22.22.2に切り替えると、4件とも通りました。インストールされた cookie は2.0.1で、パッケージの type は module です。再現したのは手元の1環境だけなので、どのNode.jsから通るのかの境界は、この記事では断定しません。テストが ERR_REQUIRE_ESM で落ちたら、アサーションを疑う前に node -v を見るのが近道です。

node -v
npm ls fastify light-my-request cookie

AIにテストを直させると、原因がNode.jsの版なのに、テストコードやインポートを書き換え始めることがあります。使うNode.jsの版をCLAUDE.mdに書いておくと、この迷走を避けられます。

検証コマンドをCLAUDE.mdに固定する

CLAUDE.mdは、セッションの開始時に読み込まれる永続的な指示です。メモリの仕組みを説明するドキュメントは、これを強制ではなく文脈として扱うと述べ、指示は具体的で簡潔なほど守られやすいと書いています。そのため、書くのは「何を実行して何が出れば完了か」という検証可能な形にします。

## テストと検証(Fastify)
- Node.jsは22系を使う。`node -v` で確かめてからテストを回す
- HTTPの検証は `app.inject()` で行う。`npm run dev` や `curl` は使わない
- ルートは `app.js` の `build()` 内で登録する(`server.js` に書かない)
- 新しいルートには `body` / `querystring` / `params` と `response` のスキーマを付ける
- スキーマ違反の期待値は、ステータスが400であることと `code` が
  `FST_ERR_VALIDATION` であることで確認する。メッセージ文面には依存しない
- 余計なプロパティは400にする方針。`removeAdditional: false` を変えない
- 検証は `npm test`。失敗が残る間は完了と報告しない

読み込みの順序や置き場所は、Claude Codeの全体像にまとまっています。

実行できるコマンドを権限で絞る

CLAUDE.mdは指示であって、Claude Codeの許可を変えるものではありません。権限のドキュメントも、プロンプトやCLAUDE.mdの指示は試みる内容を形づくるだけで、許可される内容は変えないと説明しています。実行できるコマンドを絞るなら、.claude/settings.json の権限ルールを使います。

{
  "permissions": {
    "allow": [
      "Bash(npm test)",
      "Bash(node --test *)"
    ],
    "deny": [
      "Bash(curl *)",
      "Bash(npm run dev)"
    ]
  }
}

Bash(npm test) は、その名前のコマンドだけに一致する書き方です。* を使うときは、サブコマンドの後ろに置きます。denyのルールはallowより優先され、広いdenyの中に狭いallowで例外は作れません。ルールの確認や変更は/permissionsコマンドからも行えます。

inject() なら、許可するのはテストの実行だけで済みます。devサーバを起動する経路を最初から塞げるため、バックグラウンドに残ったプロセスがポートを握り続ける事故も起きません。

作らせるときの進め方

実際にClaude Codeへ任せるときは、次の順で回すと、検証が毎回同じ形になります。

手順

スキーマ付きルートを追加させる流れ

  1. 1

    スキーマとテストを先に書かせる

    ルートの本体より先に、body と response のスキーマと、400・201のテストを書かせます。テストが落ちる状態を確認してから実装に入ります。

  2. 2

    ハンドラを実装させる

    npm test を回し、赤から緑になることを確かめさせます。実行結果の出力を読ませ、通ったと言うだけで終わらせません。

  3. 3

    境界値のテストを足させる

    minLength や minimum の境界、型の違う値、余計なプロパティなど、スキーマが守る範囲の外側にある入力を試させます。

  4. 4

    設定を変えたら理由を残させる

    Ajvの設定に触れた場合は、CLAUDE.mdの該当行と一緒に更新させます。テストの期待値だけを変えて通すことは避けます。

Hono、NestJSなど別のフレームワークで同じ型を回す例は、HonoのAPIをapp.requestで検証する手順とNestJSアプリの開発手順にあります。検証に使う入り口が変わるだけで、テストコマンドを完了条件にする考え方は同じです。

Claude Codeの --json-schema フラグで出力の形を縛る話は、Fastifyのルートスキーマとは別の仕組みです。そちらのエラーはClaude Codeの--json-schemaエラーの直し方で扱っています。

つまずきやすい点

症状原因として考えられるもの見る場所
400になるはずが201になる原因として考えられるものremoveAdditional による除去、coerceTypes による型変換見る場所build() の ajv.customOptions
inject がテスト前に例外で落ちる原因として考えられるものNode.jsの版と依存パッケージの形式の不一致見る場所node -v と npm ls
400にならず検証が走らない原因として考えられるもの本文が application/json ではない見る場所リクエストのContent-Type
テストが終わらずプロセスが残る原因として考えられるものapp.close() の呼び忘れ見る場所t.after()

3行目は、Validationの冒頭にある仕様です。本文の検証は、Content-Typeが application/json のときだけ試みられます。inject() に payload としてオブジェクトを渡した手元のテストでは検証が働きましたが、フォーム形式やテキストを送るルートでは、このContent-Typeの条件がテストの前提になります。

まとめ

inject() を検証の入り口にすると、Claude Codeに許可するのはテストコマンドだけで済みます。そのうえで、400はステータスと code で固定し、Ajvの除去・変換の挙動はテストと設定で意図を残します。AIが勝手にスキーマの厳しさを変えても、テストが赤くなるようにしておくことが、この構成の要点です。

この記事を共有:XはてブLinkedIn