🚀

Rswag を使って RSpec から API ドキュメントを生成する

2023/07/28に公開

はじめに

以前 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

コード

https://github.com/kzy52/rails-rspec-rswag

ライブラリ

Rswag

https://github.com/rswag/rswag

  • テストファーストな API 開発ができる gem です。
  • RSpec から swagger.yml を生成できるようになります。

json-schema_builder

https://github.com/parrish/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

ドキュメントの確認

http://localhost:3000/api-docs/index.html

Tandems Inc. TECH BLOG

Discussion