🙄

API 開発を革新する!Fastify と JSON Schema、OpenAPI を用いたスキーマ駆動開発の実践ガイド

2024/09/04に公開

※タイトル盛りすぎたかもしれません。

スキーマ駆動開発とは

スキーマを最初に定義しておき、その定義を元にバックエンド、フロントエンドを開発します。
スキーマは、ここではAPIの仕様のことで、エンドポイント、メソッド、リクエスト、レスポンスなどの定義を指します。

バックエンド、フロントエンドが別れているアーキテクチャには親和性が高いと思います。

そう考える理由(メリット)は以下です。

  • バックエンド、フロントエンドでAPI定義の齟齬がなくなる
  • 最初にスキーマ定義すれば、バックエンド、フロントエンド同時に開発できる

Fastifyでのスキーマ駆動開発の進め方

環境

今回は、フロントエンドからFastifyで作られたREST APIを使用するアーキテクチャを想定しています。

今回目指すこと

JSON Schemaを元にOpenAPIFastifyのリクエスト、レスポンスの3つが連動することをゴールとします。

以下のフローで実現します

  1. JSON Schemaを書く
  2. ローカルサーバを立ち上げたときに、JSON Schemaからopenapi.yamlを生成する
  3. JSON Schemaからリクエスト、レスポンスの型を生成する

1. JSON Schemaを書く

JSON Schema[1]は、スキーマをjsonで定義できます。
詳しい書き方については説明を省きます。

今回の定義は、ログインAPIを想定して実装してみます。

schema.ts
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に出力

コードは以下です

index.ts
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を設定します。

_handlers.ts
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-tsFromSchemaを使用して、リクエストとレスポンスの型を生成する事ができます。

1.で生成したJSON Schemaの定義を使用します。

以下のように記述して、型を生成できます。

schema.ts
// リクエストの型定義
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の力を使い、自作ユーティリティを作る

以下のように定義することによって、汎用的に型を生成できます。

utils.ts
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に適用する

_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のリクエストレスポンス定義を生成することができて、スキーマを起点とした開発が実現できました♫

脚注
  1. https://json-schema.org/ ↩︎

  2. https://zenn.dev/nyatinte/articles/1777eb483319d2 ↩︎

  3. https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/ ↩︎

エックスポイントワン技術ブログ

Discussion