OpenAPI+Fastifyでスキーマファースト開発

この記事は [mediba Advent カレンダー2023](https://qiita.com/advent-calendar/2023/mediba) の20日目の記事です。

※記事の内容はあくまで個人の発信であり、会社を代表する意見や見解ではありません。
※記事内のコードは実際のプロダクトのものではなく記事用に作成したものです。

この記事ではNode.jsのHTTPサーバフレームワークであるFastifyでのAPI開発で、OpenAPIを使ったスキーマファースト開発を実現した話を書きたいと思います。

## はじめに

あるプロダクトを短期間で開発する必要があり、FE/BEの開発を並行してスタートできるようにスキーマファーストで開発することにしました。

FE/BEメンバーが集まって、APIのIF定義を[OpenAPI Specification v3.0](https://spec.openapis.org/oas/v3.0.0)形式のYAML(以下、OpenAPI)で記述しながら決めました。

APIの開発において、フレームワークには前プロダクトを踏襲して[Fastify](https://fastify.dev/)を採用することになっていました。

Fastifyには、スキーマ定義によりリクエストパラメータの[バリデーション](https://fastify.dev/docs/latest/Reference/Validation-and-Serialization)と[TypeScript型付け](https://fastify.dev/docs/latest/Reference/Type-Providers/)をしてくれる機能があります。

OpenAPIからスキーマ定義を自動生成して開発できればと思い実現方法を模索しました。

## 解決方法の模索

**Fastifyが提供している [@fastify/swagger](https://www.npmjs.com/package/@fastify/swagger)というプラグインが使えるのではないか？** (SwaggerはOpenAPIの旧称)

しかし、このプラグインは実装からOpenAPIを生成する(コードファースト)しか実現できないものでした。

**スキーマファーストを実現している事例(紹介記事)はないか？**

しばらく探しましたが、見つかりませんでした。
コードファーストの事例ばかりヒットする…

**なければ、変換ツールを作れないか？**

OpenAPIのスキーマ定義部分は[JSON Schema](https://json-schema.org/)に準拠しており、FastifySchemaの各項目もJSON Schema指定できることに気づきました。
比較的かんたんな構造の詰め替え処理でFastify用のスキーマを生成できそうです。

## 変換スクリプトを実装

そこで、OpenAPIのスキーマ定義部分を抜き出し、FastifySchema構造に合わせて詰め替え、TypeScriptソースコードとして出力する、以下のような変換スクリプトを実装しました。

### 1. YAMLを読み込む

[yaml](https://www.npmjs.com/package/yaml)ライブラリによってJavaScriptのオブジェクトに解釈できました。

<details>
<summary>読み込み処理の例</summary>

```ts
import { readFile } from "node:fs/promises";

import type { OpenAPIV3 } from "openapi-types";
import { parse } from "yaml";

export async function readOpenApiYaml(filename: string) {
    const text = await readFile(filename, { encoding: "utf-8" });
    const yaml = parse(text);
    return yaml as OpenAPIV3.Document;
}
```

</details>

### 2. $ref参照を解決する

OpenAPIでは`$ref`プロパティにより、ほかの場所で定義してものを参照するよう記述できるが、
このあとの変換作業が複雑化するため事前に参照解決しておきます。

当初自前でコーディングして頑張っていたのですが、
[JSON Schema $ref Parser](https://apitools.dev/json-schema-ref-parser/)というライブラリが利用できました。

<details>
<summary>参照解決処理の例</summary>

```ts
import $RefParser from "@apidevtools/json-schema-ref-parser";
import type { OpenAPIV3 } from "openapi-types";

export async function dereference(doc: OpenAPIV3.Document) {
    const dereferenced = await $RefParser.dereference(doc, {
        continueOnError: true,
        dereference: {
            circular: "ignore",
        },
    });
    return dereferenced as OpenAPIV3.Document;
}
```

</details>

### 3. FastifySchema構造に詰め替える

読み込んだOpenAPIの構造体をオペレーション(API URLとメソッド)ごとに以下のような構造に詰め替えます。

```ts
const schema = {
    querystring: {URLクエリストリングのスキーマ定義(JSON Schema)},
    body: {リクエストBODY構造のスキーマ定義(JSON Schema)},
    response: {
        200: {ステータスコード200時のレスポンス構造のスキーマ定義(JSON Schema)},
    }
} as const;
```

**querystring**

paths[pattern][method].parametersから`in:query`のものをピックアップしてtype:objectのpropertiesに詰める

<details>
<summary>参照解決処理の例</summary>

```ts
function parametersToFastifySchemaQeury(parameters: OpenAPIV3.OperationObject["parameters"]) {
    const properties: Record<string, JSONSchema> = {};
    const required: string[] = [];
    for (const parameter of parameters) {
        if (parameter.in !== "query") {
            continue;
        }
        const { name, schema } = parameter;
        properties[name] = schema;
        if (parameter.required) {
            required.push(name);
        }
    }
    if (Object.keys(properties).length < 1) {
        return undefined;
    }

    return {
        type: "object",
        properties,
        required,
        additionalProperties: false,
    };
}

schema.parameters = parametersToFastifySchemaQeury(paths[pattern][method].parameters);
```

</details>

**body**

```ts
schema.body = paths[pattern][method].requestBody.content[media].schema
```

**response[code]**

```ts
schema.response[code] = paths[pattern][method].responses[code].content[media].schema
```

**operationIdをキーにして一つのオブジェクトにまとめる**

※operationIdはOpenAPI仕様上は必須項目ではないのですが、コード生成ツール等を考慮して必ず指定するルールにしています

```ts
const operationId = paths[pattern][method].operationId
schemas[operationId] = schema
```


### 4. TypeScriptコードとして出力

Fastify用スキーマ構造に詰め替えたオブジェクト`schemas`をJSON文字列に変換して
`export const schemas = {JSON文字列}`のように埋め込み、TypeScriptコードとして解釈できる文字列にします。
プロジェクトのコーディング規約にあわせるため[PrettierのAPI](https://prettier.io/docs/en/api)を利用して整形してから、ファイルに保存。

```ts
import { writeFile } from "node:fs/promises";
import { format, resolveConfig } from "prettier";

export async function outputTypeScriptFile(schemas: Record<string, FastifySchema>) {
    const source = await format(`export const schemas = ${JSON.stringify(schemas)} as const;`, {
        parser: "typescript",
    });
    return writeFile("./src/generated/schemas.ts", source);
}
```

## 解決

こうして、作成したスクリプトで生成したTypeScriptをimportして、Fastifyの各ルートハンドラのオプションに渡すことで、パラメータのバリデーションやパラメータの変数の型解決ができるようになりました。

```ts
import { schemas } from "./generated/schemas"; // 生成したファイル

...(中略)...

server.post(
    "/v1/user/edit",
    {
      schema: schemas.userEdit, // schemas[operationId]でスキーマ定義を参照できるようになっている
    },
    async (request, reply) => {
        // バリデーションエラーの場合はこのメソッドが呼ばれずにフレームワークがエラーを返す

        // queryやbodyのプロパティに型が付いている
        const { id } = request.query;
        const { name } = request.body;

        // スキーマ定義されたレスポンス構造と一致しない場合は型エラー
        reply.send({ id: "hoge", name: 123 });
    })
```

IFを変更する際は、OpenAPIに修正を加え、上記の変換スクリプトを再実行します。

パラメータの型が変わったり、レスポンス構造に項目が増えるといった、IF変更があった際にビルド時に型エラーが発生するようになり、実装への反映もれを検知しやすくなります。

## 結果

OpenAPIを使ったことで、ドキュメント見て手作業での実装が必要なくなり、
これまでの開発でありがちだった、APIサーバとクライアントのIF齟齬による不具合・手戻り・API実装待ちによるFE作業のブロッキングなどをほとんど発生させずに開発を進めることができました。

残念ながら諸般の都合でそのプロダクトの開発は中断されてしまったものの、
開発スケジュール通りに実装終了まで進めることができました。

## 最後に

本記事では割愛しましたが、OpenAPIのような汎用的な記述言語で設計を書いておくことで
APIドキュメントやFE側のAPIクライアントコード・単体テスト用のAPIスタブの自動生成などにも活用できます。

こういったツールの対応状況なども踏まえて技術選定・採用検討してみてはと思います。