Rails プロジェクトで クライアント向けの APIClient を自動生成するときの構成を試してみた.
Swagger で記述した API 定義からコード生成する例はちらほら見かけるが,Netflix 製の fast_jsonapi を使った記事は見かけなかったので,まとめておく.

完成版のソースコードはこちら 👉 tanakaworld/swagger-blocks-fastjson-api

TL;DR;

Part1: API定義編 (本記事)

  • Backend は Rails で API を実装
  • JSON シリアライザとして,Netflix 製の fastjson_api
  • API 定義は swagger-blocks を使用

Part2: コード生成編 (作成中)

Part3: 自動テスト編 (作成中)

  • RSpec で Reqeuests 自動テスト
  • committee-rails で Swagger 定義との整合性チェック

Scaffold Books

書籍情報の CRUD を題材に考える.
Rails 6.0.0.beta3 を使った.

scaffold で Books を生成し,画像アップロードは Active Storage を使う.

1
2
$ bundle exec rails g scaffold books title:string description:text
$ bundle exec rails db:migrate

Active Storage を有効化し,Model / View / Controller で image の記述を追加する.

1
2
$ bundle exec rails active_storage:install
$ bundle exec rails db:migrate
1
2
3
4
# app/models/book.rb
class Book < ApplicationRecord
has_one_attached :image
end

scaffold_books_list

Serializer

Books 向けとエラーハンドリング用の Serializer を用意する.

1
2
# Gemfile
gem 'fast_jsonapi'
1
2
$ bundle exec rails g serializer books
$ bundle exec rails g serializer error
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# app/serializers/book_serializer.rb
class BookSerializer
include FastJsonapi::ObjectSerializer
attributes :id,
:title,
:description,
:created_at,
:updated_at

attribute :image_url do |object|
Rails.application.routes.url_helpers.rails_blob_url(object.image) if object.image.attached?
end
end

# app/serializers/error_serializer.rb
class ErrorSerializer
include FastJsonapi::ObjectSerializer
attributes :errors
end

API 実装

app/controllers/api/books_controller.rb を実装する.app/controllers/books_controller.rb とほぼ同じだが,APIレスポンス箇所で Serializer を使う.

render json: BookSerializer.new(@books).serialized_json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Api::BooksController < ApplicationController
before_action :set_book, only: [:show, :update, :destroy]

def index
@books = Book.all
render json: BookSerializer.new(@books).serialized_json
end

def show
render json: BookSerializer.new(@book).serialized_json
end

def create
@book = Book.new(book_params)

if @book.save
render json: BookSerializer.new(@book).serialized_json, status: :created
else
render json: ErrorSerializer.new(@book).serialized_json, status: :unprocessable_entity
end
end

def update
if @book.update(book_params)
render json: BookSerializer.new(@book).serialized_json, status: :ok
else
render json: ErrorSerializer.new(@book).serialized_json, status: :unprocessable_entity
end
end

def destroy
@book.destroy
render json: nil, status: :no_content
end

private

def set_book
@book = Book.find(params[:id])
end

def book_params
params.permit(:title, :description, :image)
end
end
1
2
3
4
5
6
7
8
# config/routes.rb
Rails.application.routes.draw do
resources :books

namespace :api, defaults: {format: :json} do
resources :books, except: [:new, :edit]
end
end

/api/books のレスポンスはこうなる.

api_books_response

Swagger 定義のディレクトリ構成

各 controller 上に swgger 定義を普通に記述してもよいが,一瞬で見通しが悪くなる.
Swagger 定義と API 実装の記述箇所を分離するために次の構成にした.
(参考:Rails + swagger-blocks で OpenAPI 形式の API ドキュメントを作成する

swagger_dir.png

Controller に依存する定義は app/controllers/concerns に配置,swagger_path で API リクエストパスに対応する定義を記述する.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# app/controllers/concerns/books_api.rb
module Swagger::BooksApi
extend ActiveSupport::Concern
include Swagger::Blocks

included do
include Swagger::ErrorSchema

swagger_path '/api/books' do
# Index
operation :get do
key :operationId, 'getBooks'
key :tags, ['sampleApp']

parameter name: :id,
in: :path,
required: true,
type: :integer,
format: :int64
response 200 do
key :description, 'Books response'
fja_response_schema :array, :Book
end
extend Swagger::ErrorResponses::NotFoundError
end
end
end

•••
end

Model に依存する定義は app/models/concerns に配置し,主に swagger_schema で resource や request / response の形式を定義する.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# app/models/concerns/books_schema.rb

module Swagger::BookSchema
extend ActiveSupport::Concern
include Swagger::Blocks

included do
swagger_schema :Book,
required: [:title, :description, :image_url],
additionalProperties: false do
property :id, type: :integer
property :title, type: :string
property :description, type: :string
property :image_url, type: :string
property :created_at, type: :string
property :updated_at, type: :string
end

# response
fja_swagger_schema :Book

# request
swagger_schema :CreateBookRequest, additionalProperties: false do
property :title, type: :string
property :description, type: :string
property :image, type: :object
end
swagger_schema :UpdateBookRequest, additionalProperties: false do
property :title, type: :string
property :description, type: :string
property :image, type: :object
end
end
end

fastjson_api 向けの swagger-blocks ラッパーを実装

fast_jsonapi は jsonapi に準拠している.
この形式を各定義に書くのは冗長なので,JSON API 形式準拠したレスポンス形式を記述するラッパー fja_swagger_schema を実装した.
swagger_schema の定義名とレスポンス形式が object or array を選択できるようにしている.

前者は Controller 側の記述で使い,後者は Model 側の記述で使う.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# config/initializers/swagger_blocks.rb
module Swagger::Blocks
module ClassMethods
private

def fja_swagger_schema(schema_name)
swagger_schema "#{schema_name}Response".to_sym,
required: [:id, :type, :attributes],
additionalProperties: false do
property :id, type: :string
property :type, type: :string
property :attributes, '$ref': schema_name.to_sym
yield(self) if block_given?
end
end
end
end

module Swagger::Blocks::Nodes
class ResponseNode
def fja_response_schema(type, schema_name)
schema do
key :type, :object
key :required, [:data]
property :data do
key :type, type.to_sym
if type.to_sym === :array
items do
key :'$ref', "#{schema_name}Response".to_sym
end
elsif type.to_sym === :object
key :type, type.to_sym
key :'$ref', "#{schema_name}Response".to_sym
else
raise Error.new "Unexpected schema type:#{type} name:#{schema_name}"
end
end
end
end
end
end

Model 側で記述した swagger_schema :Book を用いて fja_swagger_schema :Book で fastjson_api のレスポンス形式の定義が記述できる.
そして Controller 側の fja_response_schema :array, :Book で API レスポンス定義として使える.

swagger-blocks の記述から JSON を生成する

ここまでで記述してきた Model と Controller を集約し,Swagger::Blocks.build_root_json(SWAGGERED_CLASSES) によって Swagger JSON を生成する.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# app/controllers/concerns/swagger/api_docs.rb
module Swagger::ApiDocs
extend ActiveSupport::Concern
include Swagger::Blocks

included do
swagger_root do
key :swagger, '2.0'
info do
key :version, '1.0.0'
key :title, 'swagger-blocks with fastjson_api'
key :description, 'swagger-blocks with fastjson_api'
end
key :produces, ['application/json']
key :consumes, ['application/json']
end

SWAGGERED_CLASSES = [
# models
Book,

# controllers
Api::BooksController,

self
].freeze
end

def swagger_data
Swagger::Blocks.build_root_json(SWAGGERED_CLASSES)
end
end

デバッグ用に,ローカル開発中に Swagger 定義を確認できるエンドポイントを用意する.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# app/controllers/api/swagger_controller.rb
class Api::SwaggerController < ActionController::Base
include Swagger::ApiDocs

def index
render json: swagger_data, status: :ok
end
end

# config/routes.rb
namespace :api, defaults: {format: :json} do
resources :swagger, only: [:index] unless Rails.env.production?

•••
end

http://localhost:3000/api/swagger にアクセスすると次のような JSON が得られる.
定義が間違っているときはこの生成自体エラーになることが大半だが,生成できても意図しない動作するときなどのデバッグに使う.
swagger-ui とかに JSON を食わせて見やすくするなども可能.

1
{"swagger":"2.0","info":{"version":"1.0.0","title":"swagger-blocks with fastjson_api","description":"swagger-blocks with fastjson_api"},"produces":["application/json"],"consumes":["application/json"],"paths":{"/api/books":{"get":{"operationId":"getBooks","tags":["sampleApp"],"parameters":[{"name":"id","in":"path","required":true,"type":"integer","format":"int64"}],"responses":{"200":{"description":"Books response","schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/definitions/BookResponse"}}}}},"404":{"description":"Resource not found","schema":{"$ref":"#/definitions/ErrorOutput"}}}},"post":{"operationId":"createBook","tags":["sampleApp"],"consumes":["multipart/form-data"],"parameters":[{"name":"body","in":"body","required":true,"schema":{"$ref":"#/definitions/CreateBookRequest"}}],"responses":{"201":{"description":"Book response","schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","$ref":"#/definitions/BookResponse"}}}},"400":{"description":"Invalid parameters","schema":{"$ref":"#/definitions/ErrorOutput"}},"422":{"description":"Unprocessable Entity","schema":{"$ref":"#/definitions/ErrorOutput"}}}}},"/api/books/{id}":{"get":{"operationId":"getBook","tags":["sampleApp"],"parameters":[{"name":"id","in":"path","required":true,"type":"integer","format":"int64"}],"responses":{"200":{"description":"Book response","schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","$ref":"#/definitions/BookResponse"}}}},"404":{"description":"Resource not found","schema":{"$ref":"#/definitions/ErrorOutput"}}}},"put":{"operationId":"updateBook","tags":["sampleApp"],"consumes":["multipart/form-data"],"parameters":[{"name":"id","in":"path","required":true,"type":"integer","format":"int64"},{"name":"body","in":"body","required":true,"schema":{"$ref":"#/definitions/UpdateBookRequest"}}],"responses":{"200":{"description":"Book response","schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","$ref":"#/definitions/BookResponse"}}}},"400":{"description":"Invalid parameters","schema":{"$ref":"#/definitions/ErrorOutput"}},"422":{"description":"Unprocessable Entity","schema":{"$ref":"#/definitions/ErrorOutput"}},"404":{"description":"Resource not found","schema":{"$ref":"#/definitions/ErrorOutput"}}}},"delete":{"operationId":"deleteBook","tags":["sampleApp"],"parameters":[{"name":"id","in":"path","required":true,"type":"integer","format":"int64"}],"responses":{"204":{"description":"No content response","schema":{}},"400":{"description":"Invalid parameters","schema":{"$ref":"#/definitions/ErrorOutput"}},"422":{"description":"Unprocessable Entity","schema":{"$ref":"#/definitions/ErrorOutput"}}}}}},"definitions":{"Book":{"required":["title","description","image_url"],"additionalProperties":false,"properties":{"id":{"type":"integer"},"title":{"type":"string"},"description":{"type":"string"},"image_url":{"type":"string"},"created_at":{"type":"string"},"updated_at":{"type":"string"}}},"BookResponse":{"required":["id","type","attributes"],"additionalProperties":false,"properties":{"id":{"type":"string"},"type":{"type":"string"},"attributes":{"$ref":"#/definitions/Book"}}},"CreateBookRequest":{"additionalProperties":false,"properties":{"title":{"type":"string"},"description":{"type":"string"},"image":{"type":"object"}}},"UpdateBookRequest":{"additionalProperties":false,"properties":{"title":{"type":"string"},"description":{"type":"string"},"image":{"type":"object"}}},"ErrorOutput":{"required":["errors"],"additionalProperties":false,"properties":{"errors":{"type":"array","items":{"type":"string"}}}}}}

TBA

  • Part2: コード生成編
  • Part3: 自動テスト編