JsonWorldでモデルからJSON Schemaを生成する

JsonWorldというGemを使って、JSON Schemaを生成できるモデルクラスをつくります。Qiita API v2のJSON Schemaを公開しました - Qiita Blog の裏側で使っているやつです。

JsonWorldの使い方

JsonWorld::DSL というModuleが提供されているので、これを任意のClassにincludeします。このmoduleをincludeしたClassでは、以下の特異メソッドが利用できるようになります。propertyとlinkを利用してインターフェースを定義し、to_json_schemaでJSON Schemaとしての表現を取り出す、というのがJsonWorldの使い方です。

  • .property(property_name, options)
  • .link(link_name, options)
  • .to_json_schema

JsonWorld::DSL

「Integer型のidプロパティを持つ」という情報を定義したJSON Schemaを生成することにします。Userクラスのインスタンスを持った、UserResourceクラスを用意して、これにincludeして使うことにします。

require "json_world"

class UserResource
  include JsonWorld::DSL

  property(
    :id,
    type: Integer,
  )

  # @param [User] user
  def initialize(user)
    @user = user
  end

  # @return [Integer]
  def id
    @user.id
  end
end

propertyの第一引数にはそのリソースが取り得るプロパティの名前をSymbolで、第二引数にはそのプロパティの性質をHashで指定できます。typeを指定すると、idというプロパティの型がInteger型であるという情報が定義されます。Rubyのクラスオブジェクトを渡していますが、これはJSON Schemaにおいて { "type": "integer" } という情報に変換されます。.to_json_schema を呼ぶと、それっぽいやつが取り出せます。

puts UserResource.to_json_schema
{
  "properties": {
    "id": {
      "type": "integer"
    }
  },
  "required": [
    "id"
  ]
}

なお、JsonWorld::DSL は .property で定義したプロパティを利用して #as_json の実装を適当に用意してくれます。active_support/json を読み込んでいる環境で、オブジェクトがこのメソッドを実装していると、#to_json がこの結果をJSONに変換したものになります。よって、あるクラスにJsonWorld::DSLをincludeしていれば、そのクラスのインスタンスは #to_json に対応できます。

user = User.new
user_resource = UserResource.new(user)
user_resource.to_json #=> '{"id":1}'

.property

あとは .property と .link の細かいオプションの説明です。.property に渡せるオプションを見ていきましょう。

  • :description
  • :example
  • :items
  • :optional
  • :pattern
  • :properties
  • :type
  • :unique

:description

これは、JSON Schemaにおける description プロパティを定義するものです。このプロパティが何であるかを説明するやつです。Qiitaでは日本語と英語のスキーマを用意する必要があるため、I18n.translate を利用した値を :description オプションとして渡しています。

property(
  :id,
  description: "A unique user ID of this user",
)

:example

これは、JSON Schemaにおいて example プロパティを定義するものです。このプロパティは例えばこういう値を取り得るよというやつですね。Qiita API v2のドキュメントは jdoc でJSON Schemaから生成してるんですが、ここではexampleの値を元に自動的にサンプルリクエストとサンプルレスポンスを生成しています。

property(
  :id,
  example: 1,
)

:items:unique

これは、JSON Schemaにおいて items プロパティを定義するものです。配列の要素がどういうものかということを表現するための値なので、typeがArrayのときだけ利用します。:items の値には .property の第二引数に渡せるものと同じオプションが渡せます (つまりネストできるということです)。以下の例では、tagsプロパティは文字列型の要素を含む配列であるということを示しています。また、このとき :unique をtrueに指定すると、中の要素は一意であるということを示せます。これは、JSON Schemaにおいては uniqueItems プロパティとして表現されます。

property(
  :tags,
  items: {
    type: String,
  },
  unique: true,
)

:optional

これは、JSON Schemaにおいてrequiredプロパティに影響する値です。JsonWorldでObject相当のデータを定義するとき、基本的には全てのプロパティは必須のものとして扱いますが、:optionalにtrueが指定されているものは必須にはなりません。例えば、記事一覧を取得するエンドポイントでは、ページネーション用のパラメータは必須ではないので、この機能を利用して定義できます。

link(
  :list_items,
  parameters: {
    page: {
      example: 2,
      optional: true,
      type: Integer,
    },
  }
)

:pattern

これは、JSON Schemaにおいてpatternプロパティを定義するものです。値が文字列型のときに、更に正規表現を指定して有効な値を限定するためのやつです。

property(
  :name,
  pattern: /^.{3,20}$/,
  type: String,
)

:properties

これは、JSON Schemaにおいてpropertiesプロパティを定義するものです。値がObject型だったときに、更にネストしてそれぞれのプロパティの情報を定義できるというやつですね。

property(
  :stats,
  properties: {
    items_count: {
      type: Integer,
    },
    stocks_count: {
      type: Integer,
    },
  },
)

:type

これは、JSON Schemaにおいてtypeプロパティを定義するものです。文字列を渡すとその値が利用されますが、Classクラスのインスタンス (※ ArrayとかStringとかです) で対応しているものを渡すと、自動的に対応する文字列に変換してくれます。例えば、Stringを指定すると"string"に、Integerを指定すると"integer"になります。

property(
  :id,
  type: Integer,
)

.link

JSON Hyper-Schemaでは、個々のリソースがどのような操作やURLに関係しているかということを定義できます。JsonWorld::DSLでは、以下のオプションとともに .link というメソッドを利用してこれを定義できます。

  • :description
  • :method
  • :parameters
  • :path
  • :rel
  • :title
class UserResource
  include JsonWorld::DSL

  link(
    :list_users,
    description: "List users in newest order",
    path: "/api/users",
    parameters: {
      page: {
        example: 2,
        optional: true,
        type: Integer,
      },
    },
  )
end

puts UserResource.to_json_schema
{
  "links": [
    {
      "description": "List users in newest order",
      "href": "/api/users",
      "method": "GET",
      "schema": {
        "properties": {
          "page": {
            "example": 2,
            "type": "integer"
          }
        }
      },
      "title": "list_users"
    }
  ]
}

リソースをまとめる

.property の :type オプションには、JsonWorld::DSLをincludeしたクラスも渡せます。なので、こういう感じで幾つかのリソースをまとめるためのリソースを用意すれば、1つの大きなスキーマを生成できます。

module Api
  class Schema
    include JsonWorld::DSL

    property(:access_token, links: true, type: Api::Resources::AccessToken)
    property(:authenticated_user, links: true, type: Api::Resources::AuthenticatedUser)
    property(:comment, links: true, type: Api::Resources::Comment)

    ...
  end
end

puts Api::Schema.to_json_schema

おわり

JsonWorldというGemを使って、JSON Schemaを生成できるモデルクラスをつくる方法について説明しました。ここで生成したスキーマを使って、サーバ側の実装に再利用したり、各種言語のクライアントライブラリを用意したりできます。多くの場合はあまり参考にならないとは思いますが、こういう風にやっている人も居るんだなあぐらいの気持ちで見といてもらえると幸いです。