こんにちは。スタートトゥデイテクノロジーズ新事業創造部のid:takanamitoです。
今日はVASILY時代から活用されているOpenAPI(Swagger)の定義からRubyのクラスを自動生成するgemを作ったので、その紹介をしようと思います。
Swaggerの定義と実際のAPIが返すレスポンスの内容がズレている
弊社ではVASILY時代からSwaggerの導入が進んでいましたが、徐々に「Swaggerの定義と実際のAPIが返すレスポンスの内容がズレている」といった問題が発生しはじめていました。
その問題を解決するために今回つくったのがこのgemです。 github.com
例えばこんなOpenAPI Specification 3.0のYAMLの定義から
schemas: user: type: object properties: username: type: string uuid: type: string repository: type: object properties: slug: type: string owner: $ref: '#/components/schemas/user' pullrequest: type: object properties: id: type: integer title: type: string repository: $ref: '#/components/schemas/repository' author: $ref: '#/components/schemas/user'
こんなクラスが自動生成できます。
# cliで生成 $ openapi2ruby generate ./path/to/link-example.yaml --out ./ $ ls . pullrequest_serializer.rb repository_serializer.rb user_serializer.rb
class PullrequestSerializer < ActiveModel::Serializer attributes :id, :title, :repository, :author def repository RepositorySerializer.new(object.repository) end def author UserSerializer.new(object.user) end def id type_check(:id, [Integer]) object.id end def title type_check(:title, [String]) object.title end private def type_check(name, types) raise "Field type is invalid. #{name}" unless types.include?(object.send(name).class) end end
開発の経緯
先述の「Swaggerの定義と実際のAPIが返すレスポンスの内容がズレている」問題
APIの改修時に必ずSwagger定義も更新した上でアプリケーションを書き換えるという運用が人の手によって行われていたため、当然起こりうる事象だったのですが
実際にコードを読んでみるとController内で単にHashのオブジェクトを to_json
してレスポンスデータを生成している処理が散見されました。
そのためAPIに型の概念を持ち込んでSwagger上の定義とレスポンスを一致させる方法を考え始めました。
実際にはOpenAPIのschema定義からActiveModel::Serializer
のクラスを自動生成しています。
調査
「コードの自動生成」という用途においては swagger-codegenが有名だったので、まずは今回やりたいRubyクラスの自動生成ができないか調査してみました。
デフォルトでは ruby, sinatra, rails5 のコードジェネレータが用意されており、以下のようにDockerを使ってコードの自動生成ができます。
# 通常のコードジェネレート $ ./run-in-docker.sh generate -i modules/swagger-codegen/src/test/resources/2_0/petstore.yaml -l ruby -o /path/to/output # 独自テンプレートでコードジェネレート $ ./run-in-docker.sh generate -i modules/swagger-codegen/src/test/resources/2_0/petstore.yaml -l ruby -t path/to/template_dir -o /path/to/output
実際にコードジェネレートすることはできますが、以下の点が気になりました。
- テンプレートにmustache記法を使うことを強制される
- 欲しいのはschema定義された数ファイルだけなのに不要なファイルが大量に生成されてしまう
- 回避しようと新しい対応言語を定義してみたがjarを実行するなどの手順が必要
たまたまやる気があったので、シンプルにOpenAPI Specificaton 3.0のschema定義からRubyのクラスだけを生成するgemを作ることにしました。
(後から知ったんですが --ignore-file-override
と .swagger-codegen-ignore
を使えば指定したテンプレートのみ使ってコードジェネレートできるようです。)
導入の利点
現状、Rubyアプリケーションにおいてスキーマ定義どおりにレスポンスが返っているか検証するにはテストを書くか
もしくはGraphQLなどのスキーマと実装が密接に紐付いている仕組みを採用することになると思います。
しかしテストを書くか否かは実装者に依存してしまいますし、既存APIをRESTからGraphQLに置き換えるのは工数的にもなかなか選べないことが多いはずです。
そういう状況において「スキーマから自動生成したシリアライザーでレスポンスの型を保証できる」今回のようなアプローチは有用かと思います。
スキーマファーストで開発をしていても、スキーマを更新した後アプリケーションにその変更を反映することを忘れてしまうと同じ問題が起こってしまいますが
今回のgemはcliを提供しているので「シリアライザーのファイルをgit管理下から外し、CIでテストやデプロイ時に最新のスキーマから自動生成して配置する」といったことも可能です。
「人が忘れてたことによってOpenAPIのスキーマ定義と実際のレスポンスがズレる」といった問題が防げるところに導入の利点があると考えています。
サンプル
このgemを使いつつ、Twitterのような簡単なRails APIを作ってみます。
まずはGemfileに以下を追加
gem 'active_model_serializers'
登場するモデルは User
, Profile
, Tweet
の3種類です。
class User < ApplicationRecord has_one :profile has_many :tweets end class Profile < ApplicationRecord belongs_to :user end class Tweet < ApplicationRecord belongs_to :user end
スキーマはこんな感じ。
create_table "profiles", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.bigint "user_id" t.string "description" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["user_id"], name: "index_profiles_on_user_id" end create_table "tweets", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.bigint "user_id" t.string "tweet_text" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["user_id"], name: "index_tweets_on_user_id" end create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end
seeds.rbでサンプルデータも。
User.create(name: 'takanamito') Profile.create(user: User.first, description: 'プロフィールです') Tweet.create(user: User.first, tweet_text: '我が問いに空言人が焼かれ死ぬ') Tweet.create(user: User.first, tweet_text: 'オレは太刀の間合い(半径4m)までで十分...!!(つーか これが限界)') Tweet.create(user: User.first, tweet_text: '私の垂直跳びベストは16m80cm!!!')
以下のようなユーザー情報を返すAPIをOpenAPIで定義します。
schemas: user: type: object properties: name: type: string profile: $ref: '#/components/schemas/profile' tweets: type: array items: $ref: '#/components/schemas/tweet' profile: type: object properties: description: type: string tweet: type: object properties: tweet_text: type: string
ActiveModel::Serializerを使う前提でControllerを書いてゆきます。
class UsersController < ApplicationController def show @user = User.find(params[:id]) render json: @user end end
ここまで用意すればあとはgemでシリアライザを自動生成するだけ。
$ openapi2ruby generate ./path/to/openapi.yaml --out ./app/serializers/
Railsを立ち上げて、ブラウザでアクセスしてみると...
シリアライザを通して生成したjsonが返せています。
現状の問題点
開発を始めたばかりということもあり、いくつかの問題を抱えています。
- OpenAPI上の各schemaのpropertyがActiveRecordのassociatonなのかわからない
- 上記の問題の解決のためにassociationであっても、シリアライザのattributes定義を使っているため循環参照による無限ループに陥る場合がある
まず1点目
例えば上記ユースケース内で紹介しているモデルはすべてActiveRecordのassociation として関係性が明示されていますが
OpenAPIの定義からはその関係性がassociationなのか、単なるクラスのメンバ変数としてアクセスするのかを知るすべはありません。
そのためシリアライザ内で has_one
, has_many
の定義は使用しておらず
全て attributes
として定義し $ref
で参照しているクラスのシリアライザで初期化した値を返すメソッドを定義しています。
これによりassociationかどうかを意識せずシリアライザを扱うことができるようになりました。
(--template
オプションにより自作のテンプレートを使うこともできます。 参照: Use original template)
しかし2点目
associationでの対応を諦めたことによりhas_one <-> belongs_to
な関係性のモデルのシリアライザ生成時した場合、循環参照が生まれてしまいました。
例えば Profile
モデルは belongs_to :user
な関係にありますが
これをschema定義上のProfileのpropertyとして定義してしまうと実行時にお互いのシリアライザを呼び合ってしまい循環参照から抜けられない状態になります。
# profileのschemaにuserへの参照を追加 profile: type: object properties: user: $ref: '#/components/schemas/user' description: type: string
class UserSerializer < ActiveModel::Serializer attributes :name, :profile, :tweets # Profileをシリアライズ def profile ProfileSerializer.new(object.profile) end # ..略.. end class ProfileSerializer < ActiveModel::Serializer attributes :user, :description # Userをシリアライズしてるので循環参照を引き起こす def user UserSerializer.new(object.user) end # ..略.. end
ActiveRecordのassociationが前提であれば Controllerで includeオプションを渡すことによりこの問題は回避可能です。
しかし先述の通りこのgemではschemaのpropertyがassociationなのか判定できず
全てのpropertyをシリアライザのattributesとして実装しているため、今回の問題を引き起こしてしまいます。
※回避する方法をご存知の方がいればこっそり教えていただけると幸いです。
おわりに
開発の経緯からサンプル実装までご紹介させていただきました。
実際に現場のアプリケーションで導入を検討しているので、これからProduction環境にのせるにあたってgemの改修をしていく予定です。
また弊社では「レスポンスに型をもたせる」という文脈でGraphQL, gRPCなどの技術の採用について普段から議論しています。 RESTにとらわれずAPIを開発したい方はぜひ下のリンクから応募して、ぜひ一度オフィスに来ていただければと思います。