OpenAPIのschema定義からRubyのクラスを生成するgem「openapi2ruby」をつくりました

f:id:vasilyjp:20180626100251j:plain

こんにちは。スタートトゥデイテクノロジーズ新事業創造部の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定義された数ファイルだけなのに不要なファイルが大量に生成されてしまう

たまたまやる気があったので、シンプルに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を立ち上げて、ブラウザでアクセスしてみると...

f:id:vasilyjp:20180625173416p:plain

シリアライザを通して生成した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を開発したい方はぜひ下のリンクから応募して、ぜひ一度オフィスに来ていただければと思います。