🚀
Rswag を使って RSpec から API ドキュメントを生成する
はじめに
以前 API ドキュメントはスプレッドシートや社内 Wiki で管理したりしていました。
最近だと OpenAPI Specification を使って書くことがほとんどです。
OpenAPI Specification とは RESTful な API を記述するための仕様で YAML と JSON 形式で記述できます。
形式はシンプルですが人力でやろうとすると、実装と乖離したり、間違いなどが必ず発生します。
今回の記事では実装と API ドキュメントが乖離しないようする方法の一つとして Rswag を使って RSpec から API ドキュメントを自動生成する方法について紹介したいと思います。
環境
- Ruby:
3.2.2
- Rails:
7.0.6
- MySQL:
8.0
コード
ライブラリ
Rswag
- テストファーストな API 開発ができる gem です。
- RSpec から swagger.yml を生成できるようになります。
json-schema_builder
- rswag-specs でリクエスト、レスポンスデータの共通化に利用しています。
テスト対象の API の作成
app/controllers/articles_controller.rb
# frozen_string_literal: true
class ArticlesController < ApplicationController
before_action :set_article, only: %i[show edit update destroy]
def index
articles = Article.all
render json: { articles: }
end
def show
if @article
render json: { article: @article }
else
render_404
end
end
def create
article = Article.new(article_params)
if article.save
render json: { article: }, status: :created
else
render_400
end
end
def update
if @article.update(article_params)
render json: { article: @article }
else
render_400
end
end
def destroy
@article.destroy
head :no_content
end
private
def set_article
@article = Article.find_by(id: params[:id])
unless (@article)
render_404
end
end
def article_params
params.require(:article).permit(:title, :content)
end
end
config/routes.rb
# frozen_string_literal: true
Rails.application.routes.draw do
mount Rswag::Ui::Engine => '/api-docs'
mount Rswag::Api::Engine => '/api-docs'
+ resources :articles, only: %i[index show create update destroy]
end
app/controllers/concerns/error_handling.rb
# frozen_string_literal: true
module ErrorHandling
private
def render_400(messages = [])
render json: { errors: messages.presence || ['400 Bad Request'] }, status: :bad_request
end
def render_401(messages = [])
render json: { errors: messages.presence || ['401 Unauthorized'] }, status: :unauthorized
end
def render_403(messages = [])
render json: { errors: messages.presence || ['403 Forbidden'] }, status: :forbidden
end
def render_404(messages = [])
render json: { errors: messages.presence || ['404 Not Found'] }, status: :not_found
end
end
app/controllers/application_controller.rb
# frozen_string_literal: true
class ApplicationController < ActionController::API
+ include ErrorHandling
end
テストで仕様するスキーマの作成
スキーマの作成は json-schema_builder
という gem を使って書いています。
spec/schemas/request/article_schema.rb
# frozen_string_literal: true
module SpecSchemas
module Request
class ArticleSchema
include JSON::SchemaBuilder
def schema
object do
object :article do
string :title, required: true, description: 'タイトル'
string :content, description: '本文'
end
end
end
end
end
end
spec/schemas/response/article_schema.rb
# frozen_string_literal: true
module SpecSchemas
module Response
class ArticleSchema
include JSON::SchemaBuilder
def schema
object do
object :article do
number :id, required: true, description: 'ID'
string :title, required: true, description: 'タイトル'
string :content, required: true, description: '本文'
string :created_at, required: true, description: '登録日時'
string :updated_at, required: true, description: '更新日時'
end
end
end
end
end
end
spec/schemas/response/articles_schema.rb
# frozen_string_literal: true
module SpecSchemas
module Response
class ArticlesSchema
include JSON::SchemaBuilder
def schema
object do
array :articles do
items do
[
object do
number :id, required: true, description: 'ID'
string :title, required: true, description: 'タイトル'
string :content, required: true, description: '本文'
string :created_at, required: true, description: '登録日時'
string :updated_at, required: true, description: '更新日時'
end
]
end
end
end
end
end
end
end
spec/schemas/response/error_schema.rb
# frozen_string_literal: true
module SpecSchemas
module Response
class ErrorSchema
include JSON::SchemaBuilder
def schema
object do
array :errors do
items type: :string
end
end
end
end
end
end
Request Spec の作成
spec/support/shared_examples.rb
# frozen_string_literal: true
RSpec.shared_examples 'valid_request_schema' do
it 'valid request schema' do
errors = expected_request_schema.schema.fully_validate(params)
expect(errors).to be_empty
end
end
RSpec.shared_examples 'valid_response_schema' do
it 'valid response schema' do
json_response = JSON.parse(response.body)
errors = expected_response_schema.schema.fully_validate(json_response)
expect(errors).to be_empty
end
end
spec/requests/articles/index_spec.rb
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Articles' do
describe 'GET /articles' do
before { create(:article) }
path '/articles' do
get 'get articles' do
produces 'application/json'
expected_response_schema = SpecSchemas::Response::ArticlesSchema.new
parameter name: :page,
in: :query,
type: :integer
let(:page) { 1 }
response 200, 'Success' do
schema expected_response_schema.schema.as_json
it { expect(response).to have_http_status(:ok) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_response_schema }
end
run_test!
end
end
end
end
end
spec/requests/articles/show_spec.rb
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Articles' do
describe 'GET /articles/:id' do
let!(:article) { create(:article) }
path '/articles/{id}' do
get 'get article' do
produces 'application/json'
expected_response_schema = SpecSchemas::Response::ArticleSchema.new
expected_error_response_schema = SpecSchemas::Response::ErrorSchema.new
parameter name: :id,
in: :path,
type: :integer
response 200, 'Success' do
let(:id) { article.id }
schema expected_response_schema.schema.as_json
it { expect(response).to have_http_status(:ok) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_response_schema }
end
run_test!
end
response 404, 'NotFound' do
let(:id) { -1 }
schema expected_error_response_schema.schema.as_json
it { expect(response).to have_http_status(:not_found) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_error_response_schema }
end
run_test!
end
end
end
end
end
spec/requests/articles/create_spec.rb
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Articles' do
describe 'POST /articles' do
path '/articles' do
post 'creates a article' do
consumes 'application/json'
produces 'application/json'
expected_request_schema = SpecSchemas::Request::ArticleSchema.new
expected_response_schema = SpecSchemas::Response::ArticleSchema.new
expected_error_response_schema = SpecSchemas::Response::ErrorSchema.new
parameter name: :params,
in: :body,
schema: expected_request_schema.schema.as_json
response 201, 'Created' do
let(:params) do
{
article: {
title: 'article title',
content: 'article content',
}
}
end
schema expected_response_schema.schema.as_json
it { expect(response).to have_http_status(:created) }
it_behaves_like 'valid_request_schema' do
let(:expected_request_schema) { expected_request_schema }
end
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_response_schema }
end
run_test!
end
response 400, 'Bad Request' do
let(:params) do
{
article: {
title: nil,
content: nil,
}
}
end
schema expected_error_response_schema.schema.as_json
it { expect(response).to have_http_status(:bad_request) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_error_response_schema }
end
run_test!
end
end
end
end
end
spec/requests/articles/update_spec.rb
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Articles' do
describe 'PATCH /articles/:id' do
let!(:article) { create(:article) }
path '/articles/{id}' do
patch 'updates a article' do
consumes 'application/json'
produces 'application/json'
expected_request_schema = SpecSchemas::Request::ArticleSchema.new
expected_response_schema = SpecSchemas::Response::ArticleSchema.new
expected_error_response_schema = SpecSchemas::Response::ErrorSchema.new
parameter name: :id,
in: :path,
type: :integer
parameter name: :params,
in: :body,
schema: expected_request_schema.schema.as_json
let(:id) { article.id }
let(:params) do
{
article: {
title: 'article title',
content: 'article content',
}
}
end
response 200, 'Success' do
schema expected_response_schema.schema.as_json
it { expect(response).to have_http_status(:ok) }
it_behaves_like 'valid_request_schema' do
let(:expected_request_schema) { expected_request_schema }
end
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_response_schema }
end
run_test!
end
response 400, 'Bad Request' do
let(:params) do
{
article: {
title: nil,
content: nil
}
}
end
schema expected_error_response_schema.schema.as_json
it { expect(response).to have_http_status(:bad_request) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_error_response_schema }
end
run_test!
end
response 404, 'Not Found' do
let(:id) { -1 }
schema expected_error_response_schema.schema.as_json
it { expect(response).to have_http_status(:not_found) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_error_response_schema }
end
run_test!
end
end
end
end
end
spec/requests/articles/destroy_spec.rb
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Articles' do
describe 'DELETE /articles/:id' do
let!(:article) { create(:article) }
path '/articles/{id}' do
delete 'delete article' do
produces 'application/json'
expected_error_response_schema = SpecSchemas::Response::ErrorSchema.new
parameter name: :id,
in: :path,
type: :integer
response 204, 'No Content' do
let(:id) { article.id }
it { expect(response).to have_http_status(:no_content) }
run_test!
end
response 404, 'NotFound' do
let(:id) { -1 }
schema expected_error_response_schema.schema.as_json
it { expect(response).to have_http_status(:not_found) }
it_behaves_like 'valid_response_schema' do
let(:expected_response_schema) { expected_error_response_schema }
end
run_test!
end
end
end
end
end
本当はもう少し細かくテストを書くのですが、今回は分かりやすいようにシンプルに書いてみました。
ドキュメントの生成
$ docker-compose run --rm -e RAILS_ENV=test app rspec --format Rswag::Specs::SwaggerFormatter --order defined --pattern 'spec/requests/**/*_spec.rb'
以下のようなファイルが生成されます。
swagger/v1/swagger.yaml
---
openapi: 3.0.1
info:
title: API V1
version: v1
paths:
"/articles":
post:
summary: create article
parameters: []
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
article:
type: object
required:
- id
- title
- content
- created_at
- updated_at
properties:
id:
type: number
description: ID
title:
type: string
description: タイトル
content:
type: string
description: 本文
created_at:
type: string
description: 登録日時
updated_at:
type: string
description: 更新日時
'400':
description: Bad Request
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: string
requestBody:
content:
application/json:
schema:
type: object
properties:
article:
type: object
required:
- title
properties:
title:
type: string
description: タイトル
content:
type: string
description: 本文
get:
summary: get articles
parameters:
- name: page
in: query
schema:
type: integer
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
articles:
type: array
items:
type: object
required:
- id
- title
- content
- created_at
- updated_at
properties:
id:
type: number
description: ID
title:
type: string
description: タイトル
content:
type: string
description: 本文
created_at:
type: string
description: 登録日時
updated_at:
type: string
description: 更新日時
"/articles/{id}":
delete:
summary: delete article
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'204':
description: No Content
'404':
description: NotFound
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: string
get:
summary: get article
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
article:
type: object
required:
- id
- title
- content
- created_at
- updated_at
properties:
id:
type: number
description: ID
title:
type: string
description: タイトル
content:
type: string
description: 本文
created_at:
type: string
description: 登録日時
updated_at:
type: string
description: 更新日時
'404':
description: NotFound
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: string
patch:
summary: update article
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
article:
type: object
required:
- id
- title
- content
- created_at
- updated_at
properties:
id:
type: number
description: ID
title:
type: string
description: タイトル
content:
type: string
description: 本文
created_at:
type: string
description: 登録日時
updated_at:
type: string
description: 更新日時
'400':
description: Bad Request
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: string
'404':
description: Not Found
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: string
requestBody:
content:
application/json:
schema:
type: object
properties:
article:
type: object
required:
- title
properties:
title:
type: string
description: タイトル
content:
type: string
description: 本文
servers:
- url: https://{defaultHost}
variables:
defaultHost:
default: localhost:3000
ドキュメントの確認
Discussion