diff --git a/lib/peri.ex b/lib/peri.ex index 0a0832e..8ba4ca7 100644 --- a/lib/peri.ex +++ b/lib/peri.ex @@ -129,7 +129,7 @@ defmodule Peri do Peri.validate(schema, invalid_data) # => {:error, [email: "is required"]} """ - def validate(schema, data) when is_map(schema) and is_map(data) do + def validate(schema, data) when is_enumerable(schema) and is_enumerable(data) do data = filter_data(schema, data) case traverse_schema(schema, data) do @@ -151,30 +151,44 @@ defmodule Peri do end end - defp filter_data(schema, data, acc \\ %{}) do + defp filter_data(schema, data) do + acc = if is_map(schema), do: %{}, else: [] + Enum.reduce(schema, acc, fn {key, type}, acc -> string_key = to_string(key) - original_key = if Map.has_key?(data, string_key), do: string_key, else: key - value = Map.get(data, original_key) + value = get_enumerable_value(data, key) + original_key = if enumerable_has_key?(data, key), do: key, else: string_key cond do - not (Map.has_key?(data, key) or Map.has_key?(data, string_key)) -> + is_enumerable(data) and not enumerable_has_key?(data, key) -> acc - is_map(value) and is_map(type) -> + is_enumerable(value) and is_enumerable(type) -> nested_filtered_value = filter_data(type, value) - Map.put(acc, original_key, nested_filtered_value) + put_in(acc[original_key], nested_filtered_value) true -> - Map.put(acc, original_key, value) + put_in(acc[original_key], value) end end) + |> then(fn + %{} = data -> data + data when is_list(data) -> Enum.reverse(data) + end) + end + + defp enumerable_has_key?(data, key) when is_map(data) do + Map.has_key?(data, key) or Map.has_key?(data, Atom.to_string(key)) + end + + defp enumerable_has_key?(data, key) when is_list(data) do + Keyword.has_key?(data, key) end @doc false defp traverse_schema(schema, data, path \\ []) do Enum.reduce(schema, {[], path}, fn {key, type}, {errors, path} -> - value = Map.get(data, key) || Map.get(data, to_string(key)) + value = get_enumerable_value(data, key) case validate_field(value, type) do :ok -> @@ -196,6 +210,13 @@ defmodule Peri do end) end + defp get_enumerable_value(enum, key) do + case Access.get(enum, key) do + nil when is_map(enum) -> Map.get(enum, Atom.to_string(key)) + val -> val + end + end + defp update_error_paths(%Peri.Error{path: path, errors: nil} = error, new_path) do %Peri.Error{error | path: new_path ++ path} end @@ -313,7 +334,7 @@ defmodule Peri do end) end - defp validate_field(data, schema) when is_map(data) do + defp validate_field(data, schema) when is_enumerable(data) do case traverse_schema(schema, data) do {[], _path} -> :ok {errors, _path} -> {:error, errors} diff --git a/mix.exs b/mix.exs index 0605e2a..69e5205 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Peri.MixProject do use Mix.Project - @version "0.2.1" + @version "0.2.2" @source_url "https://github.com/zoedsoupe/peri" def project do diff --git a/test/peri_test.exs b/test/peri_test.exs index 69b2fc0..0def325 100644 --- a/test/peri_test.exs +++ b/test/peri_test.exs @@ -659,4 +659,288 @@ defmodule PeriTest do ]} end end + + defschema(:simple_keyword, [ + {:name, :string}, + {:age, :integer}, + {:email, {:required, :string}} + ]) + + defschema(:nested_keyword, [ + {:user, + [ + {:name, :string}, + {:profile, + [ + {:age, {:required, :integer}}, + {:email, {:required, :string}} + ]} + ]} + ]) + + defschema(:optional_fields_keyword, [ + {:name, :string}, + {:age, {:required, :integer}}, + {:email, {:required, :string}}, + {:phone, :string} + ]) + + describe "simple keyword list schema validation" do + test "validates simple keyword list schema with valid data" do + data = [name: "John", age: 30, email: "john@example.com"] + assert {:ok, ^data} = simple_keyword(data) + end + + test "validates simple keyword list schema with missing required field" do + data = [name: "John", age: 30] + + assert {:error, [%Peri.Error{path: [:email], message: "is required"}]} = + simple_keyword(data) + end + + test "validates simple keyword list schema with invalid field type" do + data = [name: "John", age: "thirty", email: "john@example.com"] + + assert {:error, + [ + %Peri.Error{ + path: [:age], + message: "expected type of integer received \"thirty\" value" + } + ]} = + simple_keyword(data) + end + end + + describe "nested keyword list schema validation" do + test "validates nested keyword list schema with valid data" do + data = [user: [name: "Jane", profile: [age: 25, email: "jane@example.com"]]] + assert {:ok, ^data} = nested_keyword(data) + end + + test "validates nested keyword list schema with invalid data" do + data = [user: [name: "Jane", profile: [age: "twenty-five", email: "jane@example.com"]]] + + assert { + :error, + [ + %Peri.Error{ + message: nil, + path: [:user], + content: nil, + errors: [ + %Peri.Error{ + path: [:user, :profile], + key: :profile, + content: nil, + message: nil, + errors: [ + %Peri.Error{ + path: [:user, :profile, :age], + key: :age, + content: [expected: :integer, actual: "\"twenty-five\""], + message: "expected type of integer received \"twenty-five\" value", + errors: nil + } + ] + } + ], + key: :user + } + ] + } = nested_keyword(data) + end + + test "validates nested keyword list schema with missing required field" do + data = [user: [name: "Jane", profile: [age: 25]]] + + assert { + :error, + [ + %Peri.Error{ + message: nil, + path: [:user], + content: nil, + errors: [ + %Peri.Error{ + path: [:user, :profile], + key: :profile, + content: nil, + message: nil, + errors: [ + %Peri.Error{ + path: [:user, :profile, :email], + key: :email, + content: [], + message: "is required", + errors: nil + } + ] + } + ], + key: :user + } + ] + } = + nested_keyword(data) + end + end + + describe "optional fields keyword list validation" do + test "validates keyword list schema with optional fields" do + data = [name: "John", age: 30, email: "john@example.com"] + assert {:ok, ^data} = optional_fields_keyword(data) + + data_with_optional = [name: "John", age: 30, email: "john@example.com", phone: "123-456"] + assert {:ok, ^data_with_optional} = optional_fields_keyword(data_with_optional) + end + + test "validates keyword list schema with optional fields and invalid optional field type" do + data = [name: "John", age: 30, email: "john@example.com", phone: 123_456] + + assert {:error, + [ + %Peri.Error{ + path: [:phone], + message: "expected type of string received 123456 value" + } + ]} = + optional_fields_keyword(data) + end + end + + defschema(:mixed_schema, %{ + user_info: [ + avatar: %{ + url: :string + }, + username: {:required, :string}, + role: {:required, {:enum, [:admin, :user]}} + ] + }) + + describe "mixed schema validation" do + test "validates mixed schema with valid data" do + data = %{ + user_info: [ + avatar: %{url: "http://example.com/avatar.jpg"}, + username: "john_doe", + role: :admin + ] + } + + assert {:ok, ^data} = mixed_schema(data) + end + + test "validates mixed schema with missing required field" do + data = %{user_info: %{avatar: %{url: "http://example.com/avatar.jpg"}, role: :admin}} + + assert { + :error, + [ + %Peri.Error{ + message: nil, + path: [:user_info], + content: nil, + errors: [ + %Peri.Error{ + path: [:user_info, :username], + key: :username, + content: [], + message: "is required", + errors: nil + } + ], + key: :user_info + } + ] + } = + mixed_schema(data) + end + + test "validates mixed schema with invalid enum value" do + data = %{ + user_info: %{ + avatar: %{url: "http://example.com/avatar.jpg"}, + username: "john_doe", + role: :superuser + } + } + + assert { + :error, + [ + %Peri.Error{ + message: nil, + path: [:user_info], + content: nil, + errors: [ + %Peri.Error{ + path: [:user_info, :role], + key: :role, + content: [choices: "[:admin, :user]", actual: ":superuser"], + message: "expected one of [:admin, :user] received :superuser", + errors: nil + } + ], + key: :user_info + } + ] + } = mixed_schema(data) + end + + test "validates mixed schema with invalid field type" do + data = %{user_info: %{avatar: %{url: 12345}, username: "john_doe", role: :admin}} + + assert { + :error, + [ + %Peri.Error{ + message: nil, + path: [:user_info], + content: nil, + errors: [ + %Peri.Error{ + path: [:user_info, :avatar], + key: :avatar, + content: nil, + message: nil, + errors: [ + %Peri.Error{ + path: [:user_info, :avatar, :url], + key: :url, + content: [expected: :string, actual: "12345"], + message: "expected type of string received 12345 value", + errors: nil + } + ] + } + ], + key: :user_info + } + ] + } = mixed_schema(data) + end + + test "validates mixed schema with extra fields" do + data = %{ + user_info: %{ + avatar: %{url: "http://example.com/avatar.jpg", size: "large"}, + username: "john_doe", + role: :admin, + extra_field: "extra" + } + } + + expected_data = %{ + user_info: [ + avatar: %{url: "http://example.com/avatar.jpg"}, + username: "john_doe", + role: :admin + ] + } + + assert {:ok, ^expected_data} = mixed_schema(data) + end + end end