rubyのcommitteeでRequestValidationを試してみる(OpenAPI 3.0)

昨年末からOpenAPI 3.0について調べていました。
YAMLを書いていて、型定義を元に簡単なvalidationぐらいはやってくれるツールはないかと思って探してみたところ、 OpenApI 3.0準拠のcommitteeというrubygemで実現できることが分かりました。

準備

rails

rails new --api でプロジェクト作って、適当に./bin/rails g scaffold articleしたものを用意します。

# controller はこんな感じ(api以下に配置)。
module Api
  class ArticlesController < ApplicationController
    before_action :set_article, only: %i[show update destroy]

    def index
      articles = Article.order(created_at: :desc)
      render json: { status: "SUCCESS", message: "Loaded articles", data: articles }
    end

    def show
      render json: { status: "SUCCESS", message: "Loaded the articles", data: @article }
    end

    def create
      article = Article.new(article_params)
      if article.save
        render json: { status: "SUCCESS", data: article }
      else
        render json: { status: "ERROR", data: article.errors }
      end
    end

openapi

config/openapi.yml 一部抜粋。

paths:
  /articles/{articleId}:
    get:
      parameters:
        - name: articleId
          in: path
          description: ID of article of fetch
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: OK
      operationId: getArticleById
      tags:
        - Article
      description: Article by ID
  /articles:
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                  message:
                    type: string
                  data:
                    $ref: '#/components/schemas/ArrayOfArticles'
              examples:
                example: {}
          description: A list of articles
      operationId: getArticles
      tags:
        - Articles
      description: A list of articles
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required:
                - title
                - description
              properties:
                title:
                  type: string
                description:
                  type: string
      responses:
        '201':
          description: created
      operationId: createArticle
      tags:
        - Articles
      description: create Article
components:
  schemas:
    ArrayOfArticles:
      type: array
      items:
        type: object
        properties:
          id:
            type: integer
          title:
            type: string
          description:
            type: string
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
servers:
  - url: 'http://localhost:3000/api'

committee

こんな感じでyml/jsonを読み込めばrack middlewareとして動作してくれます。

# config/application.rb
    schema_path = Rails.root.join("config/openapi.yml").to_s
    config.middleware.use Committee::Middleware::RequestValidation, schema_path: schema_path, prefix: '/api'

RequestValidation を試してみる

url path

/articles/{articleId} GET 時の articleId は integer として定義したので 文字列だとエラーとなります。

❯❯❯ curl http://localhost:3000/api/articles/1a | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    84    0    84    0     0   9333      0 --:--:-- --:--:-- --:--:--  9333
{
  "id": "bad_request",
  message: "#/paths/~1api~1articles~1{id}/get/parameters/0/schema expected integer, but received String: 1a"
}

controllerへのvalidation実装なしで、validationができてしまいました。素晴らしい!
(ですが、messageの内容が微妙です。)

request body

/articles POST 時の parameterとしてtitleとdescriptionを定義しましたが、それぞれ必須かつ文字列としています。

descriptionなしの場合

❯❯❯ curl -X POST "http://localhost:3000/api/articles" -H "Content-Type: application/json" -d '{"title": "string"}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   161    0   142  100    19  20285   2714 --:--:-- --:--:-- --:--:-- 23000
{
  "id": "bad_request",
  "message": "#/paths/~1articles/post/requestBody/content/application~1json/schema missing required parameters: description"
}

titleをbooleanとした場合

❯❯❯ curl -X POST "http://localhost:3000/api/articles" -H "Content-Type: application/json" -d '{"title": true}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   179    0   164  100    15  14909   1363 --:--:-- --:--:-- --:--:-- 16272
{
  "id": "bad_request",
  "message": "#/paths/~1articles/post/requestBody/content/application~1json/schema/properties/title expected string, but received TrueClass: true"
}

素晴らしい。それぞれエラーとなります。
curlは端折っていますが、HTTP/1.1 400 Bad Requestが返っています。)
(ですが、messageの内容が微妙です。)

メッセージを修正してみる

validation機能は素晴らしいのですが、message の前半がhuman readableではありません。(これはYPath?)
ソースを眺めてみると、committeeの中からopenapi_parserというのを呼んでおり、この中でvalidationをしているようです。

OpenAPIErrorのinstance変数である@referenceがこのエラーメッセージの表示箇所です。

# openapi_parser-0.6.1/lib/openapi_parser/errors.rb

  class ValidateError < OpenAPIError
    def message
      "#{@reference} expected #{@type}, but received #{@data.class}: #{@data}"
    end

やってみる

ですが、いくつかのエラーパターンがあり、ここをうまく表示するには難しそうだったので、力づくでやってみました。

以下をrails config/initializersに突っ込む。

module OpenAPIParser
  class OpenAPIError < StandardError
    def initialize(reference)
      @reference = customize_reference(reference)
      # @reference = reference
    end

    private
      def customize_reference(reference)
        # path parameter
        # eg: #/paths/~1articles~1{articleId}/get/parameters/0/schema
        return $1 if reference.match(%r|{(.+)}.+/parameters/|)

        # requestBody
        # eg: #/paths/~1articles/post/requestBody/content/application~1json/schema/properties/title
        return reference.split("/").last if reference.include?('/requestBody/')

        # other
        # query parameter
        # eg: #/paths/~1articles/get
        return ""
      end
  end
end

結果

path error

❯❯❯ curl http://localhost:3000/api/articles/1a | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    84    0    84    0     0   5600      0 --:--:-- --:--:-- --:--:--  5600
{
  "id": "bad_request",
  "message": "articleId expected integer, but received String: 1a"
}

post parameter error

❯❯❯ curl -X POST "http://localhost:3000/api/articles" -H "Content-Type: application/json" -d '{"title": "string"}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    99    0    80  100    19   2962    703 --:--:-- --:--:-- --:--:--  3666
{
  "id": "bad_request",
  "message": "schema missing required parameters: description"
}

❯❯❯ curl -X POST "http://localhost:3000/api/articles" -H "Content-Type: application/json" -d '{"title": true}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    99    0    84  100    15   6000   1071 --:--:-- --:--:-- --:--:--  7071
{
  "id": "bad_request",
  "message": "title expected string, but received TrueClass: true"
}

query string error(offsetを必須パラメータとした場合)

❯❯❯ curl "http://localhost:3000/api/articles" | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    69    0    69    0     0    248      0 --:--:-- --:--:-- --:--:--   248
{
  "id": "bad_request",
  "message": " missing required parameters: offset"
}

まとめ

committee のRequestValidationを試してみました。
YAMLだけで基本的な型チェックをしてくれるのはとても素晴らしいです。 ですが、error responseをもう少し柔軟にカスタマイズできる機能は要望がありそう、ということで無理やりやってみました。
 
また、今回じっくりみてませんが、ResonseValidationもあり、テストで利用できるのは大きいので ここはガッツリ使えるかと思います。
 
あと、別件ですが、正直OpenApiのYAML書くのが辛いなぁという印象。

See Also

How to use OpenAPI3 for API developer - RubyKaigi 2019