API 開発を革新する!Fastify と JSON Schema、OpenAPI を用いたスキーマ駆動開発の実践ガイド
※タイトル盛りすぎたかもしれません。
スキーマ駆動開発とは
スキーマを最初に定義しておき、その定義を元にバックエンド、フロントエンドを開発します。
スキーマは、ここではAPIの仕様のことで、エンドポイント、メソッド、リクエスト、レスポンスなどの定義を指します。
バックエンド、フロントエンドが別れているアーキテクチャには親和性が高いと思います。
そう考える理由(メリット)は以下です。
- バックエンド、フロントエンドでAPI定義の齟齬がなくなる
- 最初にスキーマ定義すれば、バックエンド、フロントエンド同時に開発できる
Fastifyでのスキーマ駆動開発の進め方
環境
今回は、フロントエンドからFastifyで作られたREST APIを使用するアーキテクチャを想定しています。
今回目指すこと
JSON Schema
を元にOpenAPI
、Fastifyのリクエスト、レスポンス
の3つが連動することをゴールとします。
以下のフローで実現します
- JSON Schemaを書く
- ローカルサーバを立ち上げたときに、JSON Schemaから
openapi.yaml
を生成する - JSON Schemaからリクエスト、レスポンスの型を生成する
1. JSON Schemaを書く
JSON Schema[1]は、スキーマをjsonで定義できます。
詳しい書き方については説明を省きます。
今回の定義は、ログインAPIを想定して実装してみます。
import { JSONSchema } from 'json-schema-to-ts';
export const schemas = {
post: {
body: {
type: 'object',
properties: {
email: { type: 'string' },
password: { type: 'string' }
},
required: ['email', 'password']
} as const satisfies JSONSchema,
response: {
200: {
headers: {
type: 'object',
properties: {
description: {
type: 'string'
},
'Set-Cookie': {
type: 'array',
items: { type: 'string' }
}
},
required: ['Set-Cookie']
},
type: 'object',
properties: {
message: { type: 'string', enum: ['Login successful'] }
},
required: ['message']
},
401: {
type: 'object',
properties: {
error: { type: 'string', enum: ['Invalid credentials'] }
},
required: ['error']
} as const satisfies JSONSchema,
500: {
type: 'object',
properties: {
error: { type: 'string', enum: ['Internal Server Error'] }
},
required: ['error']
} as const satisfies JSONSchema
}
}
} as const;
2. JSON Schemaからopenapi.yamlを生成する
サーバー起動のタイミングでdocs/openapi.yaml
に定義を出力します。
fastify.register()
を使用することにより、Fastifyのプラグインや機能をサーバーに登録する事ができます。
実際に、openapi.yaml
に出力する手順は以下の通りです。
2-1. fastifySwaggerを使って、OpenAPI定義を生成する
2-2. fastifySwaggerUiを使って、web上でOpenAPI定義を見れるようにする
2-3. autoLoadを使用してAPIのエンドポイントを指定する
2-4. openapi.yamlに出力
コードは以下です
import fs from 'fs';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
import autoLoad from '@fastify/autoload';
import path from 'path';
import fastify from 'fastify';
const server = fastify();
const start = async () => {
// 2-1. fastifySwaggerを使って、OpenAPI定義を生成する
await server.register(fastifySwagger, {
openapi: {
info: {
title: 'API Documentation',
version: '1.0.0'
}
}
});
// 2-2. fastifySwaggerUiを使って、web上でOpenAPI定義を見れるようにする
await server.register(fastifySwaggerUi, {
routePrefix: '/docs'
});
// 2-3. autoLoadを使用してAPIのエンドポイントを指定する
await server.register(autoLoad, {
dir: path.join(__dirname, './routes'),
routeParams: true,
matchFilter: (path: string) =>
path.split('/').at(-1)?.split('.').at(-2) === '_handlers'
});
// すべてのプラグインのセットアップが完了するのを待つ
await server.ready();
server.swagger();
// fastifySwaggerUiで生成したOpenAPI定義にアクセス
const responseYaml = await server.inject('/docs/yaml');
// openapi.yamlに出力
fs.writeFileSync('docs/openapi.yaml', responseYaml.payload);
// サーバーを起動
server.listen({ port: 3000 }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
});
};
// 上記の関数を仕様
start();
3. JSON Schemaから型を生成する
2.の内容は_hanlders.ts
にAPI定義がある前提での実装でした。
_hanlders.ts
では、下記のようにschemaを設定します。
import fastify, { FastifyReply, FastifyRequest } from 'fastify';
import { schemas } from './schemas';
const server = fastify();
server.post(
'/',
{
schema: schemas['post'] // ここで定義することによって、openapi.yamlの出力がされる
},
async (
request: FastifyRequest<{
Body: {
email: string;
password: string;
}; // ここschemaから生成された型使用したい
}>,
reply: FastifyReply
) => {
const { email, password } = request.body;
try {
if (email === 'user' && password === 'password') {
return reply.status(200).send({ message: 'Login successful' }); // ここschemaから生成された型使用したい
} else {
return reply.status(401).send({ error: 'Invalid credentials' }); // ここschemaから生成された型使用したい
}
} catch {
return reply.status(500).send({ error: 'Internal Server Error' }); // ここschemaから生成された型使用したい
}
}
);
openapi.yamlの出力はされますが、リクエストとレスポンスの型が手動になってしまいます。
ここを型安全にしたいですね。
json-schema-tsを使用し、リクエストとレスポンスに型を付与する
json-schema-ts
のFromSchema
を使用して、リクエストとレスポンスの型を生成する事ができます。
1.で生成したJSON Schemaの定義を使用します。
以下のように記述して、型を生成できます。
// リクエストの型定義
export type PostAuthRequest = {
Body: FromSchema<typeof schemas.post.body>;
};
// レスポンスの型定義
export type PostAuthResponse = {
200: FromSchema<(typeof schemas.post.response)['200']>;
401: FromSchema<(typeof schemas.post.response)['401']>;
500: FromSchema<(typeof schemas.post.response)['500']>;
};
しかし、これを全部のファイルでやってたらまあまあ手間がかかりますし、定義漏れもしそうですね。
TypeScriptの力を使い、自作ユーティリティを作る
以下のように定義することによって、汎用的に型を生成できます。
import { FromSchema } from 'json-schema-to-ts';
// リクエストの型を生成するユーティリティ
export type GenerateRequestTypes<T> = (T extends { headers: object }
? { Headers: FromSchema<T['headers']> }
: {}) &
(T extends { body: object } ? { Body: FromSchema<T['body']> } : {}) &
(T extends { querystring: object }
? { QueryString: FromSchema<T['querystring']> }
: {}) &
(T extends { params: object } ? { Params: FromSchema<T['params']> } : {});
// レスポンスの型を生成するユーティリティ
// Fastifyから返されうるステータスコード一覧
type StatusCodeList = 200 | 401 | 500;
// 各ステータスの型を抽出
type ExtractStatus<
Response,
StatusCode extends StatusCodeList
> = Response extends {
[Key in `${StatusCode}`]: object;
}
? { [Key in StatusCode]: FromSchema<Response[`${StatusCode}`]> }
: {};
// Fastifyから返されうるステータスコードのレスポンスを型定義
export type GenerateResponseTypes<Response> = ExtractStatus<Response, 200> &
ExtractStatus<Response, 401> &
ExtractStatus<Response, 500>;
生成した型を_handlers.tsに適用する
import fastify from 'fastify';
import { schemas, PostAuthRequest, PostAuthResponse } from './schemas';
const server = fastify();
server.post<{
Body: PostAuthRequest['Body'];
Reply: PostAuthResponse;
}>( // ここで型を定義する
'/',
{
schema: schemas['post']
},
async (request, reply) => {
const { email, password } = request.body;
try {
if (email === 'user' && password === 'password') {
return reply.status(200).send({ message: 'Login successful' });
} else {
return reply.status(401).send({ error: 'Invalid credentials' });
}
} catch {
return reply.status(500).send({ error: 'Internal Server Error' });
}
}
);
ここで、schema.tsで定義した値のenum型が効いてきます。
たとえば、200エラーで'Login success!'とした場合エラーになります。
スキーマを厳密に定義することによって、こういった小さなヒューマンエラーが防げるのは良いですね。
最後に
上記の実装により、JSON Schemaの定義からOpenAPIの定義、APIのリクエストレスポンス定義を生成することができて、スキーマを起点とした開発が実現できました♫
Discussion