Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type validator. #24

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions lib/vex/validators/type.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
defmodule Vex.Validators.Type do
@moduledoc """
Ensure the value has the correct type.

The type can be provided in the following form:

* `type`: An atom representing the type.
It can be any of the `TYPE` in Elixir `is_TYPE` functions.
`:any` is treated as a special case and accepts any type.
* `[type]`: A list of types as described above. When a list is passed,
the value will be valid if it any of the types in the list.
* `type: inner_type`: Type should be either `map`, `list`, `tuple`, or `function`.
The usage are as follow

* `function: arity`: checks if the function has the correct arity.
* `map: {key_type, value_type}`: checks keys and value in the map with the provided types.
* `list: type`: checks every element in the list for the given types.
* `tuple: {type_a, type_b}`: check each element of the tuple with the provided types,
the types tuple should be the same size as the tuple itself.
Copy link
Contributor

@sascha-wolf sascha-wolf Dec 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should implies that it's merely recommended to be the same size but the validator enforces them to be the same. I would suggest must.

  defp do_validate(tuple, tuple: types)
      when is_tuple(tuple) and is_tuple(types) and tuple_size(tuple) == tuple_size(types) do


## Options

* `:is`: Required. The type of the value, in the format described above.
* `:message`: Optional. A custom error message. May be in EEx format
and use the fields described in "Custom Error Messages," below.

## Examples

iex> Vex.Validators.Type.validate(1, is: :binary)
{:error, "must be of type :binary"}
iex> Vex.Validators.Type.validate(1, is: :number)
:ok
iex> Vex.Validators.Type.validate(nil, is: nil)
:ok
iex> Vex.Validators.Type.validate(1, is: :integer)
:ok
iex> Vex.Validators.Type.validate("foo"", is: :binary)
:ok
iex> Vex.Validators.Type.validate([1, 2, 3], is: [list: :integer])
:ok
iex> Vex.Validators.Type.validate(%{:a => 1, "b" => 2, 3 => 4}, is: :map)
:ok
iex> Vex.Validators.Type.validate(%{:a => 1, "b" => 2}, is: [map: {[:binary, :atom], :any}])
:ok
iex> Vex.Validators.Type.validate(%{"b" => 2, 3 => 4}, is: [map: {[:binary, :atom], :any}])
{:error, "must be of type {:map, {[:binary, :atom], :any}}"}

## Custom Error Messages

Custom error messages (in EEx format), provided as :message, can use the following values:

iex> Vex.Validators.Type.__validator__(:message_fields)
[value: "The bad value"]

An example:

iex> Vex.Validators.Type.validate([1], is: :binary, message: "<%= inspect value %> is not a string")
{:error, "[1] is not a string"}
"""
use Vex.Validator

@message_fields [value: "The bad value"]

@doc """
Validates the value against the given type.
See the module documentation for more info.
"""
@spec validate(any, Keyword.t) :: :ok | {:error, String.t}
def validate(value, options) when is_list(options) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you @doc and @spec this please?

acceptable_types = Keyword.get(options, :is, [])
if do_validate(value, acceptable_types) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention would suggest to name this with a trailing ? (e.g. is_valid_type?). Just a thought though.

:ok
else
message = "must be of type #{acceptable_type_str(acceptable_types)}"
{:error, message(options, message, value: value)}
end
end

# Allow any type, useful for composed types
defp do_validate(_value, :any), do: true

# Handle nil
defp do_validate(nil, nil), do: true
defp do_validate(nil, :atom), do: false

# Simple types
defp do_validate(value, :atom) when is_atom(value), do: true
defp do_validate(value, :number) when is_number(value), do: true
defp do_validate(value, :integer) when is_integer(value), do: true
defp do_validate(value, :float) when is_float(value), do: true
defp do_validate(value, :binary) when is_binary(value), do: true
defp do_validate(value, :bitstring) when is_bitstring(value), do: true
defp do_validate(value, :tuple) when is_tuple(value), do: true
defp do_validate(value, :list) when is_list(value), do: true
defp do_validate(value, :map) when is_map(value), do: true
defp do_validate(value, :function) when is_function(value), do: true
defp do_validate(value, :reference) when is_reference(value), do: true
defp do_validate(value, :port) when is_port(value), do: true
defp do_validate(value, :pid) when is_pid(value), do: true
defp do_validate(%{__struct__: module}, module), do: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is missing from the @moduledoc. If I got that right you can also do this?

Vex.Validators.Type.validate(%MyStruct{}, is: MyStruct)


# Complex types
defp do_validate(value, :string) when is_binary(value) do
String.valid?(value)
end

defp do_validate(value, function: arity) when is_function(value, arity), do: true

defp do_validate(list, list: type) when is_list(list) do
Enum.all?(list, &(do_validate(&1, type)))
end
defp do_validate(value, map: {key_type, value_type}) when is_map(value) do
Enum.all? value, fn {k, v} ->
do_validate(k, key_type) && do_validate(v, value_type)
end
end
defp do_validate(tuple, tuple: types)
when is_tuple(tuple) and is_tuple(types) and tuple_size(tuple) == tuple_size(types) do
Enum.all? Enum.zip(Tuple.to_list(tuple), Tuple.to_list(types)), fn {value, type} ->
do_validate(value, type)
end
end

# Accept multiple types
defp do_validate(value, acceptable_types) when is_list(acceptable_types) do
Enum.any?(acceptable_types, &(do_validate(value, &1)))
end

# Fail if nothing above matched
defp do_validate(_value, _type), do: false


defp acceptable_type_str([acceptable_type]), do: inspect(acceptable_type)
defp acceptable_type_str(acceptable_types) when is_list(acceptable_types) do
last_type = acceptable_types |> List.last |> inspect
but_last =
acceptable_types
|> Enum.take(Enum.count(acceptable_types) - 1)
|> Enum.map(&inspect/1)
|> Enum.join(", ")
"#{but_last} or #{last_type}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate a bit about what this function is doing? I get what the result is but the method you need to achieve it is confusing to me.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given something like [:string, :atom, [list: :string]] it should return ":string, :atom or [list: :string]".
So given types t1, t2, ..., tn it should join t1...t(n-1) with a comma and join t1...t(n-1) and tn with or.
As types do not all implement String.Chars, I am using inspect to get a string.
I am not particularly happy with this implementation so if you have improvements idea I am all ears.

Copy link
Contributor

@sascha-wolf sascha-wolf Dec 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some suggestions:

{last_type, but_last_types} =
  acceptable_types
  |> Enum.map(&inspect/1)
  |> List.pop_at(-1)

but_last = Enum.join(but_last_types, ", ")

"#{but_last} or #{last_type}"

or alternatively:

defp acceptable_type_str([type]) do
  inspect(type)
end

defp acceptable_type_str([type, last_type]) do
  "#{inspect(type)} or #{inspect(last_type)}"
end

defp acceptable_type_str([type | tail]) do
  "#{inspect(type)}, " <> acceptable_type_string(tail)
end

defp acceptable_type_str(type) do
  inspect(type)
end

or:

defp acceptable_type_str([type | tail]) do
  "#{inspect(type)}" <>
    case tail do
      [] -> ""
      [last] -> " or #{inspect(last)}"
      more -> ", " <> acceptable_type_str(more)
    end
end

defp acceptable_type_str(type) do
  inspect(type)
end

I might have a went a bit overboard here... 😄

end
defp acceptable_type_str(acceptable_type), do: inspect(acceptable_type)
end
98 changes: 98 additions & 0 deletions test/validations/type_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
defmodule TypeTest do
use ExUnit.Case

defmodule Dummy do
defstruct [:value]
end

test "simple types" do
port = Port.list |> List.first
valid_cases = [
{1, :any},
{"a", :any},
{1, :number},
{1, :integer},
{nil, nil},
{"a", :binary},
{"a", :bitstring},
{1.1, :float},
{1.1, :number},
{:foo, :atom},
{&self/0, :function},
{{1, 2}, :tuple},
{[1, 2], :list},
{%{a: 1}, :map},
{self, :pid},
{make_ref, :reference},
{port, :port},
{1, [:binary, :integer]},
{nil, [nil, :integer]},
{"a", [:binary, :atom]},
{:a, [:binary, :atom]},
{"hello", :string},
{~r/foo/, Regex},
{%Dummy{}, Dummy}
]
invalid_cases = [
{1, :binary},
{1, :float},
{1, nil},
{nil, :atom},
{1.1, :integer},
{self, :reference},
{{1, 2}, :list},
{{1, 2}, :map},
{[1, 2], :tuple},
{%{a: 2}, :list},
{<<239, 191, 191>>, :string},
{~r/foo/, :string},
{:a, [:binary, :integer]}
]

run_cases(valid_cases, invalid_cases)
end

test "complex types" do
valid_cases = [
{&self/0, function: 0},
{[1, 2,], list: :integer},
{[1, 2, nil], list: [nil, :number]},
{[a: 1, b: 2], list: [tuple: {:atom, :number}]},
{%{:a => "a", "b" => nil}, map: {[:atom, :binary], [:binary, nil]}}
]
invalid_cases = [
{[a: 1, b: "a"], list: [tuple: {:atom, :number}]},
{%{1 => "a", "b" => 1}, map: {[:atom, :binary], [:binary, :integer]}},
{%{:a => 1.1, "b" => 1}, map: {[:atom, :binary], [:binary, :integer]}}
]
run_cases(valid_cases, invalid_cases)
end

test "deeply nested type" do
valid_value = %{a: %{1 => [a: [%{"a" => [a: [1, 2.2, 3]], "b" => nil}]]}}
invalid_value = %{a: %{1 => [a: [%{"a" => [a: [1, 2.2, "3"]]}]]}}
other_invalid_value = %{a: %{1 => [a: [%{"a" => [a: [1, 2.2, nil]]}]]}}
type = [map: {:atom, [map: {:integer, [list: [tuple: {:atom, [list: [map: {:binary, [nil, [list: [tuple: {:atom, [list: [:integer, :float]]}]]]}]]}]]}]}]
run_cases([{valid_value, type}], [{invalid_value, type}, {other_invalid_value, type}])
end

test "default message" do
expected = [{:error, :foo, :type, "must be of type :integer"}]
assert Vex.errors(%{foo: "bar"}, foo: [type: [is: :integer]]) == expected
expected = [{:error, :foo, :type, "must be of type :atom, :string or :list"}]
assert Vex.errors(%{foo: 1}, foo: [type: [is: [:atom, :string, :list]]]) == expected
expected = [{:error, :foo, :type, "value 1 is not a string"}]
message = "value <%= value %> is not a string"
assert Vex.errors(%{foo: 1}, foo: [type: [is: :string, message: message]]) == expected
end

defp run_cases(valid_cases, invalid_cases) do
Enum.each valid_cases, fn {value, type} ->
assert Vex.valid?([foo: value], foo: [type: [is: type]])
end

Enum.each invalid_cases, fn {value, type} ->
refute Vex.valid?([foo: value], foo: [type: [is: type]])
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test that covers the error message output?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

end