params 入門

この記事は 「Elixir Advent Calendar 2019」 20日目の記事です。

昨日は ndac_todoroki さんの [EctoでCSVを読めるようにする - Qiita](https://qiita.com/ndac_todoroki/items/48c44dd1a2e9c7a824d3) でした。

Ecto.Adapter を利用した拡張を書くときに参考になりますね！
ところで、ちょうど「まえおき」のセクションでバリデータとしての Ecto についての言及があったので、
自分が業務のコードでもよく利用している [params](https://hex.pm/packages/params) というライブラリを紹介しようと思います。

とはいっても基本的な紹介はすでに Qiita でも [【Elixir/Phoenix】paramsライブラリでリクエストパラメータのバリデーションとキャストを楽ちんに行う - Qiita](https://qiita.com/shkomine/items/98bb5c401e4267f43571) で紹介されているので、
この記事ではもうちょっと細かい話を紹介していきます。

# params の紹介

Ecto.Schema や Ecto.Changeset をライトな記法でつかうための支援ライブラリです。
気軽なパラメータバリデートなどの用途に使うにはやや重めな定義が必要な Ecto.Schema をだいぶ単純な定義で利用可能にしてくれます。

このライブラリ、README やドキュメントはあっさりしているのですが、実はわりと柔軟な記法もサポートしています。

## 実は色々できる params

コード例を交えつつ実は色々できるところを紹介していきます。

- 実際には `defmodule M do ...` の中で利用していたりしますが省略しています。
- Params がロードされた iex にコピペして動かせるコード片を gist に軽くまとめているので、動かして見たい方はそちらを参照ください。
  - https://gist.github.com/enerick/5d438ad3c383baa828c976cb095a5900



### 基本的な使い方

例として、以下のような適当なパラメータを定義してみます。


```elixir
defparams person_params(%{name!: :string, age: :integer})
```

`person_params` は必須パラメータに `:name` を、オプショナルパラメータに `:age` を持ちます。
この定義は以下のように利用することができます。

```elixir
# valid な入力
iex> ch = person_params(%{"name" => "ジョン", "age" => 18})
#Ecto.Changeset<
  action: nil,
  changes: %{age: 18, name: "ジョン"},
  errors: [],
  data: #Params.M.PersonParams<>,
  valid?: true
>

# map に変換
iex> ch |> Params.to_map
%{age: 18, name: "ジョン"}

# Strong parameter 的にも使える
iex> person_params(%{"name" => "ジョン", "gomi" => "ゴミ"}) |> Params.to_map
%{name: "ジョン"}
```
```elixir
# invalid な入力
iex> person_params(%{"age" => "hoge"})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [
    name: {"can't be blank", [validation: :required]},
    age: {"is invalid", [type: :integer, validation: :cast]}
  ],
  data: #Params.M.PersonParams<>,
  valid?: false
>
```

データ型や必須パラメータのバリデーションができていることがわかります。

---

### カスタムバリデータを使いたい！

API パラメータのバリデーションなら大抵はここまでの基本的なバリデーションができれば十分なことも多いですが、より詳細な制御を行いたい場合もあるかもしれません。

そういった場合には、カスタムバリデータを与えることができます。

先ほどの例を `:age` が18歳〜200歳であることをチェックするように拡張してみます。

#### ふつうのやりかた

README 的なやり方では、べつのバリデート関数を定義してあげてそちらを利用します。

```elixir
# age は 18~200 であることをバリデート
defmodule PersonParams do
  use Params.Schema, %{name!: :string, age: :integer}
  import Ecto.Changeset, only: [cast: 3, validate_inclusion: 3]

  def custom(ch, params) do
    cast(ch, params, ~w(name age)a)
    |> validate_inclusion(:age, 18..200)
  end
end
```

利用するときには `from/2` を使います。

```elixir
iex> PersonParams.from(%{"age" => 1}, with: &PersonParams.custom/2)
#Ecto.Changeset<
  action: nil,
  changes: %{age: 1},
  errors: [age: {"is invalid", [validation: :inclusion, enum: 18..200]}],
  data: #PersonParams<>,
  valid?: false
>
```

(この実装は実は不十分で、必須パラメータのバリデーションが効かなくなったりもしています。)

#### 実は changeset は defoverridable なので super で拡張できる

バリデート関数に別名を振りたいケースはそれほど多くないので、もっとシンプルに行きたい気がしてきます。
**実は changeset をそのまま拡張できます。**

```elixir
defmodule PersonParams do
  use Params.Schema, %{name!: :string, age: :integer}
  import Ecto.Changeset, only: [validate_inclusion: 3]

  def changeset(ch, params) do
    # Params.Schema で defoverridable が定義されているので、実は super が使える
    super(ch, params)
    |> validate_inclusion(:age, 18..200)
  end
end
```

`changeset/2` を利用する場合には `from/1` で OK になります。
またこの方法では `:name` の必須バリデーションもしっかり効いています。


```elixir
iex> PersonParams.from(%{"age" => 1})
#Ecto.Changeset<
  action: nil,
  changes: %{age: 1},
  errors: [
    age: {"is invalid", [validation: :inclusion, enum: 18..200]},
    name: {"can't be blank", [validation: :required]}
  ],
  data: #PersonParams<>,
  valid?: false
>
```

#### 実は changeset の拡張は defparams でも使える

こういう拡張が `defparams` でも使えたら Controller 上で簡潔に済ませられるのに...という気がしてきます。
**実は defparams は `do` ブロックを受け取ることができ、その中で同じことができます。**

```elixir
# defparams でもカスタムバリデータが実は使える
defparams person_params(%{name!: :string, age: :integer}) do
  def changeset(ch, params) do
    super(ch, params)
    |> validate_inclusion(:age, 18..200)
  end
end
```

使い方もそのままです。

```elixir
iex> person_params(%{"age" => 1})
#Ecto.Changeset<
  action: nil,
  changes: %{age: 1},
  errors: [
    age: {"is invalid", [validation: :inclusion, enum: 18..200]},
    name: {"can't be blank", [validation: :required]}
  ],
  data: #Params.M.PersonParams<>,
  valid?: false
>
```

---

### ネスト構造を扱いたい！

ネストしたパラメータ構造を利用する場合がよくあります。 Ecto では [embedded_schema](https://hexdocs.pm/ecto/Ecto.Schema.html#embedded_schema/1) として表現されますが、Params ではこれもライトな記法で利用できます。

#### ふつうのやり方

シンプルに map をそのままネストさせると、 embedded schema として定義してくれます。良いですね。

```elixir
defparams person_params(%{
            name!: :string,
            age: :integer,
            # nested な構造もサポート (embedded schema)
            pets!: [
              %{
                name!: :string,
                category!: [field: :string, default: "dog"]
              }
            ]
          })
```

ネスト構造側のバリデーションもちゃんと効きます。
(さりげなくデフォルト値を利用している `:category` は無指定でもデフォルト値が入るので通っています)

```elixir
iex> person_params(%{"name" => "hoge", "pets" => [%{"hoge" => 100}]})
#Ecto.Changeset<
  action: nil,
  changes: %{
    name: "hoge",
    pets: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{},
        errors: [name: {"can't be blank", [validation: :required]}],
        data: #Params.M.PersonParams.Pets<>,
        valid?: false
      >
    ]
  },
  errors: [],
  data: #Params.M.PersonParams<>,
  valid?: false
>
```

#### ナイーブな定義の再利用

ネスト構造は往々にして別のパラメータ定義そのものだったりします。どうせならを再利用したい気がしてきます。
Params では**パラメータ定義が単なる map** なので、それを利用した簡単な再利用ができます。

```elixir
@pet %{name!: :string, category!: [field: :string, default: "dog"]}
defparams person_params(%{
            name!: :string,
            age: :integer,
            # 定数から定義を展開
            pets!: [@pet]
          })
```

#### 実は defparams の定義をネスト構造として再利用できる

ただ、ナイーブな再利用では、ネスト構造側にカスタムバリデーションを入れたい場合に、カスタムバリデーションごと再利用することができません。
**実は defparams で定義したパラメータを別のパラメータ定義にモジュール参照で埋め込むことができます。**

一見簡単に見える拡張ですが、データ型部分に Ecto.Type も指定できるという特性上、データ型なのか埋め込みなのかを区別可能な別の記法を導入する必要があって面倒なため、長年の(?)未解決問題になっていたのですが、2019年10月に[突然解決されました](https://github.com/vic/params/pull/30) :tada: 

この機能は 2019-12-19 時点で未リリースです。v2.1.2 以降で使えるようになることでしょう。

```elixir
defparams pet_params(%{name!: :string, category!: [field: :string, default: "dog"]}) do
  def changeset(ch, params) do
    super(ch, params)
    |> validate_inclusion(:category, ~w(dog cat))
  end
end

defparams person_params(%{
            name!: :string,
            age: :integer,
            # 実は defparams の定義を再利用できる
            pets: {:embeds_many, Params.M.PetParams}
          })
```

```elixir
iex> person_params(%{"pets" => [%{"category" => "bird"}]})
#Ecto.Changeset<
  action: nil,
  changes: %{
    pets: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{category: "bird"},
        errors: [
          # カスタムバリデーションも効いている
          category: {"is invalid",
           [validation: :inclusion, enum: ["dog", "cat"]]},
          name: {"can't be blank", [validation: :required]}
        ],
        data: #Params.M.PetParams<>,
        valid?: false
      >
    ]
  },
  errors: [name: {"can't be blank", [validation: :required]}],
  data: #Params.M.PersonParams<>,
  valid?: false
>
```

## その他よくつかうやつ

### Ecto.Type 拡張

パラメータ定義のデータ型には Ecto.Type を指定可能です。
[ecto_enum](https://hex.pm/packages/ecto_enum)との組み合わせによる enum 指定は簡単便利でよく使います。


### 共通のヘルパー関数

Controller 層で使うときには、入力データを Changeset にして、その結果をみて data にして、という手順だけでも面倒になるものです。
なので共通のヘルパー関数を利用しています。

イメージ的には以下のような簡単なものです。

```elixir
@doc """
パラメータを validate します。
"""
@spec validate_params(map, (map -> Ecto.Changeset.t())) ::
        {:ok, struct} | {:error, Ecto.Changeset.t()}
def validate_params(params, method) do
  method.(params)
  |> case do
    %Ecto.Changeset{valid?: true} = ch ->
      {:ok, ch |> Params.data()}

    ch ->
      {:error, ch}
  end
end
```

API のパラメータバリデーションなら、 Phoenix の action_fallback を利用して、 `{:error, Ecto.Changeset.t}` に対して適当にエラー内容を展開する定義を用意しておくといい感じです。

## さいごに

大抵の Ecto.Schema 利用シーンをライトな記述でカバーできるので、最近は DynamoDB のモデル定義にも Params をつかったりしています。
定義を単なる map で与えられるため、**フィールド定義の合成を map のマージで非常にシンプルに表現できたり**と、思わぬ副作用的なメリットもあったりします。

ドキュメントはそっけないですが、おすすめのライブラリです。是非利用してみてください :wave: