committeeのRequestValidationは便利だが使うのをやめた話

※この記事は自分が所属する組織で書いた以下の記事のコピーです。投稿した記事は個人の著作物として自ブログにコピーして良いルールとしています。

元記事: https://tech-blog.mitsucari.com/entry/2025/08/11/083943


こんにちは、ミツカリCTOの塚本こと、つかびー(@tsukaby0) です。

弊社では数年前からWeb API開発においてOpenAPIおよびスキーマファーストの開発スタイルをとっています。

今回の記事ではスキーマファーストの開発に interagent/committee を使っていましたが、その中の機能の一つである RequestValidation を廃止した話をします。

スキーマファースト

スキーマファーストの開発とは、Web APIを開発する際にまずOpenAPI(旧Swagger)仕様やGraphQLスキーマなどの形式で仕様を先に定義し、その後で実装を行うことを指します。

より具体的なイメージとしては、以下のような形です(※サンプルコードは生成AIコードですし、本筋ではないので深く読み込む必要はないです)。

まず以下のようなOpenAPI YAMLを定義して、

paths:
  /books:
    get:
      summary: 書籍一覧取得
      description: 書籍の一覧を取得します。検索条件やページネーションに対応しています。
      operationId: getBooks
      tags:
        - books
      parameters:
        - name: title
          in: query
          description: 書籍タイトルで検索(部分一致)
          required: false
          schema:
            type: string
            example: 'JavaScript'
        - name: author
          in: query
          description: 著者名で検索(部分一致)
          required: false
          schema:
            type: string
            example: '山田太郎'
        - name: genre
          in: query
          description: ジャンルで検索
          required: false
          schema:
            type: string
            enum: [programming, business, novel, science, history]
            example: 'programming'
        - name: page
          in: query
          description: ページ番号(1から開始)
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
            example: 1
        - name: limit
          in: query
          description: 1ページあたりの取得件数
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
            example: 20
        - name: sort
          in: query
          description: ソート順
          required: false
          schema:
            type: string
            enum:
              [
                title_asc,
                title_desc,
                published_date_asc,
                published_date_desc,
                price_asc,
                price_desc,
              ]
            default: title_asc
            example: 'published_date_desc'
      responses:
        '200':
          description: 書籍一覧取得成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  books:
                    type: array
                    items:
                      $ref: '#/components/schemas/Book'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                required:
                  - books
                  - pagination
              example:
                books:
                  - id: 1
                    title: 'JavaScript完全ガイド'
                    author: '山田太郎'
                    isbn: '978-4-12-345678-9'
                    published_date: '2023-06-15'
                    price: 3200
                    genre: 'programming'
                    description: 'JavaScript言語の基礎から応用まで詳しく解説'
                    stock: 25
                  - id: 2
                    title: 'React実践入門'
                    author: '佐藤花子'
                    isbn: '978-4-98-765432-1'
                    published_date: '2023-08-20'
                    price: 2800
                    genre: 'programming'
                    description: 'Reactを使ったWebアプリケーション開発の実践的な手法'
                    stock: 15
                pagination:
                  current_page: 1
                  per_page: 20
                  total_pages: 5
                  total_items: 98
                  has_next_page: true
                  has_previous_page: false
        '400':
          description: リクエストパラメータが不正
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: 'INVALID_PARAMETER'
                  message: 'pageパラメータは1以上の整数である必要があります'
        '500':
          description: サーバーエラー
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: 'INTERNAL_SERVER_ERROR'
                  message: 'サーバー内部エラーが発生しました'

(任意で)このAPIの仕様をレビューし、次に、実装を行うことをスキーマファーストと言います。

class Api::V1::BooksController < ApplicationController
  before_action :authenticate_user!

  # GET /api/v1/books
  def index
    @books = Book.all

    # 検索条件の適用
    apply_search_filters

    # ソート条件の適用
    apply_sort_order

    # ページネーション
    @total_items = @books.count
    @books = @books.page(page_param).per(limit_param)

    # レスポンス作成
    render json: {
      books: books_json,
      pagination: pagination_json
    }, status: :ok
  rescue StandardError => e
    Rails.logger.error "Books index error: #{e.message}"
    render json: {
      error: {
        code: 'INTERNAL_SERVER_ERROR',
        message: 'サーバー内部エラーが発生しました'
      }
    }, status: :internal_server_error
  end

  private

  def apply_search_filters
    # タイトル検索
    if params[:title].present?
      @books = @books.where('title ILIKE ?', "%#{params[:title]}%")
    end

    # 著者検索
    if params[:author].present?
      @books = @books.where('author ILIKE ?', "%#{params[:author]}%")
    end

    # ジャンル検索
    if params[:genre].present?
      @books = @books.where(genre: params[:genre])
    end
  end

  def apply_sort_order
    case params[:sort]
    when 'title_asc'
      @books = @books.order('title ASC')
    when 'title_desc'
      @books = @books.order('title DESC')
    when 'published_date_asc'
      @books = @books.order('published_date ASC')
    when 'published_date_desc'
      @books = @books.order('published_date DESC')
    when 'price_asc'
      @books = @books.order('price ASC')
    when 'price_desc'
      @books = @books.order('price DESC')
    else
      @books = @books.order('title ASC') # デフォルト
    end
  end

  def page_param
    page = params[:page].to_i
    page > 0 ? page : 1
  end

  def limit_param
    limit = params[:limit].to_i
    case limit
    when 1..100
      limit
    else
      20 # デフォルト
    end
  end

  def books_json
    @books.map do |book|
      {
        id: book.id,
        title: book.title,
        author: book.author,
        isbn: book.isbn,
        published_date: book.published_date&.strftime('%Y-%m-%d'),
        price: book.price,
        genre: book.genre,
        description: book.description,
        stock: book.stock
      }
    end
  end

  def pagination_json
    {
      current_page: @books.current_page,
      per_page: @books.limit_value,
      total_pages: @books.total_pages,
      total_items: @total_items,
      has_next_page: @books.next_page.present?,
      has_previous_page: @books.prev_page.present?
    }
  end

  # パラメータのバリデーション
  def validate_params
    errors = []

    # ジャンルのバリデーション
    if params[:genre].present? && !Book::GENRES.include?(params[:genre])
      errors << 'genreパラメータが不正です'
    end

    # ソートのバリデーション
    valid_sorts = %w[title_asc title_desc published_date_asc published_date_desc price_asc price_desc]
    if params[:sort].present? && !valid_sorts.include?(params[:sort])
      errors << 'sortパラメータが不正です'
    end

    # ページのバリデーション
    if params[:page].present? && params[:page].to_i < 1
      errors << 'pageパラメータは1以上の整数である必要があります'
    end

    # リミットのバリデーション
    if params[:limit].present? && (params[:limit].to_i < 1 || params[:limit].to_i > 100)
      errors << 'limitパラメータは1以上100以下の整数である必要があります'
    end

    if errors.any?
      render json: {
        error: {
          code: 'INVALID_PARAMETER',
          message: errors.join(', ')
        }
      }, status: :bad_request
      return false
    end

    true
  end
end

スキーマファーストの利点

スキーマファーストの利点は色々あります。

  • 実装の前に設計や仕様が明確になる
  • 仕様がYAML or JSONとして残るため、コードよりは仕様が明瞭(人によってはコードの方が理解しやすい場合もある)
  • 関連ツールによって仕様ファイル(YAML or JSON)からモックサーバーを立ち上げたり、コードの自動生成ができる
  • バックエンド側の実装完了を待たずにモックサーバーだけでフロントエンドの開発が進められる (※2015ごろから流行った記憶で、最近はあまり聞きませんが、まだまだやっているところはあるはず)
  • ミツカリでは実際にフロントエンドのTS用に仕様ファイルから型およびAPI clientのコードをgenerateしています

ただし、スキーマファーストは完璧ではなく人によってはコードファーストのスタイルを好みます。私はどちらかというとスキーマファーストですが、ある程度辛さも体験しているので気持ちはわかります。本筋から逸れるので詳細には書きませんが、スキーマファーストのよくある問題としてはスキーマと実装が乖離することです(fooというjsonフィールドが仕様としてはあるはずなのに、実装はされていない、というようなもの)。この点はコードファーストの場合はほとんどの場合、コードから仕様ファイルを生成する形を取るため、実装の乖離は起きないです。

スキーマファーストを実現する interagent/committee

interagent/committee というRubyのライブラリがあります。これについては既に多数の記事が出ているため、詳細は割愛しますが、一言で言うとOpenAPIの仕様ファイルを読み取ってバリデーションなどを行ってくれるものです。

@ota42y さんがコミッターであり、2018年ごろから日本で流行らせてくれたような認識です。

ota42yさんは ota42y/openapi_parser のメンテナ(オーナー)でもあり、こちらはOpenAPIのパーサーです。committeeも以下の通り依存しているので、状況次第ではこちらのライブラリコードを読むこともあると思います。私の場合、過去に2度ほどありました。意図した挙動にならないケースやOpenAPI 3.0と3.1の違いや対応状況など、微妙に困ることがあるのでOpenAPIのパースで困ったらこちらのリポジトリのコードを読みましょう。

なお、Railsでなくても使えますが、Railsで使う場合は以下のライブラリも併用することになると思います。詳細や使い方は調べれば沢山出てくるため、ここでは割愛します。

committeeのRequestValidationをやめた話

前置きがかなり長くなりましたが、ここからがこの記事の本題です。

committeeには Committee::Middleware::RequestValidation と言う機能が備わっています。

これはその名の通りRequestがValidであるか検証してくれるものです。例えばOpenAPIでは POST books APIのnameフィールドは30文字まで (maxLength: 30)と言うような定義ができます。このとき、31文字以上のPOSTがされたらそれをOpenAPIの定義に従ってRequestValidationが自動で弾く(400 BadRequestを返す)、と言うようなことが実現できます。

Middlewareとして機能するため、Railsで使う場合はControllerの処理に到達する前にRequestValidationが動きます。

当初からこの機能は便利だと思っていたのですが、微妙に感じる点もありました。

Validationの責務の分散

まず前提として、弊社のコードベースではFormオブジェクトによるバリデーションを行なっています。Modelのバリデーションとは別に用意しており、Controllerレイヤのバリデーションのために使っています。バリデーションはModelレイヤだけで良いという考え方もあると思いますが、私はそうは考えなかったので、別々で用意しています。

Formオブジェクトについては以下の記事が参考になります。

そのため、committeeのRequestValidationを使ってしまうとControllerのバリデーション(Form)とは別にさらにバリデーションが行われてしまうため、Formに責務を集約できません。

集約できないため、例えば「maxLengthはRequestValidationでやるから、Formには書く必要がないよな・・・」と言うようなことをいちいち考える必要があって開発としてはやりづらいです。そのため、弊社ではRequestValidationはおまけ程度に考えておいて、基本的にはForm側にもれなく(maxLengthなどでも)実装しようと言うスタンスをとっていました。これではRequestValidationはほぼ意味がないですね。

RequestValidationエラーの不便さ

エラー時にRequestValidationが返すjsonは開発者・APIユーザーにとってはとっては理解できても、エンドユーザー(顧客)にはわかりづらいと言う問題があります。具体的にはバリデーションエラーがあった場合、例えばこのようなエラーが表示されます。

{
  "id": "bad_request",
  "message": "#/paths/~1account~1app-transfers/post/requestBody/content/application~1json/schema missing required parameters: recipient"
}

特定のAPIのrecipient必須パラメタが無い、というエラーですね。

これは以下の部分のコードで生成されています。

このエラーは開発者にとっては理解できる内容でも、仮にこの内容をそのまま何らかのサービスのエンドユーザーに表示したとしたらほぼ理解できません(一旦ハンドルして翻訳しろと言う話もでありますが)。

デフォルトではおそらくこれは多言語対応できないという問題もあります。この点は調べるともしかするとできる可能性はあると思います。

ただ、このエラークラスはカスタマイズ可能です。詳細はREADMEのValidationErrorの部分を参照してください。

複雑なValidationを実現できない

RequestValidation、というかOpenAPIのバリデーションがコードほど柔軟ではないと言う問題もあります。maxLengthやregexなどの基礎的なバリデーションはOpenAPIで定義できますが、field_aがfooという条件だった場合に・・・というような条件付きバリデーションや何らかの外部リソース(例えばDB)に依存したバリデーションなどはOpenAPIでは定義できません。そのため、committeeのRequestValidationでも実現できません。

RequestValidationを廃止した

これらの理由からRequestValidationは使わない方が良いという結論になったため、廃止することにしました。実際には移行作業は他のメンバーに行なっていただきましたが、Form側には定義されていないけど、OpenAPI側には定義されているバリデーションというのがいくつかあり、簡単には行きませんでした。現時点ではまだ廃止作業中です。

これは私の落ち度ですが、ここまで先を見通せなかったので、RequestValidationの導入にはより慎重になるべきでした。あるいはより柔軟に、問題が発覚したタイミングで素早く廃止できていればもう少し移行作業は簡単だったかもしれません。今回の問題点は導入から早々に気づいていたのですが、他のタスクを優先したばかりに2年以上放置する事態になってしまいました・・・。

RequestValidationが不要ならばcommitteeも不要なのか

今回はあくまでRequestValidationを使わないようにしただけで、他の機能は使っています。引き続きRESTfulなjsonのAPIをRubyで作る限りはcommitteeを使い続けたいと思います。

前の章でスキーマファースト開発の問題として実装との乖離が発生するという話をしました。committeeではこれを解消することができます。

具体的にはREADMEを参照すると良いですが、例えば ResponseValidation という仕組みがあります。これはレスポンスが仕様通りであるかどうかをチェックする機能です。これによって例えば Rails.env.development の開発モードでだけチェックを行なって早期に仕様の不一致に気づけるようにする、などができます。

また、 Rails.env.development に限らず全ての環境で有効化するというのも全然アリだと思います。本番環境(Rails.env.production)で問題があるのに動作し続けるケースと、問題がある場合はエラーを吐いて問題を検知できるケース、どちらが良いかはアプリケーションやビジネスの特性次第ですが気づけるのは基本良いことです。

他にも実行時ではなくテスト時に気づくこともできます。それもREADMEの Test Assertions を参照すると良いです。

assert_request_schema_confirmassert_response_schema_confirm を使うことで仕様通りのリクエスト、レスポンスが行われているかをチェックすることができます。今回はランタイム時に検証を行う RequestValidation を廃止しましたが、テスト時は前述のアサーションでレスポンスだけでなくリクエストについても検証すると良いかもしれません。


現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!