♻️

rswag と OpenAPI TypeScript で型安全を向上する

に公開

Rails × TypeScript の開発において、テストファイルから自動で型定義を生成する方法をまとめました。
手動での型定義を避けることで、開発の安全性を向上させることが可能になります。

流れ

  1. rswag で OpenAPI を生成する
  2. OpenAPI TypeScript で型定義を自動生成する
  3. 生成された型を使って API クライアントを型安全にする

1. rswag で OpenAPI を生成する

https://github.com/rswag/rswag?tab=readme-ov-file#getting-started

インストール

Gemfile
gem 'rswag'
$ bundle install
$ rails g rswag:install

リクエストスペックを書く

rswag ではリクエストスペックを元に OpenAPI を生成します。
ここでは /api/articles の GET/POST をカバーするシンプルな例を用意します。

spec/requests/api/articles_spec.rb
require 'swagger_helper'

RSpec.describe 'Articles API', type: :request do
  let!(:articles) { create_list(:article, 2) }

  path '/api/articles' do
    get '記事一覧を取得する' do
      tags 'Articles'
      produces 'application/json'

      response '200', '成功' do
        schema type: :array, items: {
          type: :object,
          properties: {
            id: { type: :integer },
            title: { type: :string },
            body: { type: :string }
          },
          required: %w[id title body]
        }

        run_test! do |response|
          data = JSON.parse(response.body)
          expect(data.size).to eq(articles.size)
        end
      end
    end

    post '記事を作成する' do
      tags 'Articles'
      consumes 'application/json'

      parameter name: :article, in: :body, required: true, schema: {
        type: :object,
        properties: {
          article: {
            type: :object,
            properties: {
              title: { type: :string },
              body: { type: :string }
            },
            required: %w[title body]
          }
        },
        required: %w[article]
      }

      let(:article) { { article: { title: 'Hello', body: 'world' } } }

      response '201', '作成に成功' do
        schema type: :object,
               properties: {
                 id: { type: :integer },
                 title: { type: :string },
                 body: { type: :string }
               },
               required: %w[id title body]

        run_test! do |response|
          expect(response.status).to eq(201)
        end
      end
    end
  end
end

OpenAPI の生成

スペックを書いたら、rswag:specs:swaggerize を叩くことで swagger/v1/swagger.yaml が更新されます。

$ bundle exec rake rswag:specs:swaggerize
# swagger/v1/swagger.yaml に OpenAPI 3.0.1 の定義が出力される

この YAML が API の仕様書兼、TypeScript へ型を引き継ぐためのソースになります。

2. OpenAPI TypeScript で型定義を自動生成する

https://openapi-ts.dev/

OpenAPI の仕様が手に入ったら、openapi-typescript で TypeScript 型を生成します。

インストール

$ npm install --save-dev openapi-typescript typescript

package.json にスクリプトを追加しておくと便利です。

package.json
{
  "scripts": {
    "openapi:generate": "openapi-typescript swagger/v1/swagger.yaml -o src/types/generated/openapi.d.ts"
  }
}

型生成コマンド

$ npm run openapi:generate
# src/types/generated/openapi.d.ts が生成される

生成されたファイルは巨大ですが、paths キー以下に API ごとのレスポンスやリクエストの型が定義されています。今回の /api/articles の GET なら次のような形になります。

src/types/generated/openapi.d.ts
export interface paths {
  "/api/articles": {
    get: {
      responses: {
        200: {
          content: {
            "application/json": {
              id: number;
              title: string;
              body: string;
            }[];
          };
        };
      };
    };
    post: {
      requestBody: {
        content: {
          "application/json": {
            article: {
              title: string;
              body: string;
            };
          };
        };
      };
      responses: {
        201: {
          content: {
            "application/json": {
              id: number;
              title: string;
              body: string;
            };
          };
        };
      };
    };
  };
}

3. 生成された型を使って API クライアントを型安全にする

3. 生成された型を使って API クライアントを型安全にする

型が用意できたので、フロントエンド側からはそれをインポートして使うだけです。fetchArticles.ts を例にします。

src/api/fetchArticles.ts
import type { paths } from "../types/generated/openapi";

type ArticlesIndexResponse =
  paths["/api/articles"]["get"]["responses"]["200"]["content"]["application/json"];

export const fetchArticles = async (): Promise<ArticlesIndexResponse> => {
  const response = await fetch("/api/articles");
  if (!response.ok) {
    throw new Error("Failed to fetch articles");
  }
  const data = (await response.json()) as ArticlesIndexResponse;
  return data;
};

POST のリクエストボディも同様に扱えます。

src/api/createArticle.ts
import type { paths } from "../types/generated/openapi";

type CreateArticleRequest =
  paths["/api/articles"]["post"]["requestBody"]["content"]["application/json"];
type CreateArticleResponse =
  paths["/api/articles"]["post"]["responses"]["201"]["content"]["application/json"];

export const createArticle = async (
  payload: CreateArticleRequest
): Promise<CreateArticleResponse> => {
  const response = await fetch("/api/articles", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!response.ok) {
    throw new Error("Failed to create article");
  }
  const data = (await response.json()) as CreateArticleResponse;
  return data;
};

生成された OpenAPI 型をそのまま使うメリットは、バックエンドの変更がそのままコンパイルエラーになって表面化することです。

例えば titleheadline にリネームされれば、openapi:generate を再実行した時点で fetchArticlescreateArticle で型エラーが発生し、見逃しを防げます。

まとめ

  • rswag を使うと、RSpec で書いたリクエストテストから OpenAPI 仕様を生成できる
  • openapi-typescript でその仕様から TypeScript の型を自動生成できる
  • 生成された型を使用することで、BE と FE の仕様差異を検知しやすくなる

Discussion