Rails + React + OpenAPI な環境で、クエリパラメータに配列を指定する時に調べたことをまとめてみた

Rails + React + OpenAPI な環境で、クエリパラメータに配列を指定することがありました。

ただ、実装するまでにいろいろ調べたことがあったため、メモを残します。

 
目次

 

環境

 

わかったこと

以降で、今回分かったことについて記載していきます。

 

Railsでは ids=1&ids=2 な書式のクエリパラメータを受け取れる

Railsガイドに記載がありました。 /clients?ids[]=1&ids[]=2&ids[]=3 という書式でクエリパラメータを渡せば良いようです。

params ハッシュには、一次元のキーバリューペアの他に、ネストした配列やハッシュも保存できます。値の配列をフォームから送信するには、以下のようにキー名に空の角かっこ [] のペアを追加します。

GET /clients?ids[]=1&ids[]=2&ids[]=3

これで、受け取った params[:ids] の値は ["1", "2", "3"] になりました。ここで重要なのは、パラメータの値が常に「文字列」になることです。Railsはパラメータの型推測や型変換を行いません。

4.1 ハッシュと配列のパラメータ | Action Controller の概要 - Railsガイド

 

OpenAPIスキーマでは Parameter Object の style を使って表現する

OpenAPI v3.0 の定義によると、クエリパラメータに配列を渡す場合は style を使って表現できそうです。
https://swagger.io/specification/#parameter-object

Railsの場合は

  • style を form
  • explode を true

にすれば良さそうです。

具体的にはこんな感じです。

paths:
  /api/array_in_query_params/fruits:
    get:
      summary: |
        Arrayをクエリ文字列に入れてリクエストするAPI
      tags:
        - array_in_query_params
      parameters:
        - name: names[]
          in: query
          required: false
          description: |
            果物の名前のリスト
          schema:
            type: array
            items:
              type: string
          style: form
          explode: true
# ...

 
なお、RSpecからOpenAPIスキーマを生成するgem rswag のFAQにも同様の記載がありました。

For OpenApi 3 The style and explode keyword arguments allow you to choose how the array query parameters are serialized.

parameter name: 'widget', in: :query, type: :array, style: :form, explode: true, items: { type: string }
# /?widget[]=foo&widget[]=bar

https://github.com/rswag/rswag/wiki/FAQ#how-can-i-submit-arrays-through-query-parameters

 

ストロングパラメータでは id: [] のように指定する

ここまでで、クエリパラメータに配列を指定する場合でも、OpenAPI - Rails 間ではOpenAPIスキーマを共有できそうなことが分かりました。

次は、プロダクションコードとテストコードを実際に作っていきます。

 

まずはプロダクションコードとしてコントローラを作ります。

ストロングパラメータを使った時にクエリパラメータの配列を取得するには、 params.permit(id: []) のように指定します。

今回は

  • クエリパラメータ
    • names[] というキーで、各値が入ってくる
  • レスポンス
    • {"fruits":[{"id":<index>,"name": <各値> }, ...]} という形でレスポンスする

なコントローラを作ります。

class Api::ArrayInQueryParams::FruitsController < ApplicationController
  def index
    names = permitted_params[:names] || ['みかん']

    fruits = names.map.with_index { |name, i| { id: i, name: name } }

    render json: { fruits: fruits }
  end

  def permitted_params
    params.permit(names: [])
  end
end

 
route.rb も追加し、 /api/array_in_query_params/fruits というパスでリクエストを受け付けるようにします。

  namespace :api do
    namespace :array_in_query_params do
      resources :fruits, only: [:index]
    end
  end

 
curlでリクエストすると、想定通りのレスポンスがありました。ストロングパラメータの定義がうまくできたようです。

% curl "http://localhost:7100/api/array_in_query_params/fruits?names%5B%5D=りんご&names%5B%5D=なし"
{"fruits":[{"id":0,"name":"りんご"},{"id":1,"name":"なし"}]}

 

現時点では、Committee が Query parameter の explode に対応していない

プロダクションコードができたので、次はテストコードです。

をインストール後、RSpecでテストコードを書きます。

なお、テストコード中で期待値とするクエリパラメータの値は、 Active Support to_query メソッドを使って定義すれば良さそうでした。

配列に to_query メソッドを適用した場合、 to_query を配列の各要素に適用して key[] をキーとして追加し、それらを「&」で連結したものを返します。

[3.4, -45.6].to_query('sample')
# => "sample%5B%5D=3.4&sample%5B%5D=-45.6"

https://railsguides.jp/active_support_core_extensions.html#to-query

 
できあがったテストコードはこちらです。

context 'URLをハードコーディングする版' do
  it 'クエリ文字列で指定した配列の中身がレスポンスされる' do
    query_string = %w[りんご なし].to_query('names')
    get "/api/array_in_query_params/fruits?#{query_string}"

    assert_request_schema_confirm
    assert_response_schema_confirm(200)

    expect(actual).to eq({ fruits:[{id: 0, name: 'りんご'}, {id: 1, name: 'なし'}] })
  end
end

 
テストを実行するとパスします。

ただ、ためしにOpenAPIスキーマexplodefalse にしてもテストがパスしてしまいました。

調べてみたところ、Committee に以下の issue がありました。
No support for explode field in array query parameters · Issue #253 · interagent/committee

コメントによると、Committeeが関係しているgemの方での修正が必要なようです。
support array in query parameter · Issue #122 · ota42y/openapi_parser

そのため、現時点では、Committee が Query parameter の explode に対応していないことが分かりました。

 

OpenAPI generator typescript-axios は operationId を定義した上で使う

ひとまずRails APIは動作するようになったので、次はフロントエンドの React まわりを調べてみます。

React で OpenAPI に対応した axios クライアントを生成するには、 OpenAPI generator の typescript-axios を使います。
https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-axios.md

 
ちなみに、 typescript-axios を使う場合、デフォルトでは apiArrayInQueryParamsFruitsGet のような分かりづらいメソッド名を持つクライアントができてしまいます。

そんな中、同僚から「 operationId を指定すると良い」と聞いたため、今回定義してみます。

なお、 operationId は Operation Object にて以下のように定義されています。

Unique string used to identify the operation. The id MUST be unique among all operations described in the API. The operationId value is case-sensitive. Tools and libraries MAY use the operationId to uniquely identify an operation, therefore, it is RECOMMENDED to follow common programming naming conventions.

https://swagger.io/specification/#operation-object

 
今回のOpenAPIスキーマの場合、 operationIdfetchArrayInQueryParamsFruits として追加してみました。

paths:
  /api/array_in_query_params/fruits:
    get:
      summary: |
        Arrayをクエリ文字列に入れてリクエストするAPI

      # 追加
      # operationIdを設定しないと `apiArrayInQueryParamsFruitsGet` のような名前になるため分かりづらい
      operationId: fetchArrayInQueryParamsFruits

      tags:
        - array_in_query_params
      parameters:
        - name: names[]
          in: query
          required: false
          description: |
            果物の名前のリスト
          schema:
            type: array
            items:
              type: string
          style: form
          explode: true
# ...

 
あとは

  • docker-compose.yml に、OpenAPI generator 公式の生成用Dockerを定義
  • docker compose run --rm openapi_typescript_axios で typescript-axios クライアントを生成
  • React まわりを実装

します。

 
docker compose run --rm openapi_typescript_axios にて生成された axios クライアントで operationId を定義した結果を見てみると、 fetchArrayInQueryParamsFruits というメソッドが生えていました。

 
Reactでは、以下のようなコンポーネントを定義し、

  • フォームで検索条件を入力し、ボタンをクリック
  • axiosがRailsへリクエス
  • Railsからのレスポンスを画面に描画

します。

import {SubmitHandler, useForm} from "react-hook-form";
import {Fruit} from "@/types/typescript-axios";
import {arrayInQueryParamsApi} from "@/apiClient";
import {useState} from "react";

type FormInput = {
  name1: string
  name2: string
}


const Component = (): JSX.Element => {
  const [fruits, setFruits] = useState<Array<Fruit>>([])

  const {handleSubmit, register} = useForm<FormInput>({
    criteriaMode: 'all',
  })

  const onSubmit: SubmitHandler<FormInput> = async (formInput) => {
    const names = Object.values(formInput).filter(v => !!v)
    const {data} = await arrayInQueryParamsApi.fetchArrayInQueryParamsFruits(names)

    setFruits(data.fruits)
  }

  return (
    <>
      <h1>Array In Query Params Page</h1>

      <h2>検索条件</h2>

      <form onSubmit={handleSubmit(onSubmit)}>
        <p>
          <label>
            1つ目の果物を入れてください:
            <input type="text" {...register('name1')} />
          </label>
        </p>

        <p>
          <label>
            2つ目の果物を入れてください:
            <input type="text" {...register('name2')} />
          </label>
        </p>

        <button type="submit">検索</button>
      </form>

      <h2>結果</h2>

      {fruits && (
        <ul>
          {fruits.map((fruit) => <li key={fruit.id}>{fruit.id}: {fruit.name}</li>)}
        </ul>
      )}
    </>
  )
}

export default Component

 
また、axiosで生成するクエリパラメータを確認できるよう、OpenAPI generator で生成した axios クライアントのラッパーを作ります。

import * as api from './types/typescript-axios/api'
import axios, {AxiosInstance} from "axios";

const instance = axios.create({
  headers: {
    'Content-Type': 'application/json',
  },
  responseType: 'json'
})

instance.interceptors.request.use(request => {
  // どんなURLでリクエストしているかをconsole出力してみる
  console.log(request.url)
  return request
})

const options: [undefined, string, AxiosInstance] = [undefined, '', instance]

export const arrayInQueryParamsApi = new api.ArrayInQueryParamsApi(...options)

 
これらをためしてみたところ、画面が描画されました。React - Rails 間で正しく通信できているようです。

 
ブラウザのコンソールを見ても、 ?names%5B%5D=apple&names%5B%5D=banana のように Rails が期待する形でクエリパラメータが付与されていました。

 

OpenAPI generator の Ruby クライアントでは、希望する形での配列ができない

typescript-axios ではクライアントを生成できたので、次は Ruby クライアントを試してみます。
https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/ruby.md

 
Ruby クライアントは

  • typhoeus
  • faraday

の2つがベースになります。

なお、 faraday の方はベータサポートのようです。

 
まずは Ruby クライアントを利用するアプリを作成します。

といっても、Rubyクライアントの利用アプリを別途作成するのは手間です。

そのため、Active Job の中で Ruby クライアントを利用して動作を確認してみます。

 
Active Job でRubyクライアントを利用するまでの流れは以下のとおりです。

  • Rails API のコントローラに Active Job を起動するエンドポイントを作成し、そこに curl でPOST
  • Active Job の中で、Ruby クライアントを使って、 /api/array_in_query_params/fruits へアクセス
  • レスポンスを出力する

 
コントローラはこんな感じ。合わせて route.rb も修正しておきます。

class Api::ArrayInQueryParams::FruitsController < ApplicationController
  protect_from_forgery with: :null_session, only: :create

  # ...

  def create
    OpenapiClientJob.perform_later
  end

  # ...
end

 
ジョブはこんな感じです。

class OpenapiClientJob < ApplicationJob
  queue_as :default

  def perform(*args)
    # response = fetch_by_openapi_client

    puts response
  end

  private def fetch_by_openapi_client
    api_instance = OpenapiClient::ArrayInQueryParamsApi.new

    api_instance.fetch_array_in_query_params_fruits({'names': %w[バナナ マンゴー]})
  end
end

 
次に、Rubyクライアントを生成します。

まずは typhoeus を使うため、 docker-compose に以下のコマンドを指定します。

generate -i local/openapi.yaml -g ruby -o local/openapi/gems/typhoeus --library=typhoeus

 
生成された Ruby クライアントは gem になっているため、Gemfile に追加してインストールします。

gem 'openapi_client', path: './openapi/gems/typhoeus'

 
これで準備ができたため、curlでアクセスします。

curl -X POST "http://localhost:7100/api/array_in_query_params/fruits"

 
APIのログを見ると

"/api/array_in_query_params/fruits?names%5B%5D%5B0%5D=%E3%83%90%E3%83%8A%E3%83%8A&names%5B%5D%5B1%5D=%E3%83%9E%E3%83%B3%E3%82%B4%E3%83%BC"

と出力されていました。

クエリパラメータは分かりにくいですが、 params を見ると

"names"=>[{"0"=>"バナナ", "1"=>"マンゴー"}]

となっていました。

想定していたRailsのクエリパラメータの形式とは異なるようです。

 
次に faraday 版を試してみます。

command を以下に差し替えて Ruby クライアントを生成します。

generate -i local/openapi.yaml -g ruby -o local/openapi/gems/faraday --library=faraday

 
同様に curl で試してみると、APIのログには

/api/array_in_query_params/fruits?names%5B%5D%5B%5D=%E3%83%90%E3%83%8A%E3%83%8A&names%5B%5D%5B%5D=%E3%83%9E%E3%83%B3%E3%82%B4%E3%83%BC

と出力され、 params

"names"=>[["バナナ"], ["マンゴー"]]

となっていました。

こちらも想定していたRailsのクエリパラメータの形式とは異なるようです。

 

素の faraday を使う場合はデフォルトでRailsの書式になった

ここまでのRubyクライアントはOpenAPIスキーマを元にして生成しましたが、最後に素のfaradayを使って試してみます。

同様にActive Jobへ

Faraday.get('http://localhost:7100/api/array_in_query_params/fruits', names: %w[バナナ マンゴー])

と定義してつかってみます。

すると、クエリ文字列は

/api/array_in_query_params/fruits?names%5B%5D=%E3%83%90%E3%83%8A%E3%83%8A&names%5B%5D=%E3%83%9E%E3%83%B3%E3%82%B4%E3%83%BC

となり、 params

"names"=>["バナナ", "マンゴー"]}

と想定したRailsの書式になっていました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_with_vite_rails-sample/pull/6