Rails + React + OpenAPI な環境で、クエリパラメータに配列を指定することがありました。
ただ、実装するまでにいろいろ調べたことがあったため、メモを残します。
目次
- 環境
- わかったこと
- Railsでは ids[]=1&ids[]=2 な書式のクエリパラメータを受け取れる
- OpenAPIスキーマでは Parameter Object の style を使って表現する
- ストロングパラメータでは id: [] のように指定する
- 現時点では、Committee が Query parameter の explode に対応していない
- OpenAPI generator typescript-axios は operationId を定義した上で使う
- OpenAPI generator の Ruby クライアントでは、希望する形での配列ができない
- 素の faraday を使う場合はデフォルトでRailsの書式になった
- ソースコード
環境
- Rails 7.0.3.1
- React 18.2.0
- OpenAPI スキーマ 3.0
- committee-rails 0.6.1
- rspec-rails 6.0.0.rc1
- OpenAPI generator 6.0.1
- faraday 2.3.0
わかったこと
以降で、今回分かったことについて記載していきます。
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はパラメータの型推測や型変換を行いません。
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スキーマの explode
を false
にしてもテストがパスしてしまいました。
調べてみたところ、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.
今回のOpenAPIスキーマの場合、 operationId
を fetchArrayInQueryParamsFruits
として追加してみました。
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では、以下のようなコンポーネントを定義し、
します。
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
/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