12k
All articles

FastifyでREST APIを構築する方法

Fastify v5 と Prisma で REST API を構築し、JSON Schema 検証、プラグイン、デコレータ、フック、TypeBox 型付けで posts CRUD を実装。

OpenReplay Team
OpenReplay Team
FastifyでREST APIを構築する方法

Fastifyは高パフォーマンス・低オーバーヘッドのNode.js Webフレームワークであり、その最大の特徴はJSONスキーマ駆動のバリデーションとシリアライゼーションです。各リクエストとレスポンスの形状を宣言すると、Fastifyは起動時にそれらのスキーマを高速な関数にコンパイルします。メンテナーの知る限り、Fastifyはコードの複雑さにもよりますが、最速クラスのWebフレームワークの一つであり、毎秒76,000件以上のリクエストを処理できます。このチュートリアルでは、Fastify v5Prismaをデータ層として使用し、postsリソースの完全なCRUD REST APIを構築します。また、FastifyをExpressと差別化する4つの基本要素、すなわちカプセル化されたプラグイン、デコレーター、スキーマベースのバリデーション/シリアライゼーション、ライフサイクルフックに焦点を当てます。

重要なポイント

  • Fastify v5はNode.js v20以降が必要です。v4は2025年6月30日にサポート終了となり、v3以前はメンテナンスされていないため、新しいAPIはv5.xラインで開始してください。
  • Fastifyのresponseスキーマは二重の役割を果たします。専用のシリアライザーをコンパイルすることでシリアライゼーションを高速化し、かつスキーマで宣言されていないプロパティをすべて除去するため、内部フィールドがレスポンスに漏れることがありません。
  • v5からスキーマの短縮記法が廃止されました。querystringparamsbodyresponseのすべてのスキーマは、typeプロパティを含む完全なJSONスキーマである必要があります。
  • データベースクライアントをルート間で共有するには、プラグインをfastify-pluginでラップしてカプセル化を意図的に解除し、fastify.decorate('prisma', client)でクライアントをアタッチします。
  • TypeBoxタイププロバイダーを使用すると、各スキーマを一度だけ宣言し、FastifyがそこからリクエストとレスポンスのTypeScript型を推論するため、バリデーションルールと型定義のずれが生じません。

なぜFastifyなのか:Expressにはない4つの基本要素

同じCRUDエンドポイントはExpressでも実装できます。しかし、Fastifyのモデルを簡単に再現することはできません。そのモデルは、カプセル化されたプラグイン、デコレーター、スキーマベースのバリデーションとシリアライゼーション、そしてライフサイクルフックという4つの基本要素で構成されています。メンテナーは、Fastifyがフック、プラグイン、デコレーターを通じて完全に拡張可能であると述べています。これらはルーターに後付けされた便利機能ではなく、アーキテクチャそのものです。

関心事Express(一般的な実装)Fastify(慣用的な実装)
入力バリデーション手動またはミドルウェア(express-validatorルートに宣言されたJSONスキーマ
レスポンスシリアライゼーションJSON.stringifyレスポンススキーマからコンパイルされたfast-json-stringify
リソースの共有共有されたreqへのグローバルミドルウェアカプセル化されたプラグイン+デコレーター
リクエストライフサイクル線形ミドルウェアチェーン型付きフック(onRequestpreHandleronErrorなど)

パフォーマンスの主張はマーケティングではなく、仕組みに基づいています。必須ではありませんが、FastifyはJSONスキーマを使用してルートのバリデーションと出力のシリアライゼーションを行うことを推奨しており、内部的にはスキーマを高パフォーマンスな関数にコンパイルします。レスポンススキーマは起動時に専用のシリアライザーに変換されるため、JSONの生成において汎用的なJSON.stringifyのパスをスキップできます。公式の数値はあくまでフレームワークのオーバーヘッドを評価することを目的とした合成的な「Hello World」ベンチマークとして扱い、実際の数値を引用する前に自分のアプリケーションでベンチマークを行ってください。

プロジェクトのセットアップと最小限のv5サーバー

FastifyとPrisma v7はどちらもESMファーストであるため、ESMから始めます。Fastify v5はNode.js v20以降のみをサポートします。古いバージョンのNode.jsを使用している場合は、Fastify v5を使用するために新しいバージョンにアップグレードする必要があります。

mkdir fastify-posts-api && cd fastify-posts-api
npm init -y
npm pkg set type="module"
npm i fastify@5

現在の安定版リリースは2026年6月28日に公開された5.9.0です。server.jsを作成します。

import Fastify from 'fastify'

const app = Fastify({ logger: true })

app.get('/health', async () => ({ status: 'ok' }))

try {
  await app.listen({ port: 3000 })
} catch (err) {
  app.log.error(err)
  process.exit(1)
}

オブジェクト形式のlistenの呼び出しに注意してください。.listen()メソッドの可変長引数シグネチャはv5で削除されたため、可変数の引数で.listen()を呼び出すことはできなくなりました。常に{ port: 3000 }のようなオプションオブジェクトを渡してください。node --watch server.jsで実行し、http://localhost:3000/healthにアクセスしてください。

Fastify流のルーティング:プラグインとカプセル化

Fastifyでは、ルートはプラグイン内に配置され、各プラグインは独自のカプセル化コンテキストで実行されます。プラグイン内に登録されたデコレーター、フック、ルートは、そのプラグインとその子プラグインからは参照できますが、兄弟プラグインからは参照できません。これは、すべてが1つのグローバルなリクエストオブジェクトを共有するExpressミドルウェアとは逆の動作です。プラグインリファレンスにこのスコープモデルが記載されています。

プラグインはカプセル化されたインスタンスを受け取る非同期関数に過ぎません。

// routes/posts.js
export default async function postsRoutes(app, opts) {
  app.get('/', async (request, reply) => {
    return [] // リストハンドラーは後で実装
  })
}

server.jsでプレフィックスを指定して登録します。

import postsRoutes from './routes/posts.js'

app.register(postsRoutes, { prefix: '/api/posts' })

プラグイン関数は一貫してasyncにしてください。v5マイグレーションガイドでは、v4の非推奨事項がすべて削除され、アップグレード後は動作しなくなることが明示されています。また、v4では許容されていた1つのプラグイン内でのコールバックスタイルとPromiseスタイルの混在も許可されなくなりました。async/awaitを選択し、一貫して使用してください。

スキーマバリデーションとシリアライゼーション

任意のルートにschemaオブジェクトをアタッチすると、Fastifyは受信するbodyparamsquerystringheadersをバリデーションし、responseスキーマに対して送信ペイロードをシリアライズします。これはフレームワークの代表的な機能であり、バリデーションとシリアライゼーションに記載されています。

v5では、すべてのスキーマに適用される1つのルールがあります。短縮記法が廃止されました。v5以降、querystringparamsbodyresponseには、typeプロパティを含む完全なJSONスキーマを指定する必要があります。そうしないとルートはバリデーションを行いません。v5マイグレーションガイドには、jsonShortHandオプションの削除が記録されています。

postsリソースのスキーマを定義します。

// schemas/posts.js
export const postResponse = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    title: { type: 'string' },
    content: { type: 'string' },
    published: { type: 'boolean' },
    createdAt: { type: 'string' }
  }
}

export const createPostBody = {
  type: 'object',
  required: ['title', 'content'],
  properties: {
    title: { type: 'string', minLength: 1 },
    content: { type: 'string', minLength: 1 },
    published: { type: 'boolean', default: false }
  }
}

export const idParams = {
  type: 'object',
  required: ['id'],
  properties: { id: { type: 'string' } }
}

responseスキーマは多くのチュートリアルで省略されがちですが、二重の価値があります。汎用的な文字列化よりも高速な専用シリアライザーにコンパイルされ、かつ許可リストとして機能します。ハンドラーが返すプロパティのうち、スキーマで宣言されていないものはクライアントに到達する前に除去されます。クエリ結果に紛れ込んだpasswordHashや内部のuserIdは、シリアライザーがそれらを出力する方法を知らないため、サーバーから外に出ることはありません。

TypeBoxを使用したTypeScriptのアプローチ

TypeScriptを使用する場合、バリデーションスキーマと型定義を同期させ続けることが課題となります。TypeBoxタイププロバイダーはそれらを1つの宣言にまとめます。必須のピア依存関係としてtypebox@fastify/type-provider-typeboxをインストールします。

npm i typebox @fastify/type-provider-typebox

プロバイダーを登録し、Typeを使用して各スキーマを一度だけ宣言します。

import Fastify from 'fastify'
import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'

const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()

app.post('/api/posts', {
  schema: {
    body: Type.Object({
      title: Type.String({ minLength: 1 }),
      content: Type.String({ minLength: 1 })
    })
  }
}, async (request) => {
  // request.body は { title: string; content: string } として型付けされる
  const { title, content } = request.body
  return { title, content }
})

TypeTypeBoxTypeProvider@fastify/type-provider-typeboxからインポートし、Fastify().withTypeProvider<TypeBoxTypeProvider>()を呼び出すと、bodyのフィールド型が自動的に推論されます。スコープ付きパッケージを使用してください。古いガイドに記載されているレガシーな@sinclair/typeboxのインポートはv5以前の名称です。v5の注意点として、マイグレーションガイドにはタイププロバイダーがValidatorSchemaSerializerSchemaの2つの別々の型に分割されたことが記載されているため、Fastifyと合わせてプロバイダーパッケージもアップグレードしてください。また、プロバイダーの型はグローバルに伝播しないことにも注意してください。カプセル化された使用では、1つ以上のプロバイダーを使用するようにコンテキストを再マッピングできるため、プロバイダーを必要とする各プラグインで再宣言する必要があります。

デコレータープラグインとしてのデータベース層

デコレーターを使用してデータベースをFastifyインスタンスに接続し、カプセル化を意図的に解除するプラグインを通じて公開します。1つのリソース(データベースクライアント)をすべてのルートで共有するには、プラグインをfastify-pluginでラップしてデコレーションが自身のスコープから外に出られるようにし、fastify.decorate('prisma', client)でアタッチします。このパターンはデコレーターリファレンスに記載されています。

このチュートリアルではPrisma(v7、現在は7.8.x)を使用します。PrismaはRustを使用せず、ESMのみをサポートしており、上記のESMセットアップに適しています。インストールし、スキーマを定義して、クライアントを生成します。Prisma 7ではクライアントの作成にドライバーアダプターも必要なため、コアパッケージと合わせてSQLiteアダプターをインストールします。

npm i @prisma/client@7 @prisma/adapter-better-sqlite3
npm i -D prisma@7
// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model Post {
  id        String   @id @default(uuid())
  title     String
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
}

Prisma 7はCLIをprisma.config.tsファイルで設定し、上記で設定したoutputパスにクライアントを生成します。npx prisma migrate devを実行してデータベースとクライアントを作成してください。次にデコレーターとして公開します。Prisma 7ではnew PrismaClient()の直接呼び出しが削除されたため、ドライバーアダプターを使用してクライアントを構築する必要があります。

// plugins/prisma.js
import fp from 'fastify-plugin'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from '../generated/prisma/client.js'

export default fp(async (app) => {
  const adapter = new PrismaBetterSqlite3({ url: 'file:./dev.db' })
  const prisma = new PrismaClient({ adapter })
  await prisma.$connect()

  app.decorate('prisma', prisma)
  app.addHook('onClose', async (instance) => {
    await instance.prisma.$disconnect()
  })
})

プラグインがfpでラップされているため、app.prismaはアプリケーション内のすべてのルートからアクセスできます。

PostgresとTypeORMを使用する場合も、デコレーターパターンは同じです。DataSourceを接続し、app.decorate('db', dataSource)とするだけです。変わるのはスキーマ層のみです。

正しいステータスコードを使用したCRUDハンドラー

postsプラグイン内に、それぞれスキーマを持つルートとして5つの操作を実装します。各動詞に対して適切なステータスコードを返します。

  • POSTでは作成されたリソースとともに201を返す
  • 読み取りでは200を返す
  • DELETEではボディなしで204を返す
  • 検索で見つからない場合は404を返す

FastifyはContent-Typeを設定し、レスポンススキーマに対して自動的にシリアライズします。詳細はReply APIを参照してください。

// routes/posts.js
import { postResponse, createPostBody, idParams } from '../schemas/posts.js'

export default async function postsRoutes(app) {
  // CREATE
  app.post('/', {
    schema: { body: createPostBody, response: { 201: postResponse } }
  }, async (request, reply) => {
    const post = await app.prisma.post.create({ data: request.body })
    return reply.code(201).send(post)
  })

  // READ all
  app.get('/', {
    schema: { response: { 200: { type: 'array', items: postResponse } } }
  }, async () => {
    return app.prisma.post.findMany({ orderBy: { createdAt: 'desc' } })
  })

  // READ one
  app.get('/:id', {
    schema: { params: idParams, response: { 200: postResponse } }
  }, async (request, reply) => {
    const post = await app.prisma.post.findUnique({ where: { id: request.params.id } })
    if (!post) return reply.code(404).send({ message: 'Post not found' })
    return post
  })

  // UPDATE
  app.put('/:id', {
    schema: { params: idParams, body: createPostBody, response: { 200: postResponse } }
  }, async (request, reply) => {
    const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
    if (!exists) return reply.code(404).send({ message: 'Post not found' })
    return app.prisma.post.update({ where: { id: request.params.id }, data: request.body })
  })

  // DELETE
  app.delete('/:id', {
    schema: { params: idParams }
  }, async (request, reply) => {
    const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
    if (!exists) return reply.code(404).send({ message: 'Post not found' })
    await app.prisma.post.delete({ where: { id: request.params.id } })
    return reply.code(204).send()
  })
}

DELETEに関するv5の注意点として、v4ではFastifyはContent-Type: application/jsonヘッダーと空のボディを持つDELETEリクエストを許可していましたが、v5ではこれが許可されなくなりました。ペイロードがない場合はContent-Typeヘッダーを送信しないでください。

エラーハンドリング、フック、本番環境への対応

setErrorHandlerでエラーハンドリングを集中管理し、フックでリクエストライフサイクルをインターセプトします。これらはExpressのミドルウェアチェーンに代わる2つの拡張ポイントです。フックリファレンスにはリクエストフローが順番に記載されています。onRequestpreParsingpreValidationpreHandler → ハンドラー → preSerializationonSendonResponseの順で、何かがスローされるとonErrorが発火します。

class NotFoundError extends Error {
  constructor (message) { super(message); this.statusCode = 404 }
}

app.setErrorHandler((error, request, reply) => {
  request.log.error(error)
  const status = error.statusCode ?? 500
  reply.code(status).send({
    error: error.name,
    message: status === 500 ? 'Internal Server Error' : error.message
  })
})

// preHandlerフックの例:ハンドラーが実行される前にルートをゲートする
app.addHook('preHandler', async (request) => {
  request.log.info({ url: request.url }, 'incoming request')
})

バリデーションの失敗はハンドラーが実行される前に短絡し、自動的に400を返します。これがスキーマによってハンドラーのコードが削減される理由の一つです。フック内でのタイミング計測にはreply.elapsedTimeを使用してください。reply.getResponseTime()メソッドはv5で削除されました。代わりにreply.elapsedTimeを使用してください。

本番環境のチェックリストとして、以下のプラグインをインストールします。それぞれスコープ付きの@fastify/*パッケージであり、ルートプラグインと同じ方法で登録します。

より深い認証フローとデプロイメントについては、専用のガイドを参照してください。これらのエンドポイントが実際のトラフィック下でどのように動作するかを確認したい場合は、APIを使用するフロントエンドにNode.jsエラーモニタリングを組み合わせてください。

これで、スキーマが入力をバリデーションし、出力をシリアライズし、自己文書化する、実行可能なFastify v5 CRUD APIが完成しました。データベースはグローバルインポートではなくデコレーターを通じて共有されています。次の具体的なステップとして、@fastify/swaggerを追加し、既に記述したスキーマに向けると、OpenAPI仕様が自動生成されます。これはFastifyにおいてスキーマが後付けではなく真実の源泉であることの証明です。

よくある質問

fastify.registerとfastify.decorateの違いは何ですか?

registerメソッドはプラグインを独自のカプセル化コンテキストにマウントするため、その中に追加されたルート、フック、デコレーターはそのプラグインとその子プラグインにスコープされます。decorateメソッドはFastifyインスタンス、リクエスト、またはリプライに再利用可能なプロパティやメソッドを直接アタッチします。通常、データベースクライアントのような共有リソースを公開するためにdecorateを使用し、そのプラグインをfastify-pluginでラップしてデコレーションがカプセル化から外に出てアプリケーション全体で利用できるようにします。

FastifyはExpressより速いですか?どのくらい速いですか?

Fastify自身のベンチマークでは毎秒76,000件以上のリクエストを処理できると報告されていますが、メンテナーはこれがフレームワークのオーバーヘッドを測定する合成的な「Hello World」テストであり、実際のスループットではないと注意しています。機械的な優位性は具体的です。FastifyはJSONスキーマを起動時に専用のバリデーションおよびシリアライゼーション関数にコンパイルするため、レスポンスの生成において汎用的なJSON.stringifyのオーバーヘッドをスキップできます。速度の倍率を引用する前に、必ず自分のアプリケーションでベンチマークを行ってください。

Fastify v5のルートスキーマがv4では動作していたのにバリデーションに失敗するのはなぜですか?

v5からスキーマの短縮記法とjsonShortHandオプションが削除されたため、querystring、params、body、responseのすべてのスキーマは、typeプロパティを明示的に含む完全なJSONスキーマである必要があります。トップレベルのtypeなしにpropertiesのみを宣言するスキーマはバリデーションされなくなります。各スキーマオブジェクトにtype 'object'を追加してください。v5マイグレーションガイドには、これがv4の短縮記法からの意図的な破壊的変更として記録されています。

TypeScriptで型を二重に記述せずにFastifyを使用できますか?

はい。ピア依存関係としてtypeboxと、スコープ付きの@fastify/type-provider-typeboxパッケージをインストールし、TypeBoxTypeProviderジェネリックを使用してFastify().withTypeProviderを呼び出します。Typeを使用して各スキーマを一度だけ宣言すると、Fastifyはそこからリクエストとレスポンスの型を推論し、バリデーションルールとTypeScriptインターフェースのずれがなくなります。古いガイドに記載されているレガシーな@sinclair/typebox名ではなく、@fastify/type-provider-typeboxからTypeをインポートし、プロバイダーを必要とする各プラグインで再宣言してください。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.