Top View


Author Daiki Urata

RailsとrswagでAPIスキーマを自動生成し、ReactのTanStack Queryフックを作成する方法

2024/06/10

はじめに

今回はRails側でrswagによるテストコードを記入、そこからOpenAPIスキーマを生成、TanStack QueryのHooksを生成するまでの手順を記事に残したいと思います。

この方法の良い点としては、APIのテストコードを書くことでAPI仕様書を生成できるので実態と仕様書のずれが発生しない点とOpenAPIのスキーマファイルを手動で管理する手間がない点です。

さらにOpenAPIスキーマからGeneratorを使ってTanStack QueryのHooksまで生成することで、React側でのAPIリクエスト処理と返ってきたレスポンスをステートに格納するなどの状態管理処理の実装を一気に省くことができます。

もちろんレスポンス値の型(TypeScript)も一緒に生成されるので、API側との齟齬も発生しにくいです。

実装の流れとしてはRailsのAPI作成→テストコード作成→OpenAPIスキーマ生成→TanStack Queryコード生成という流れになります。

Railsプロジェクト作成

まずはAPIモードでRailsプロジェクトを作成します。

rails new my-project --api

rswagの導入

次にrswagを導入します。

テストを楽にするため、rspec-railsとfactory_bot_railsもついでに追加しておきます。

Gemfileを以下のように編集します。

group :development, :test do
  gem 'rspec-rails', '~> 6.1.0'
  gem 'factory_bot_rails'
end

gem 'rswag'

rswagに関してはrswag-specsrswag-ui をbundler groupで分けて入れることも可能ですが、RAILS_ENV=test を指定してrakeタスクを実行する必要などがあり面倒なため今回は分けませんでした。

以下インストールコマンドでrspecとrswagに関するファイルの作成や変更が行われます。

bundle install
rails g rspec:install
rails g rswag:install

今回は細かいfactory_bot_railsやrspec-railsのセットアップは省きます。

詳しくは各Gemのドキュメントを参考にしてください。

簡単なPostのscaffold

rswagの準備が整ったので次に簡単なAPIを用意します。

rails generate scaffold API::Post title:string body:text
rails db:migrate

routes.rbは以下のようになります。

Rails.application.routes.draw do
  namespace :api do
    resources :posts
  end
end

app/controllers/api/posts_controller.rbは以下のようになります。

class Api::PostsController < ApplicationController
  before_action :set_api_post, only: %i[ show update destroy ]

  # GET /api/posts
  def index
    @api_posts = Api::Post.all

    render json: @api_posts
  end

  # GET /api/posts/1
  def show
    render json: @api_post
  end

  # POST /api/posts
  def create
    @api_post = Api::Post.new(api_post_params)

    if @api_post.save
      render json: @api_post, status: :created, location: @api_post
    else
      render json: @api_post.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /api/posts/1
  def update
    if @api_post.update(api_post_params)
      render json: @api_post
    else
      render json: @api_post.errors, status: :unprocessable_entity
    end
  end

  # DELETE /api/posts/1
  def destroy
    @api_post.destroy!
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_api_post
      @api_post = Api::Post.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def api_post_params
      params.require(:api_post).permit(:title, :body)
    end
end

シードを作成して動くか試します。

db/seeds.rbを編集します。

10.times do |i|
  Api::Post.create!(title: "Post #{i}", body: "This is the body of post #{i}")
end

rails db:seed

サーバーを起動してhttp://localhost:3000/api/postsへアクセスするとPostの一覧データが取得できるはずです。

rspecのテスト作成

APIが完成したので、rswagのテストを書いていきます。

まず、factories/api/post.s.rbを編集しておきます。

FactoryBot.define do
  factory :api_post, class: 'Api::Post' do
    title { "MyString" }
    body { "MyText" }
  end
end

テストを書く前にrswagの重要な設定としてopenapi_strict_schema_validation をtrueにします。

これはrswagのデフォルトではテストコード内のレスポンスに明示的に書かれてないプロパティがControllerから実際に返ってくるレスポンスに含まれていた場合、テストが通ってしまいます。

openapi_strict_schema_validation を有効にすることで、厳密にレスポンスをチェックしてテストコードに書かれていないプロパティが返ってきたらテストが落ちて気づけるようにしておきます。

これにより間違って公開してはいけない情報を公開するのを防ぐことができるので有効にしておくことが良いと考えています。

spec/swagger_helper.rbを編集してopenapi_strict_schema_validation を有効にします。

ついでにPostのレスポンススキーマを共通で使い回したいので、Componentとして定義しておきます。

# frozen_string_literal: true

require 'rails_helper'

RSpec.configure do |config|
  config.openapi_root = Rails.root.join('swagger').to_s
  config.openapi_specs = {
    'v1/swagger.yaml' => {
      openapi: '3.0.1',
      info: {
        title: 'API V1',
        version: 'v1'
      },
      components: {
        schemas: {
          post: { # <- 追加
            type: :object,
            properties: {
              id: { type: :integer },
              title: { type: :string },
              body: { type: :string },
              created_at: { type: :string, format: 'date-time' },
              updated_at: { type: :string, format: 'date-time' }
            },
          }
        }
      },
      paths: {},
      servers: [
        {
          url: 'https://{defaultHost}',
          variables: {
            defaultHost: {
              default: 'www.example.com'
            }
          }
        }
      ]
    }
  }

  config.openapi_format = :yaml
  config.openapi_strict_schema_validation = true # <- 追加
end

openapi_strict_schema_validation は個別のテストごとに有効化できるようですので、詳しくはドキュメントを参考にしてください。

テストを書く準備が整ったので、本題のrswagのテストコードを記述します。

便利なgenerateコマンドが用意されているので実行します。

rails generate rspec:swagger API::PostsController

spec/requests/api/posts_spec.rbというファイルが作成されるはずです。

今回はレスポンスが厳密にチェックできるのとスキーマが生成できるのを目的としてるので、作成されたものを少し編集して以下のようなテストコードになりました。

require 'swagger_helper'

RSpec.describe 'api/posts', type: :request do

  path '/api/posts' do

    get('list posts') do
      consumes 'application/json'
      produces 'application/json'
      response(200, 'successful') do
        schema type: :array,
                items: {
                  '$ref' => '#/components/schemas/post'
                }

        run_test!
      end
    end

    post('create post') do
      consumes 'application/json'
      produces 'application/json'
      parameter name: :new_post, in: :body, schema: {
        type: :object,
        properties: {
          api_post: {
            type: :object,
            properties: {
              title: { type: :string },
              body: { type: :string }
            }
          }
        },
      }
      response(201, 'successful') do
        let(:new_post) { { api_post: { title: 'title', body: 'body' } } }

        run_test!
      end
    end
  end

  path '/api/posts/{id}' do
    parameter name: 'id', in: :path, type: :string, description: 'id'

    get('show post') do
      consumes 'application/json'
      produces 'application/json'
      response(200, 'successful') do
        schema '$ref' => '#/components/schemas/post'

        let(:post) { create(:api_post, title: 'title', body: 'body') }
        let(:id) { post.id }
        run_test!
      end
    end

    patch('update post') do
      response(200, 'successful') do
        consumes 'application/json'
        produces 'application/json'
        parameter name: :post, in: :body, schema: {
          type: :object,
          properties: {
            api_post: {
              type: :object,
              properties: {
                title: { type: :string },
                body: { type: :string }
              }
            }
          },
        }

        before do
          @post = create(:api_post, title: 'title', body: 'body')
        end
        let(:id) { @post.id }
        let(:post) { { api_post: { title: 'new title', body: 'new body' } } }
        run_test!
      end
    end

    delete('delete post') do
      response(204, 'successful') do
        consumes 'application/json'
        produces 'application/json'

        before do
          @post = create(:api_post, title: 'title', body: 'body')
        end
        let(:id) { @post.id }

        run_test!
      end
    end
  end
end

先ほどのspec/swagger_helper.rbで定義したPostのComponentはresponse()の部分で使用されています。

これによりPost一覧と詳細のAPIで記述量を削減できるのと、TypeScript側でGeneratorを使ってクライアントコードを生成した際にも定義したComponentの型がexportされるため便利です。

テストを実行して問題なければ、以下コマンドでスキーマを生成します。


rspec

rails rswag:specs:swaggerize

うまくいけばswagger.yamlといファイルが作成されます。

---
openapi: 3.0.1
info:
  title: API V1
  version: v1
components:
  schemas:
    post:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        body:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
paths:
  "/api/posts":
    get:
      summary: list posts
      responses:
        '200':
          description: successful
          content:
            application/json:
              schema:
                type: array
                items:
                  "$ref": "#/components/schemas/post"
    post:
      summary: create post
      parameters: []
      responses:
        '201':
          description: successful
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                api_post:
                  type: object
                  properties:
                    title:
                      type: string
                    body:
                      type: string
  "/api/posts/{id}":
    parameters:
    - name: id
      in: path
      description: id
      required: true
      schema:
        type: string
    get:
      summary: show post
      responses:
        '200':
          description: successful
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/post"
    patch:
      summary: update post
      parameters: []
      responses:
        '200':
          description: successful
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                api_post:
                  type: object
                  properties:
                    title:
                      type: string
                    body:
                      type: string
    delete:
      summary: delete post
      responses:
        '204':
          description: successful
servers:
- url: https://{defaultHost}
  variables:
    defaultHost:
      default: www.example.com

これで目的の1つであるテストコードからAPIの仕様書を生成までできました。

CORS設定

API側の最後の仕上げとしてフロントエンド側からアクセスできるようCORSの設定をしておきます。

Gemfileを編集、インストールします。

gem "rack-cors"
bundle install

config/initializers/cors.rbを編集し、アクセス可能なOriginを設定します。

# Be sure to restart your server when you modify this file.

# Avoid CORS issues when API is called from the frontend app.
# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests.

# Read more: https://github.com/cyu/rack-cors

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "http://localhost:5173"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

さらに今回はOpenAPIスキーマからそのままクライアントコードを生成する関係で、spec/swagger_helper.rbを編集してurlを開発サーバーに向けておく必要があります。

実際は環境に応じて切り替える必要があるかもしれません。

※クライアント側で切り替えることもありです

今回は説明を簡単にするため以下のようにlocalhost:3000へ向けておきます。

RSpec.configure do |config|
  config.openapi_specs = {
    'v1/swagger.yaml' => {
	    # 諸々省略
      servers: [
        {
          url: 'http://localhost:3000',
        }
      ]
    }
  }
end

これでRails側の実装は完了となります。

Reactプロジェクト作成

次はReact側で用意したAPIへリクエストを行いPostデータを取得するまで説明します。

今回はRailsの同じプロジェクト内にViteテンプレートを使ってfrontendoというディレクトリにReactプロジェクトを作成します。

今回は説明を簡単にするのとRailsがAPIモードのためこの方法を選択しましたが、別リポジトリやサブモジュールで管理など別の方法でも問題ないと思います。

古い記事ですが、私が前に書いたvite_railsを使用してRails環境にReactを共存させる形でも良いと思います。

以下コマンドを実行してfrontendディレクトリにVite + React + TypeScript環境を作成します。

rm package.json package-lock.json
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
npm run dev

TanStack Queryセットアップ

問題なく動いたら次にTanStack Queryを導入します。

npm install @tanstack/react-query

frontend/src/App.tsxを編集してQueryClientProviderを追加します。

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./App.css";
import { Posts } from "./Posts";

const queryClient = new QueryClient();

function App() {
  return (
    <>
      <QueryClientProvider client={queryClient}>
        <Posts />
      </QueryClientProvider>
    </>
  );
}

export default App;

frontend/src/Posts.tsxを編集します。ここでは特に何も処理を書きません。

export function Posts() {
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}

OpenAPIからクライアントコードとTanStack Queryのフックを生成する

最後に私がOSSでメンテしているOpenAPI React Query Codegenというツールを使いOpenAPIスキーマからTanStack QueryのHooksを生成します。

npm install -D @7nohe/openapi-react-query-codegen

インストールが終わったら、package.jsonを編集してコマンドを追加します。

{
  "scripts": {
    "generate:client": "openapi-rq -i ../swagger/v1/swagger.yaml"
  },
}

その後、コマンドを実行します。

npm run generate:client

すると以下のようなディレクトリが作成されます。

  • frontend/openapi
    • queries: TanStack Queryフックコード
    • requests: APIクライアントコード

このツールでは内部で@hey-api/openapi-ts というTSクライアントコード生成ライブラリを使用しており、ReactやTanStack Queryが関係しないピュアなTSコードのAPIリクエスト処理を先にrequestsディレクトリへ生成しています。

その後OpenAPI React Query Codegenで生成コードを解析、queriesディレクトリに対してTanStack Queryのコードを生成しています。

詳しくは生成されたコードを確認するとわかると思います。今回は生成コードの説明は省きます。

生成コードを使ってfrontend/src/Posts.tsxでPost一覧データを取得表示する処理を追記します。

import { useDefaultServiceGetApiPosts } from "../openapi/queries";
export function Posts() {
  const { data, isLoading } = useDefaultServiceGetApiPosts();

  if (isLoading) {
    return <div>Loading...</div>;
  }
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {data?.map((post) => (
          <li key={post.id}>
            <p>
              {post.title}: {post.body}
            </p>
          </li>
        ))}
      </ul>
    </div>
  );
}

npm run dev で実行してみると、無事に一覧データが取得できました。

TanStack Query Demo

おわりに

今回はRails側でAPI作成してテストコードを書き、そのままOpenAPIスキーマを生成、そのスキーマからTanStack Queryのコードを生成してデータフェッチまでを行いました。

ご紹介したようにymlを書かずにRails側でテストコードを書いただけでAPI仕様書が作成できました。

さらにReact側ではAPIリクエスト処理を一切書かずに生成されたHooksをインポートしてきただけでPostの一覧データを取得できました。

レスポンスにも型がついており、API側との齟齬も起きにくくなります。API側もrswagのopenapi_strict_schema_validationオプションを有効にしてテストすることで、正しいプロパティがレスポンスに含まれていることを担保できています。

API仕様書のメンテは割と後回しにされがちですし、ymlを書くのも結構しんどいですが、この方法なら常に実態にあった仕様書が作れるので良い方法ではないかと考えています。

Daiki Urata

Daiki Urata

Twitter X

フロントエンド/モバイルアプリなどを主に開発しています。