19
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【Elixir/Phoenix】paramsライブラリでリクエストパラメータのバリデーションとキャストを楽ちんに行う

Last updated at Posted at 2019-10-06

はじめに

携わるプロジェクトでElixir/Phoenixを開発している中で、リクエストパラメータのハンドリングがなかなかしっくり来ず、paramsというライブラリを導入したらだいぶ幸せになったので、その知見をお裾分けできればという思いで記事に起こしてみます。

と順を追って話していくので、paramsの使い方をすぐに知りたい方は、paramsの紹介からどうぞ!!

※何か認識間違っているところがあれば、バンバンご指摘頂けると嬉しいです!!

Elixir/Phoenixでのリクエストの受け取り方の基本

Elixir/PhoenixでRequestパラメータを受け取る際、

  • Pathパラメータ
  • Queryパラメータ
  • Bodyパラメータ

といったパラメータはすべてControllerの action/2 メソッドの第2引数である params で受け取ることとなります。(第2引数のparamsの実体はConn.paramsなので、もちろん第1引数のconnからも取得することができます)

その際、サンプル通りに実装すると、以下のようにパラメータを受け取ることが一般的です。

  • 必須パラメータはパターンマッチで取得
  • 任意のパラメータはparamsというmapに文字列キーでアクセスして取得
hoge_controller.ex
defmodule HogeWeb.HogeController do
  ...

  # 必須パラメータの受け取り
  def hoge(conn, %{"hoge" => hoge} = params) do

    # 任意パラメータの受け取り
    fuga = params["fuga"]

    ...
  end
end

この時点では、何の問題もなさそうです。
ただ、実際にプロジェクトを運用をしていくと、つらいところがチラホラ出てきます。次から、その例を見ていきましょう

既存のリクエストパラメータハンドリングのつらいところ

自分が感じた既存のリクエストパラメータハンドリングのつらいところは以下のようなところです。

  1. 必須パラメータが増えた時にControllerがごちゃごちゃする
  2. 任意パラメータの受け取りが曖昧になる
  3. パラメータの型がバラバラ

【つらみ1】 必須パラメータが増えた時にControllerがごちゃごちゃする

上記のサンプルくらいシンプルならいいのですが、実際のAPIのリクエストパラメータは一定以上増えることがよくあります。
例えば、名前や電話番号、住所などを入力してもらうフォームを考えると、Controllerのコードは以下のようになるでしょう。(コードの中身自体は超適当です。)

user_address_controller.ex
def create(conn, %{"family_name" => family_name, "first_name" => first_name, "phone_number" => phone_number, "zip_code" => zip_code, "prefecture" => prefecture, "city" => city, "address" => address} = params) do

  middle_name = params["middle_name"]
  user_address = UserAddressService.create(family_name, first_name, ...)

  conn
  |> json(user_address)
end

かなりパラメータ数が多く、一つ減った増えたの認知がものすごくしづらいです。

【つらみ2】 任意パラメータの受け取りが曖昧になる

上記のつらみ1の対応として、paramsのまま、別モジュールに引き渡すという手もできます。
たとえば、上記のコードでUserAddressService.createの引数をparamsにまとめると、以下のようになります。

user_address_controller.ex
def create(conn, params) do

  user_address = UserAddressService.create(params)

  conn
  |> json(user_address)
end
user_address_service.ex
@spec create(map) :: UserAddress.t
def create(params) do
  %{
    "family_name" => family_name,
    "first_name" => first_name,
    "phone_number" => phone_number,
    "zip_code" => zip_code,
    "prefecture" => prefecture,
    "city" => city,
    "address" => address
  } = params

  ...

  middle_name = params["middle_name"]

end

どうでしょうか?

Controllerはスッキリしたし、悪くないように見えますが、
UserAddressServiceでは「paramsが何のキーを持っているのか?」「このキーは果たして正しいのか?」というのはコードを読んだだけでは読み取れず、修正時にはAPIのインタフェース定義書等を別途見て、確認する必要が出てきます。(ControllerのparamsとAPIのインタフェース定義書を突き合わせるのは、個人的に許せるのですが、階層構造の設計でService層とかにあるものを突き合わせるのは個人的に気持ちわるいです。

また、今回は1つのメソッドで値を抜き出しているだけなので、あまり影響がないですが、これが複数のメソッドなどを跨いで値を取得するようになると、何のキーがあるかはどんどんわからなくなっていってしまいます。

【つらみ3】パラメータの型がバラバラ

Controllerで受け取ったparamsの値を素直に使おうとすると、パラメータの型がStringだったり、Intだったり、バラバラな事に気づきます。

requestパラメータを受け取った時の型としては、以下のように変換されます。

パラメータ種別 受け取る時の型
Bodyパラメータ(JSON) JSONの型に準ずる
Bodyパラメータ(Form) すべて文字列
Pathパラメータ すべて文字列
Queryパラメータ すべて文字列

JSON以外はParserとかを通している訳ではないので、そりゃそうなんですが、これがなかなか不便です。

Controllerで受け取ったパラメータを一旦そのままDBに突っ込んでしまうような薄い構成のアプリケーションでは、Ecto.Schema、及びEcto.Changesetが自動で変換してくれるためあまり問題にならないのですが、受け取った値でロジックを処理しなければならない場合、問題となります。

例えば、標準で容易されているString.to_integerは、数字以外の文字列はもちろん、文字列以外のものを入れてもエラーを吐いて終了します。
DBに入れる前に数値比較でバリデーション処理等を行っている場面では、受け取った型を調べ、問題なければ変換する。といった処理が必要になり、非常に面倒です。

これらの辛いところをparamsを用いて解決します。

paramsの紹介

paramsとは

Easy parameters validation/casting with Ecto.Schema, akin to Rails' strong parameters.

「Ecto.Schemaを用いたパラメータの簡単なバリデーションと変換」と書かれていますが、その名の通りのライブラリです。

内部的にはPhoenixのエコシステムの一環であり、実績のあるEctoライブラリが利用されており、paramsはより簡単で簡潔な記法で、Ectoの機能を利用することができます。
そのため動作にも信頼ができますし、ここで得た知見はEctoを用いたDB操作前のバリデーションにも流用することができます。

こちらの記事を書く時点でのバージョンはv2.1.1になります

Ectoを用いた課題解決

もちろん、paramsは内部的にEctoを使用しているので、Ectoを用いても、つらみは解決できます。
過去の記事にもElixirで値のバリデーションをEctoで行う
というエントリーがありました。

実装例に関しては、paramsのAboutを引用します。
※ちゃんと読みたい方は上記を直接お願いします。

例として、シンプルなUserオブジェクトをリクエストパラメータとして扱うなら、以下のように書くことができます。

# Ecto.Schemaを定義
defmodule MyApp.User do
   use MyApp.Web, :model

   schema "users" do
     field :name, :string
     field :age,  :integer
   end

   @required [:name]
   @optional [:age]

   def changeset(changeset_or_model, params) do
     cast(changeset_or_model, params, @required ++ @optional)
     |> validate_required(@required)
   end
end

# Controllerでの利用
def create(conn, params) do
  ch = User.changeset(%User{}, params)
  if ch.valid? do
    ...
end

ここまでは何も問題がないです。

ただし、Ectoを純粋に用いた場合、複雑なJsonを受け取るのに苦労をします。
以下のようなJsonを受け取ることを想定すると、

{
  "breed": "Russian Blue",
  "age_min": 0,
  "age_max": 5,
  "near_location": {
     "latitude": 92.1,
     "longitude": -82.1
  }
}

以下のようにコードを書く必要があります。

# ネストしたオブジェクトの定義
defmodule MyApi.Params.Location
  use Ecto.Schema
  import Ecto.Changeset

  @required ~w(latitude longitude)
  @optional ~w()

  schema "location params" do
    field :latitude, :float
    field :longitude, :float
  end

  def changeset(ch, params) do
    cast(ch, params, @required ++ @optional)
    |> validate_required(@required)
  end
end

# ルートオブジェクトの定義
defmodule MyAPI.Params.KittenSearch
  use Ecto.Schema
  import Ecto.Changeset

  @required ~w(breed)
  @optional ~w(age_min age_max)

  schema "params for kitten search" do
    field :breed, :string
    field :age_min, :integer
    field :age_max, :integer
    embeds_one :near_location, Location
  end

  def changeset(ch, params) do
    cast(ch, params, @required ++ @optional)
    |> validate_required(@required)
    |> cast_embed(:near_location, required: true)
  end
end

# Controllerでの利用
def search(conn, params) do
  alias MyAPI.Params.KittenSearch
  changeset = KittenSearch.changeset(%KittenSearch{}, params)
  if changeset.valid? do
    ...
end

求める機能は実現できますが、ちょっと複雑です。
この機能をparamsを用いて、簡単に導入します。

paramsを用いた実装

paramsのUsageのコードを引用して、簡単に解説したものです。

defparams

上記の実装は以下のように書けます。
めちゃくちゃ簡単です。

defmodule MyAPI.KittenController do

  use Params

  defparams kitten_search %{
    breed!: :string, # 必須パラメータ
    age_max: :integer, # 任意パラメータ
    age_min: [field: :integer, default: 1], # default値の定義
    near_location!: %{ # 入れ子の定義
      latitude!: :float, longitude!: :float
    },
    tags: [:string] # 配列の定義
  }

  def index(conn, params) do
    changeset = kitten_search(params)
    if changeset.valid? do
      search = Params.data changeset
      IO.puts search.near_location.latitude
    ...
  end
end

defparamsParams.Schamaを生成するマクロです。

なお、defparamasの引数は、mapです。keyとvalueは以下のようになっています。

説明
key field名のatom
末尾に"!"をつけると必須パラメータとして扱われる
value 基本はEcto Typeのatom
defauld値を設定したい場合は、[field: :integer, default: 1]のように配列で宣言する

Moduleでの定義とSchemaの利用

defparamsを用いる他に、モジュールを作成してその中にスキーマやカスタムバリデーション用のメソッドを利用できます

defmodule UserSearch do
  use Params.Schema, %{name: :string, age: :integer}
  import Ecto.Changeset, only: [cast: 3, validate_inclusion: 3]

  def child(ch, params) do
    cast(ch, params, ~w(name age))
    |> validate_inclusion(:age, 1..6)
  end
end

defmodule MyApp.UserController do

  def index(conn, params) do
    changeset = UserSearch.from(params, with: &UserSearch.child/2)
    if changeset.valid? do
      # age in 1..6
  end

end

child/2メソッドはEcto.Changesetを第1引数に取り、第2引数のparamsconn.paramsになり、Ecto.Changesetを返すメソッドです。
Ecto.Changesetを使っているので、その機能に含まれたバリデーションは全て利用することができます。値の範囲、文字数の最小、最大、文字列の正規表現といった用意されたバリデーションから、カスタムバリデーションや、入力値の加工(空白文字の削除)などもここで実装できます。
ここでその実装方法については触れないので、公式のEcto.Changesetを参照ください。

Changesetから構造体やマップに変換する方法については、以下のメソッドが提供されています。

上記を用いることで、バリデーション後のchangesetを扱いやすいdata型に変更できます。

defmodule UserUpdateParams do
  use Params.Schema, %{
    name: :string,
    age: :integer,
    auditlog: [field: :boolean, default: true]
  }
end

changeset = UserUpdateParams.from(%{name: "John"})

Params.data(changeset) # => %UserUpdateParams{name: "John", age: nil, auditlog: true}
Params.to_map(changeset) # => %{name: "John", auditlog: true}

このように、非常に簡潔な実装ながら、最初に紹介した既存のリクエストパラメータハンドリングのつらいところ全て解決しています

  • 必須パラメータの数が増えてもSchema定義されているので見やすい
  • 任意パラメータも定義してあるし、!の有無で必須パラメータとの違いが見やすい
  • 型のCastはEctoの作法に則ってよしなにやってくれる

(ついでに)

  • 必須パラメータが欠けている時にもchangeset.valid?でハンドリングできるので、400等の適切なハンドリングがしすい
  • Ecto.Changesetを使っているので、カスタムバリデーションもエスケープ処理も自由自在

と良いことずくめです!

Paramsライブラリを使う際の注意点

最後に、paramsライブラリは非常に便利なのですが、バリデーション条件を追加するカスタムバリデーションを利用する時には、少し注意が必要です。

サンプル通りに実装し、changeset = UserSearch.from(params, with: &UserSearch.child/2)のようにfromメソッドの第2引数に:withをキーにメソッドを設定した場合、既存のバリデーション機能が一部うまく動きません。

うまく使えない機能は以下の2点です

  • "!"をつけて指定していた必須か否かのバリデーションが自動で効かなる
  • 入れ子のバリデーションが自動で効かなくなる

なぜだろうと思いParams.from内部実装を見てみると、withに関数を指定すると、on_castが完全にその関数に置き換わるためでした。

  def from(params, options \\ []) when is_list(options) do
    on_cast = Keyword.get(options, :with, &__MODULE__.changeset(&1, &2))
    __MODULE__ |> struct |> Ecto.Changeset.change |> on_cast.(params)
  end

既存の必須パラメータなどのバリデーションを活かしつつ、カスタムでバリデーションを足したい場合、以下のように、Params.changesetを使用することで、デフォルトのバリデーションを呼び出すことが可能です。
こうすることで、デフォルトのバリデーションと追加のバリデーションをうまく共存させることができます。

defmodule UserSearch do
  use Params.Schema, %{name: :string, age: :integer}
  import Ecto.Changeset, only: [cast: 3, validate_inclusion: 3]

  def child(ch, params) do
    changeset(ch, params) #もしくは、Params.changeset(ch, params)
    |> validate_inclusion(:age, 1..6)
  end
end

※ 尚、!等を使ったデフォルトのバリデーションのエラー文言は基本的に英語で出力されますので、日本語のエラー文言を出力したい!という場合は、changesetではなく、castを利用することをおすすめします!

最後に

自分がparamsライブラリを利用して流れを、理由から一通り書いてみました!
paramsライブラリは非常に簡潔な記法で十分に検証されているEctoの機能を使うことができる非常に便利なライブラリです。
Elixir/Phoenixでパラメータハンドリングに悩んでいる方は、是非検討してみてください!

19
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?