Table of Contents
はじめに
今回は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-specs
とrswag-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
で実行してみると、無事に一覧データが取得できました。
おわりに
今回はRails側でAPI作成してテストコードを書き、そのままOpenAPIスキーマを生成、そのスキーマからTanStack Queryのコードを生成してデータフェッチまでを行いました。
ご紹介したようにymlを書かずにRails側でテストコードを書いただけでAPI仕様書が作成できました。
さらにReact側ではAPIリクエスト処理を一切書かずに生成されたHooksをインポートしてきただけでPostの一覧データを取得できました。
レスポンスにも型がついており、API側との齟齬も起きにくくなります。API側もrswagのopenapi_strict_schema_validationオプションを有効にしてテストすることで、正しいプロパティがレスポンスに含まれていることを担保できています。
API仕様書のメンテは割と後回しにされがちですし、ymlを書くのも結構しんどいですが、この方法なら常に実態にあった仕様書が作れるので良い方法ではないかと考えています。