Skip to content

Commit

Permalink
support natively keyword lists
Browse files Browse the repository at this point in the history
  • Loading branch information
zoedsoupe committed Jun 17, 2024
1 parent 9a39ed8 commit 9f8aaef
Show file tree
Hide file tree
Showing 3 changed files with 316 additions and 11 deletions.
41 changes: 31 additions & 10 deletions lib/peri.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ->
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
284 changes: 284 additions & 0 deletions test/peri_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]"]
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: "[email protected]"]

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: "[email protected]"]]]
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: "[email protected]"]]]

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: "[email protected]"]
assert {:ok, ^data} = optional_fields_keyword(data)

data_with_optional = [name: "John", age: 30, email: "[email protected]", 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: "[email protected]", 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

0 comments on commit 9f8aaef

Please sign in to comment.