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

# はじめに

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

* [Elixir/Phoenixでのリクエストの受け取り方の基本](#Elixir/Phoenixでのリクエストの受け取り方の基本)
* [既存のリクエストパラメータハンドリングのつらいところ](#既存のリクエストパラメータハンドリングのつらいところ)
* [paramsの紹介](#paramsの紹介)

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

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


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

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

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

といったパラメータはすべてControllerの `action/2` メソッドの第2引数である `params` で受け取ることとなります。(第2引数の`params`の実体は[`Conn.params`](https://hexdocs.pm/plug/1.8.1/Plug.Conn.html#module-fetchable-fields)なので、もちろん第1引数の`conn`からも取得することができます)

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


- 必須パラメータはパターンマッチで取得
- 任意のパラメータは`params`という`map`に文字列キーでアクセスして取得

```elixir: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のコードは以下のようになるでしょう。（コードの中身自体は超適当です。）

```elixir: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にまとめると、以下のようになります。

```elixir:user_address_controller.ex
def create(conn, params) do

  user_address = UserAddressService.create(params)

  conn
  |> json(user_address)
end
```

```elixir: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とは

* https://github.com/vic/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で行う](https://qiita.com/niku/items/0bd63a496c9a685d7e33)
というエントリーがありました。


実装例に関しては、[paramsのAbout](https://github.com/vic/params#about)を引用します。
※ちゃんと読みたい方は上記を直接お願いします。


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

```elixir
# 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を受け取ることを想定すると、

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

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

```elixir
# ネストしたオブジェクトの定義
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](https://github.com/vic/params#usage)のコードを引用して、簡単に解説したものです。

### defparams

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

```elixir
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
```

`defparams`は[Params.Schama](https://hexdocs.pm/params/Params.Schema.html)を生成するマクロです。

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

| 値 | 説明 |
|:--|:--|
| key | field名のatom <br> 末尾に"!"をつけると必須パラメータとして扱われる |
| value | 基本は[Ecto Type](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types)のatom <br> defauld値を設定したい場合は、`[field: :integer, default: 1]`のように配列で宣言する|


### Moduleでの定義とSchemaの利用

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

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


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

* [Params.data](https://hexdocs.pm/params/Params.html#data/1)
* [Params.to_map](https://hexdocs.pm/params/Params.html#to_map/1)

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

```elixir
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`の[内部実装](https://github.com/vic/params/blob/master/lib/params/schema.ex#L79)を見てみると、withに関数を指定すると、on_castが完全にその関数に置き換わるためでした。

```elixir
  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`](https://github.com/vic/params/blob/master/lib/params/behaviour.ex#L6)を使用することで、デフォルトのバリデーションを呼び出すことが可能です。
こうすることで、デフォルトのバリデーションと追加のバリデーションをうまく共存させることができます。

```elixir
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`でパラメータハンドリングに悩んでいる方は、是非検討してみてください！