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: コード生成編 (作成中)
- openapi-generator で TypeScript の APIClient を自動生成
Part3: 自動テスト編 (作成中)
- RSpec で Reqeuests 自動テスト
- committee-rails で Swagger 定義との整合性チェック
Scaffold Books
書籍情報の CRUD を題材に考える. Rails 6.0.0.beta3 を使った.
scaffold で Books を生成し,画像アップロードは Active Storage を使う.
$ bundle exec rails g scaffold books title:string description:text
$ bundle exec rails db:migrate
Active Storage を有効化し,Model / View / Controller で image
の記述を追加する.
$ bundle exec rails active_storage:install
$ bundle exec rails db:migrate
# app/models/book.rb
class Book < ApplicationRecord
has_one_attached :image
end
Serializer
Books 向けとエラーハンドリング用の Serializer を用意する.
# Gemfile
gem 'fast_jsonapi'
$ bundle exec rails g serializer books
$ bundle exec rails g serializer error
# 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
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
# 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
のレスポンスはこうなる.
Swagger 定義のディレクトリ構成
各 controller 上に swgger 定義を普通に記述してもよいが,一瞬で見通しが悪くなる. Swagger 定義と API 実装の記述箇所を分離するために次の構成にした. (参考:Rails + swagger-blocks で OpenAPI 形式の API ドキュメントを作成する)
Controller に依存する定義は app/controllers/concerns
に配置,swagger_path
で API リクエストパスに対応する定義を記述する.
# 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 の形式を定義する.
# 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 側の記述で使う.
# 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 を生成する.
# 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 定義を確認できるエンドポイントを用意する.
# 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 を食わせて見やすくするなども可能.
{"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: 自動テスト編