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 readbleではありません。(これは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

2019年に買って良かったもの

買って良かったもの

今年は結構いろいろ買いました。改めて良いなと思うものを載せてみます。

Pixcel 3a

国内版SIMフリー Google Pixel 3a 64GB Clearly White

国内版SIMフリー Google Pixel 3a 64GB Clearly White

  • 出版社/メーカー: Google
  • メディア: エレクトロニクス

何といってもこれかなぁ。iPhone7sからの移行です。
iPhone11高いし、電池の減りが凄まじいことになってきたので、思い切ってAndroidにしてみました。 これまでのAndroidよりもヌルヌル動く感じがするし、電池の持ちもよく、何より写真がきれいに撮れます。 Pixcel 3aの下の方を強く握ると、Googleアシスタントが起動するのも良いです。 音声入力もiPhoneの頃より利用する機会が増えました。 コスパ的にも、すごい満足。

WF-1000MX3 Sonyノイズキャンセリングイヤホン

Pixcel 3aのコスパが良いのもあって、ノイズキャンセリングイヤホンを購入してみました。 ケースもカッコ良くて良いです。 来年春のPixel Budsが気になりますが、今のところ不満はないです。

人感センサーのLEDライト

これ非常に良いです。   クローゼットなどの、小さい空間にはおすすめ。
人感センサーのLEDライトを買った - rochefort's blog

アイマス

アイマスク欲しくて探していたのですが、こちらの商品は当たりでした。
さらさらですごい気持ちいいです。重宝してます。

貝印 KAI T型 ピーラー

貝印 KAI T型 ピーラー SELECT100 DH-3000

貝印 KAI T型 ピーラー SELECT100 DH-3000

  • メディア: ホーム&キッチン

twitterのtimelineに流れてきて、気になって買ってしまいました。 何とキャベツがふわふわの千切りみたいに削ることができます。 若干、散らかりますが、よく使ってます。

残念だったもの

首掛け式の携帯スタンド

ダントツでこれ。
一応寝ながらスマホ見れますが、硬くて痛いです。
でかい針金みたいな感じで、意味がわかんないですね。 何で買ったんだろう。

vscodeでrubocopのAutoCorrectを使う方法

ググると、rufoやprettierというフォーマッター使う事例がありましたが、rubocop単体できた方が望ましいので 調べてやって見ました。

プラグイン

misogi/vscode-ruby-rubocop: Rubocop extension for Visual Studio Code

こんな感じで動作してくれます。
f:id:rochefort:20191123175448p:plain 素晴らしい。

問題点

2つ挙げられています。
1つ目は、rvm or chruby だとうまくいかないらしいですが、とりあえずrbenvだと動作したので気にしないことにします。

This extension may have a problem when using a rvm or chruby environment. We recommend vscode-ruby. It can also lint ruby code.

When autoCorrect, History of changing file is broken.

2つ目は、autoCorrect すると履歴壊れますとのこと。 こっちは気になりますが、でもやっぱり保存時にautoCorrectさせたい。

AutoCorrect設定

vscodeでformatterを保存時に有効にするには、以下の設定をすれば動作するとのことですが、 どうも自動修正されません。

"editor.formatOnSave": true,

対応方法

autocorrect on save · Issue #49 · misogi/vscode-ruby-rubocop
を見ると、

"editor.formatOnSaveTimeout": 5000,

を入れれば良いとのこと。

今のところこんな感じで使っています。

    "[ruby]": {
      "editor.formatOnSave": true,
      "editor.formatOnSaveTimeout": 5000,
    },
    "ruby.rubocop.executePath": "",
    "ruby.rubocop.configFilePath": "",