defmodule Peri do @moduledoc """ Peri is a schema validation library for Elixir, inspired by Clojure's Plumatic Schema. It focuses on validating raw maps and supports nested schemas and optional fields. ## Usage To define a schema, use the `defschema` macro. By default, all fields in the schema are optional unless specified as `{:required, type}`. ```elixir defmodule MySchemas do import Peri defschema :user, %{ name: :string, age: :integer, email: {:required, :string}, address: %{ street: :string, city: :string }, tags: {:list, :string}, role: {:enum, [:admin, :user, :guest]}, geolocation: {:tuple, [:float, :float]}, rating: {:custom, &validate_rating/1} } defp validate_rating(n) when n < 10, do: :ok defp validate_rating(_), do: {:error, "invalid rating", []} end ``` You can then use the schema to validate data: ```elixir user_data = %{name: "John", age: 30, email: "john@example.com", address: %{street: "123 Main St", city: "Somewhere"}, tags: ["science", "funky"], role: :admin, geolocation: {12.2, 34.2}, rating: 9} case MySchemas.user(user_data) do {:ok, valid_data} -> IO.puts("Data is valid!") {:error, errors} -> IO.inspect(errors, label: "Validation errors") end ``` ## Available Types - `:string` - Validates that the field is a binary (string). - `:integer` - Validates that the field is an integer. - `:float` - Validates that the field is a float. - `:boolean` - Validates that the field is a boolean. - `:atom` - Validates that the field is an atom. - `:any` - Allow any datatype. - `{:required, type}` - Marks the field as required and validates it according to the specified type. - `:map` - Validates that the field is a map without checking nested schema. - `{:either, {type_1, type_2}}` - Validates that the field is either of `type_1` or `type_2`. - `{:oneof, types}` - Validates that the field is at least one of the provided types. - `{:list, type}` - Validates that the field is a list where elements belongs to a determined type. - `{:tuple, types}` - Validates that the field is a tuple with determined size and each element have your own type validation (sequential). - `{custom, anonymous_fun_arity_1}` - Validates that the field passes on the callback, the function needs to return either `:ok` or `{:error, reason}` where `reason` should be a string. - `{:custom, {MyModule, :my_validation}}` - Same as `{custom, anonymous_fun_arity_1}` but you pass a remote module and a function name as atom. - `{:custom, {MyModule, :my_validation, [arg1, arg2]}}` - Same as `{:custom, {MyModule, :my_validation}}` but you can pass extra arguments to your validation function. Note that the value of the field is always the first argument. """ @doc """ Defines a schema with a given name and schema definition. ## Examples defmodule MySchemas do import Peri defschema :user, %{ name: :string, age: :integer, email: {:required, :string} } end user_data = %{name: "John", age: 30, email: "john@example.com"} MySchemas.user(user_data) # => {:ok, %{name: "John", age: 30, email: "john@example.com"}} invalid_data = %{name: "John", age: 30} MySchemas.user(invalid_data) # => {:error, [email: "is required"]} """ defmacro defschema(name, schema) do quote do def get_schema(unquote(name)) do unquote(schema) end def unquote(name)(data) do Peri.validate(unquote(schema), data) end end end defguardp is_enumerable(data) when is_map(data) or is_list(data) def conforms?(schema, data) do case validate(schema, data) do {:ok, _} -> true {:error, _errors} -> false end end @doc """ Validates a given data map against a schema. Returns `{:ok, data}` if the data is valid according to the schema, or `{:error, errors}` if there are validation errors. ## Parameters - schema: The schema definition map. - data: The data map to be validated. ## Examples schema = %{ name: :string, age: :integer, email: {:required, :string} } data = %{name: "John", age: 30, email: "john@example.com"} Peri.validate(schema, data) # => {:ok, %{name: "John", age: 30, email: "john@example.com"}} invalid_data = %{name: "John", age: 30} Peri.validate(schema, invalid_data) # => {:error, [email: "is required"]} """ def validate(schema, data) when is_enumerable(schema) and is_enumerable(data) do data = filter_data(schema, data) case traverse_schema(schema, data) do {[], _path} -> {:ok, data} {errors, _path} -> {:error, errors} end end def validate(schema, data) do case validate_field(data, schema) do :ok -> {:ok, data} {:error, reason, info} -> msg = EEx.eval_string(reason, info) err = %Peri.Error{message: msg, content: info} {:error, err} end end 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) value = get_enumerable_value(data, key) original_key = if enumerable_has_key?(data, key), do: key, else: string_key cond do is_enumerable(data) and not enumerable_has_key?(data, key) -> acc is_enumerable(value) and is_enumerable(type) -> nested_filtered_value = filter_data(type, value) put_in(acc[original_key], nested_filtered_value) true -> 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 = get_enumerable_value(data, key) case validate_field(value, type) do :ok -> {errors, []} {:error, [%Peri.Error{} = nested_err | _]} -> current_path = path ++ [key] nested_error = update_error_paths(nested_err, current_path) err = %Peri.Error{path: current_path, key: key, errors: [nested_error]} {[err | errors], path} {:error, reason, info} -> msg = EEx.eval_string(reason, info) current_path = path ++ [key] err = %Peri.Error{path: current_path, message: msg, content: info, key: key} {[err | errors], path} end 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 defp update_error_paths(%Peri.Error{path: path, errors: errors} = error, new_path) do updated_errors = Enum.map(errors, &update_error_paths(&1, new_path)) %Peri.Error{error | path: new_path ++ path, errors: updated_errors} end @doc false defp validate_field(_, :any), do: :ok defp validate_field(val, :atom) when is_atom(val), do: :ok defp validate_field(val, :map) when is_map(val), do: :ok defp validate_field(val, :string) when is_binary(val), do: :ok defp validate_field(val, :integer) when is_integer(val), do: :ok defp validate_field(val, :float) when is_float(val), do: :ok defp validate_field(val, :boolean) when is_boolean(val), do: :ok defp validate_field(val, :list) when is_list(val), do: :ok defp validate_field(nil, {:required, _}), do: {:error, "is required", []} defp validate_field([], {:required, {:list, _}}), do: {:error, "cannot be empty", []} defp validate_field(val, {:required, type}), do: validate_field(val, type) defp validate_field(nil, _), do: :ok defp validate_field(val, {:custom, callback}) when is_function(callback, 1) do case callback.(val) do :ok -> :ok {:ok, _} -> :ok err -> err end end defp validate_field(val, {:custom, {mod, fun}}) do case apply(mod, fun, [val]) do :ok -> :ok {:ok, _} -> :ok err -> err end end defp validate_field(val, {:custom, {mod, fun, args}}) do case apply(mod, fun, [val | args]) do :ok -> :ok {:ok, _} -> :ok err -> err end end defp validate_field(val, {:either, {type_1, type_2}}) do with {:error, _, _} <- validate_field(val, type_1), {:error, _, _} <- validate_field(val, type_2) do info = [first_type: type_1, second_type: type_2, actual: inspect(val)] template = "expected either <%= first_type %> or <%= second_type %>, got: <%= actual %>" {:error, template, info} end end defp validate_field(val, {:oneof, types}) do types |> Enum.reduce_while(:error, fn type, :error -> case validate_field(val, type) do :ok -> {:halt, :ok} {:error, _reason, _info} -> {:cont, :error} end end) |> then(fn :ok -> :ok :error -> expected = Enum.map_join(types, " or ", &to_string/1) info = [oneof: expected, actual: inspect(val)] template = "expected one of <%= oneof %>, got: <%= actual %>" {:error, template, info} end) end defp validate_field(val, {:tuple, types}) when is_tuple(val) do if tuple_size(val) == length(types) do Enum.with_index(types) |> Enum.reduce_while(:ok, fn {type, index}, :ok -> case validate_field(elem(val, index), type) do :ok -> {:cont, :ok} {:error, reason, nested_info} -> info = [index: index] ++ nested_info {:halt, {:error, "tuple element <%= index %>: #{reason}", info}} end end) else info = [length: length(types), actual: length(Tuple.to_list(val))] template = "expected tuple of size <%= length %> received tuple wwith <%= actual %> length" {:error, template, info} end end defp validate_field(val, {:enum, choices}) do if to_string(val) in Enum.map(choices, &to_string/1) do :ok else info = [choices: inspect(choices, pretty: true), actual: inspect(val)] template = "expected one of <%= choices %> received <%= actual %>" {:error, template, info} end end defp validate_field(data, {:list, type}) when is_list(data) do Enum.reduce_while(data, :ok, fn el, :ok -> case validate_field(el, type) do :ok -> {:cont, :ok} {:error, errors} -> {:halt, {:error, errors}} {:error, reason, info} -> {:halt, {:error, reason, info}} end end) end defp validate_field(data, schema) when is_enumerable(data) do case traverse_schema(schema, data) do {[], _path} -> :ok {errors, _path} -> {:error, errors} end end defp validate_field(val, type) do info = [expected: type, actual: inspect(val, pretty: true)] {:error, "expected type of <%= expected %> received <%= actual %> value", info} end end