Repository: dashbitco/nimble_options (elixir)

Issue:
How to validate a list of keyword lists
When trying to validate a list of keyword lists, I would assume the following would work:

```elixir
definitions = [
  my_items: [
    type: {:list, :keyword_list}, 
    keys: [key: [type: :string]]
  ]
]

NimbleOptions.validate!([
  my_items: [
    [key: "value"],
    [key: "value"]
  ]
], definitions)
```

But instead I'm met with the following errors
```
** (ArgumentError) expected a keyword list, but an entry in the list is not a two-element tuple with an atom as its first element, got: [key: "value"]
    (elixir 1.12.1) lib/keyword.ex:475: Keyword.keys/1
    (nimble_options 0.4.0) lib/nimble_options.ex:388: NimbleOptions.validate_unknown_options/2
    (nimble_options 0.4.0) lib/nimble_options.ex:376: NimbleOptions.validate_options_with_schema_and_path/3
    (nimble_options 0.4.0) lib/nimble_options.ex:409: NimbleOptions.reduce_options/2
    (elixir 1.12.1) lib/enum.ex:4251: Enumerable.List.reduce/3
    (elixir 1.12.1) lib/enum.ex:2402: Enum.reduce_while/3
    (nimble_options 0.4.0) lib/nimble_options.ex:402: NimbleOptions.validate_options/2
    (nimble_options 0.4.0) lib/nimble_options.ex:377: NimbleOptions.validate_options_with_schema_and_path/3
```

Pull request description:
Support lists of keyword lists in :list
Closes #77.

Patch:
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index 0de7277..c593bb4 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -135,7 +135,13 @@ defmodule NimbleOptions do
       contains the validated (and possibly updated) elements, each as returned after validation
       through `subtype`. For example, if `subtype` is a custom validator function that returns
       an updated value, then that updated value is used in the resulting list. Validation
-      fails at the *first* element that is invalid according to `subtype`.
+      fails at the *first* element that is invalid according to `subtype`. If `subtype` is
+      a keyword list, you won't be able to pass `:keys` directly. For this reason,
+      keyword lists (`:keyword_list` and `:non_empty_keyword_list`) are special cased and can
+      be used as the subtype by using `{:keyword_list, keys}` or
+      `{:non_empty_keyword_list, keys}`. For example, a type such as
+      `{:list, {:keyword_list, enabled: [type: :boolean]}}` would a *list of keyword lists*,
+      where each keyword list in the list could have the `:enabled` boolean option in it.
 
     * `{:tuple, list_of_subtypes}` - A tuple as described by `tuple_of_subtypes`.
       `list_of_subtypes` must be a list with the same length as the expected tuple.
@@ -674,9 +680,24 @@ defmodule NimbleOptions do
   end
 
   defp validate_type({:list, subtype}, key, value) when is_list(value) do
+    {subtype, nested_schema} =
+      case subtype do
+        {keyword_list, keys} when keyword_list in [:keyword_list, :non_empty_keyword_list] ->
+          {keyword_list, keys}
+
+        other ->
+          {other, _nested_schema = nil}
+      end
+
     updated_elements =
       for {elem, index} <- Stream.with_index(value) do
         case validate_type(subtype, "list element", elem) do
+          {:ok, value} when not is_nil(nested_schema) ->
+            case validate_options_with_schema_and_path(value, nested_schema, _path = [key]) do
+              {:ok, updated_value} -> updated_value
+              {:error, %ValidationError{} = error} -> throw({:error, index, error})
+            end
+
           {:ok, updated_elem} ->
             updated_elem
 
@@ -803,6 +824,13 @@ defmodule NimbleOptions do
     end)
   end
 
+  # This is to support the special-cased "{:list, {:keyword_list, my_key: [type: ...]}}",
+  # like we do in the :or type.
+  def validate_type({:list, {type, keys}})
+      when type in [:keyword_list, :non_empty_keyword_list] and is_list(keys) do
+    {:ok, {:list, {type, keys}}}
+  end
+
   def validate_type({:list, subtype}) do
     case validate_type(subtype) do
       {:ok, validated_subtype} -> {:ok, {:list, validated_subtype}}


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index d190713..75d6531 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -968,6 +968,37 @@ defmodule NimbleOptionsTest do
              }
     end
 
+    for subtype <- [:keyword_list, :non_empty_keyword_list] do
+      test "{:list, subtype} with a #{inspect(subtype)} subtype" do
+        type = {:custom, __MODULE__, :string_to_integer, []}
+        schema = [opts_list: [type: {:list, {unquote(subtype), str: [type: type]}}]]
+
+        # Valid
+        opts = [opts_list: [[str: "1"], [str: "2"]]]
+
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:ok, [opts_list: [[str: 1], [str: 2]]]}
+
+        # Invalid
+        opts = [opts_list: [[str: "123"], [str: "not an int"]]]
+
+        message = """
+        list element at position 1 in :opts_list failed validation: expected string to be \
+        convertible to integer\
+        """
+
+        assert NimbleOptions.validate(opts, schema) == {
+                 :error,
+                 %NimbleOptions.ValidationError{
+                   key: :opts_list,
+                   keys_path: [],
+                   message: message,
+                   value: [[str: "123"], [str: "not an int"]]
+                 }
+               }
+      end
+    end
+
     test "valid {:tuple, tuple_def}" do
       schema = [result: [type: {:tuple, [{:in, [:ok, :error]}, :string]}]]