diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex new file mode 100644 index 0000000..38cf7a7 --- /dev/null +++ b/lib/parameter/engine.ex @@ -0,0 +1,593 @@ +defmodule Parameter.Engine do + @moduledoc """ + `Parameter.Engine` are the building blocks for the serializing and deserializing parameters. + `Parameter.load/3`, `Parameter.validate/3` and `Parameter.dump/3` functions are powered by the + functions of this module. The main `Parameter` API will be enough for most of the cases but for a more + declarative and custom approach, `Parameter.Engine` is highly recommended. + + Let's use `Parameter.Engine` for a given schema: + + defmodule MyApp.UserParams do + use Parameter.Schema + + param do + field :first_name, :string, required: true + field :last_name, :string, required: true + field :age, :integer, default: 0 + has_one :address, AddressParam, required: true do + field :street, :string, required: true + field :number, :integer + end + end + end + + This schema is straightforward to understand how it should behave when parsing but it's also very strict since it + doesn't allow any customization. For example imagine we want to use the same schema but with different parsing logic, + like in a Phoenix application where it's API have one endpoint where `first_name` is a required field but another + endpoint the same schema should have the `first_name` as an optional field. This is possible to do with the Runtime + Schemas by manually modifying a map schema to put required `true` or `false`. It would work but it's not the most + straightforward solution. `Parameter.Engine` helps by making it declarative how the schema should be parsed. + + ## Example + Considering the above example, we can make a more generic schema by dropping the required and default keys: + + defmodule MyApp.UserParams do + use Parameter.Schema + + alias Parameter.Engine + + param do + field :first_name, :string + field :last_name, :string + field :age, :integer + has_one :address, AddressParam do + field :street, :string + field :number, :integer + end + end + + def load(params) do + __MODULE__ + |> Engine.load_params(params) + |> Engine.validate_required([:first_name, :last_name]) + |> Engine.add_default(:age, 0) + |> Engine.load_nested_param(:address, &load_address/1) + |> Engine.load() + end + + defp load_address(params) do + __MODULE__.AddressParam + |> Engine.load_params(params) + |> Engine.validate_required([:street]) + |> Engine.load() + end + end + + + You can also customize a schema with the `required` or `default` options with the declarative approach and the `Parameter.Engine` + will use what's declared under the schema unless it's explicit set in the `Engine` to change the behaviour like + having conflicting `default` option in the schema and in the `Engine`. Parameter will favour what is declared in the `Engine` + when there is conflicting options. + + The example below shows the usage of the `MyApp.UserParams` in a Phoenix controller: + + defmodule MyAppWeb.UserController do + use MyAppWeb, :controller + + alias MyApp.UserParams + alias MyApp.Users + + def create(conn, %{"user" => user_params}) do + with {:ok, user_loaded_params} <- UserParams.load(user_params), + {:ok, user} <- Users.create(user_loaded_params) do + + json(conn + |> put_status(:created) + |> json(%{user: user}) + + else + {:error, %Ecto.Changeset{}} = error -> + # Changeset errors + error + {:error, reason} -> + # Parameter errors + {:error, reason} + end + end + end + + In case we need different ways for loading in different controllers, this is also possible by implementing + the `Engine` parsing logic in the module that requires thhe specific logic. + + """ + require Parameter.Schema + alias Parameter.Field + alias Parameter.Schema + alias Parameter.Types + + @type t :: %__MODULE__{ + schema: module() | nil, + fields: list(Field.t()), + valid?: boolean(), + data: map() | nil, + changes: map(), + errors: map(), + cast_fields: list(atom()), + operation: :load | :dump | :validate | nil + } + + defstruct schema: nil, + fields: [], + valid?: true, + data: nil, + changes: %{}, + errors: %{}, + cast_fields: [], + operation: nil + + defguard module_or_runtime(param) when is_atom(param) or is_map(param) + + @doc """ + Build compiles the schema and automatically fetch which fields will be casted during the + `load`, `dump` or `validate` functions. + + It can be used as the starting point for building your schema logic. + + ## Examples + + Using the given schema as example on how to load parameters and modifying the logic + to only load the `:first_name` field. + + defmodule UserSchema do + use Parameter.Schema + import Parameter.Engine + + param do + field :first_name, :string + field :last_name, :string + end + + def load(params \\ %{}) do + __MODULE__ + |> build() + |> cast_only([:first_name]) + |> load(params) + end + end + + """ + @spec build(module | map()) :: t() + def build(schema) when module_or_runtime(schema) do + compiled_schema = Schema.build!(schema) + fields = Schema.fields(compiled_schema) + + %__MODULE__{ + schema: Schema.module(schema), + fields: fields, + cast_fields: infer_cast_fields(fields) + } + end + + @spec cast_only(t(), list(atom() | tuple())) :: t() + def cast_only(%__MODULE__{} = engine, fields) when is_list(fields) do + cast_fields = select_valid_cast_fields(engine.fields, fields) + %__MODULE__{engine | cast_fields: cast_fields} + end + + @doc """ + ## Example + defmodule UserParams do + use Parameter.Schema + + import Parameter.Engine + + param do + field :first_name, :string + field :last_name, :string + end + + def load(params) do + UserParams + |> build() + |> load(params) + end + end + """ + @spec load(t() | module() | map(), map() | list(map()), Keyword.t()) :: t() + def load(engine, params, opts \\ []) + + def load(%__MODULE__{} = engine, params, opts) do + %__MODULE__{engine | data: params, operation: :load} + |> cast_and_load_params(opts) + end + + def load(schema, params, opts) when module_or_runtime(schema) do + schema + |> build() + |> load(params, opts) + end + + def apply_operation(%__MODULE__{} = engine) do + engine + |> case do + %__MODULE__{valid?: true} -> + {:ok, fetch_engine_changes(engine)} + + %__MODULE__{valid?: false} -> + {:error, fetch_engine_errors(engine)} + end + end + + defp fetch_engine_changes(%__MODULE__{changes: changes}) when is_map(changes) do + Enum.reduce(changes, changes, fn + {field_key, %__MODULE__{} = engine}, acc -> + Map.put(acc, field_key, fetch_engine_changes(engine)) + + {field_key, values}, acc when is_list(values) -> + Enum.map(values, fn + %__MODULE__{} = engine -> + fetch_engine_changes(engine) + + value -> + value + end) + |> then(fn list -> + Map.put(acc, field_key, list) + end) + + _, acc -> + acc + end) + end + + defp fetch_engine_errors(%__MODULE__{errors: errors, changes: changes}) do + Enum.reduce(changes, errors, fn + {field_key, values}, acc when is_list(values) -> + invalid_data = + values |> Enum.with_index() |> Enum.filter(fn {engine, _index} -> !engine.valid? end) + + if Enum.empty?(invalid_data) do + acc + else + invalid_data + |> Enum.map(fn {engine, index} -> %{index => engine.errors} end) + |> then(fn list -> + Map.put(acc, field_key, list) + end) + end + + {field_key, %__MODULE__{valid?: false} = engine}, acc -> + Map.merge(acc, %{field_key => fetch_engine_errors(engine)}) + + _, acc -> + acc + end) + end + + def add_change(%__MODULE__{changes: changes} = engine, field, change) do + %__MODULE__{engine | changes: Map.put(changes, field, change)} + end + + def get_change(%__MODULE__{changes: changes}, field) do + Map.get(changes, field) + end + + def add_error(%__MODULE__{errors: errors} = engine, field, error) do + %__MODULE__{engine | valid?: false, errors: Map.put(errors, field, error)} + end + + def operation(%__MODULE__{} = engine, operation) when operation in [:load, :dump, :validate] do + %__MODULE__{engine | operation: operation} + end + + defp cast_and_load_params( + %__MODULE__{ + fields: fields, + cast_fields: cast_fields + } = engine, + opts + ) do + fields_to_exclude = + fields + |> Enum.map(& &1.name) + |> Enum.reject(fn field -> field in cast_fields end) + + opts_with_fields_to_exclude = Keyword.merge(opts, exclude: fields_to_exclude) + cast_params(engine, opts_with_fields_to_exclude) + end + + defp cast_params(%__MODULE__{fields: fields, cast_fields: cast_fields} = engine, opts) do + Enum.reduce(cast_fields, engine, fn field_name, engine -> + field = Schema.get_field(fields, field_name) + fetch_and_verify_input(engine, field, opts) + end) + end + + defp infer_cast_fields(fields) do + Schema.field_names(fields) + end + + defp select_valid_cast_fields(schema, {nested_field_name, fields}) do + case Schema.get_field(schema, nested_field_name) do + %Field{type: {:map, nested_schema}} -> select_valid_cast_fields(nested_schema, fields) + %Field{type: {:array, nested_schema}} -> select_valid_cast_fields(nested_schema, fields) + _ -> [] + end + end + + defp select_valid_cast_fields(schema, fields) when is_list(fields) do + Enum.reduce(fields, [], fn + {field_name, _fields} = nested_field, acc -> + [{field_name, select_valid_cast_fields(schema, nested_field)} | acc] + + field_name, acc -> + if Schema.get_field(schema, field_name) do + [field_name | acc] + else + acc + end + end) + |> Enum.reverse() + end + + defp fetch_and_verify_input(engine, field, opts) do + case fetch_input(engine, field) do + :error -> + check_required(engine, field, :ignore) + + {:ok, nil} -> + check_nil(engine, field, opts) + + {:ok, ""} -> + check_empty(engine, field, opts) + + {:ok, value} -> + handle_field(engine, field, value, opts) + + {:error, reason} -> + add_error(engine, field.name, reason) + end + end + + defp fetch_input(%__MODULE__{data: data, operation: operation}, field) do + do_fetch_input(data, field, operation) + end + + defp do_fetch_input(data, field, :load = _operation) do + fetched_input = Map.fetch(data, field.key) + + if to_string(field.name) == field.key do + verify_double_key(fetched_input, field, data) + else + fetched_input + end + end + + defp do_fetch_input(data, field, _operation) do + Map.fetch(data, field.name) + end + + defp verify_double_key(:error, field, input) do + Map.fetch(input, field.name) + end + + defp verify_double_key(fetched_input, field, input) do + case Map.fetch(input, field.name) do + {:ok, _value} -> + {:error, "field is present as atom and string keys"} + + _ -> + fetched_input + end + end + + defp check_required( + %__MODULE__{operation: :load} = engine, + %Field{name: name, required: true, load_default: nil}, + value + ) + when value in [:ignore, nil] do + add_error(engine, name, "is required") + end + + defp check_required( + %__MODULE__{operation: :validate} = engine, + %Field{name: name, required: true, dump_default: nil}, + value + ) + when value in [:ignore, nil] do + add_error(engine, name, "is required") + end + + defp check_required( + %__MODULE__{operation: :load} = engine, + %Field{name: name, load_default: default}, + :ignore + ) + when not is_nil(default) do + add_change(engine, name, default) + end + + defp check_required( + %__MODULE__{operation: :dump} = engine, + %Field{name: name, dump_default: default}, + :ignore + ) + when not is_nil(default) do + add_change(engine, name, default) + end + + defp check_required(%__MODULE__{} = engine, _field, :ignore) do + engine + end + + defp check_required(%__MODULE__{} = engine, %Field{name: name}, value) do + add_change(engine, name, value) + end + + defp check_nil(engine, field, opts) do + if opts[:ignore_nil] do + check_required(engine, field, :ignore) + else + check_required(engine, field, nil) + end + end + + defp check_empty(engine, field, opts) do + if opts[:ignore_empty] do + check_required(engine, field, :ignore) + else + check_required(engine, field, "") + end + end + + defp handle_field(engine, %Field{virtual: true}, _value, _opts) do + engine + end + + defp handle_field(engine, %Field{type: {:array, _nested_fields}} = field, values, opts) + when is_list(values) do + do_load_assoc(engine, field, values, opts) + end + + defp handle_field(engine, %Field{name: name, type: {:array, _schema}}, _values, _opts) do + add_error(engine, name, "invalid array type") + end + + defp handle_field(engine, %Field{type: {:map, _nested_fields}} = field, value, opts) + when is_map(value) do + do_load_assoc(engine, field, value, opts) + end + + defp handle_field(engine, %Field{name: name, type: {:map, _schema}}, _values, _opts) do + add_error(engine, name, "invalid map type") + end + + defp handle_field( + %__MODULE__{operation: :load} = engine, + %Field{type: type} = field, + value, + _opts + ) do + case Types.load(type, value) do + {:error, error} -> + add_error(engine, field.name, error) + + {:ok, loaded_value} -> + add_change(engine, field.name, loaded_value) + end + end + + defp do_load_assoc( + %__MODULE__{operation: :load} = engine, + %Field{type: {:map, nested_fields}} = field, + value, + opts + ) do + schema = get_schema_from_nested_assoc(engine, field) + + %__MODULE__{ + schema: Schema.module(schema), + fields: nested_fields, + cast_fields: infer_cast_fields(nested_fields) + } + |> load(value, opts) + |> case do + %__MODULE__{valid?: false} = inner_engine -> + %__MODULE__{ + engine + | changes: Map.put(engine.changes, field.name, inner_engine), + valid?: false + } + + %__MODULE__{valid?: true} = inner_engine -> + add_change(engine, field.name, inner_engine) + end + end + + defp do_load_assoc( + %__MODULE__{operation: :load} = engine, + %Field{type: {:array, nested_fields}} = field, + values, + opts + ) do + schema = get_schema_from_nested_assoc(engine, field) + + values + |> Enum.reverse() + |> Enum.reduce(engine, fn value, engine -> + %__MODULE__{ + schema: schema, + fields: nested_fields, + cast_fields: infer_cast_fields(nested_fields) + } + |> load(value, opts) + |> case do + %__MODULE__{valid?: false} = inner_engine -> + field_changes = get_change(engine, field.name) || [] + engine = add_change(engine, field.name, [inner_engine | field_changes]) + %__MODULE__{engine | valid?: false} + + %__MODULE__{valid?: true} = inner_engine -> + field_changes = get_change(engine, field.name) || [] + add_change(engine, field.name, [inner_engine | field_changes]) + end + end) + end + + defp get_schema_from_nested_assoc(engine, field) do + if runtime_schema = engine.schema && Schema.runtime_schema(engine.schema) do + {_nested, schema} = Map.get(runtime_schema, field.name) |> Keyword.get(:type) + schema + end + end +end + +defimpl Inspect, for: Parameter.Engine do + import Inspect.Algebra + + def inspect(engine, opts) do + list = + for attr <- [:schema, :fields, :cast_fields, :changes, :errors, :data, :valid?] do + {attr, Map.get(engine, attr)} + end + + container_doc("#Parameter.Engine<", list, ">", opts, fn + {:schema, schema}, opts -> + concat("schema: ", to_doc(schema, opts)) + + {:fields, fields}, opts -> + concat("fields: ", fields(fields, opts)) + + {:cast_fields, cast_fields}, opts -> + concat("cast_fields: ", to_doc(cast_fields, opts)) + + {:changes, changes}, opts -> + concat("changes: ", to_doc(changes, opts)) + + {:data, data}, _opts -> + concat("data: ", to_doc(data, opts)) + + {:errors, errors}, opts -> + concat("errors: ", to_doc(errors, opts)) + + {:valid?, valid?}, opts -> + concat("valid?: ", to_doc(valid?, opts)) + end) + end + + # defp to_struct(%{__struct__: struct}, _opts), do: "#" <> Kernel.inspect(struct) <> "<>" + # defp to_struct(other, opts), do: to_doc(other, opts) + + defp fields(fields, opts) when is_list(fields) do + Enum.reduce(fields, [], fn %Parameter.Field{name: name}, acc -> + [name | acc] + end) + |> Enum.reverse() + |> to_doc(opts) + end + + defp fields(module, opts) do + to_doc(module, opts) + end +end diff --git a/lib/parameter/schema.ex b/lib/parameter/schema.ex index 5ed17ba..196c949 100644 --- a/lib/parameter/schema.ex +++ b/lib/parameter/schema.ex @@ -187,6 +187,8 @@ defmodule Parameter.Schema do {:ok, %{"level" => 0}} """ + alias Parameter.Field + alias Parameter.Schema.Builder alias Parameter.Schema.Compiler alias Parameter.Types @@ -318,6 +320,7 @@ defmodule Parameter.Schema do end defdelegate compile!(opts), to: Compiler, as: :compile_schema! + defdelegate build!(opts), to: Builder, as: :build! defp schema(caller, block) do precompile = @@ -336,14 +339,14 @@ defmodule Parameter.Schema do compile = quote do raw_params = Module.get_attribute(__MODULE__, :param_raw_fields) - Module.put_attribute(__MODULE__, :param_fields, Parameter.Schema.compile!(raw_params)) + Module.put_attribute(__MODULE__, :param_fields, Parameter.Schema.build!(raw_params)) end postcompile = quote unquote: false do defstruct Enum.reverse(@param_struct_fields) - def __param__(:fields), do: Enum.reverse(@param_fields) + def __param__(:fields), do: @param_fields def __param__(:field_names) do Enum.map(__param__(:fields), & &1.name) @@ -360,6 +363,8 @@ defmodule Parameter.Schema do def __param__(:field, name: name) do Enum.find(__param__(:fields), &(&1.name == name)) end + + def __param__(:runtime_schema), do: @param_raw_fields end quote do @@ -369,14 +374,41 @@ defmodule Parameter.Schema do end end + def module(module) when is_atom(module) do + module + end + + def module(_fields) do + nil + end + def fields(module) when is_atom(module) do module.__param__(:fields) + rescue + _error -> + module end - def fields(fields) when is_list(fields) do + def fields(fields) do fields end + def get_field(module_or_fields, field_name) do + module_or_fields + |> fields() + |> Enum.find(&(&1.name == field_name)) + end + + def assoc_fields(module_or_fields) do + module_or_fields + |> fields() + |> Enum.filter(fn + %Field{type: {:array, _schema}} -> true + %Field{type: {:map, _schema}} -> true + _ -> false + end) + end + def field_keys(module) when is_atom(module) do module.__param__(:field_keys) end @@ -393,6 +425,18 @@ defmodule Parameter.Schema do Enum.find(fields, &(&1.key == key)) end + def field_names(module) when is_atom(module) do + module.__param__(:field_names) + end + + def field_names(fields) when is_list(fields) do + Enum.map(fields, & &1.name) + end + + def runtime_schema(module) when is_atom(module) do + module.__param__(:runtime_schema) + end + def __mount_nested_schema__(module_name, env, block) do block = quote do diff --git a/lib/parameter/schema/builder.ex b/lib/parameter/schema/builder.ex new file mode 100644 index 0000000..7e673ae --- /dev/null +++ b/lib/parameter/schema/builder.ex @@ -0,0 +1,75 @@ +defmodule Parameter.Schema.Builder do + @moduledoc false + alias Parameter.Field + alias Parameter.Types + + def build!(schema) when is_map(schema) do + for {name, opts} <- schema do + {type, opts} = Keyword.pop(opts, :type, :string) + type = compile_type!(type) + + field = Field.new!([name: name, type: type] ++ opts) + + case validate_default(field) do + :ok -> field + {:error, reason} -> raise ArgumentError, message: inspect(reason) + end + end + end + + def build!(schema) when is_atom(schema) or is_list(schema) do + Parameter.Schema.fields(schema) + end + + defp compile_type!({type, schema}) when is_tuple(schema) do + {type, compile_type!(schema)} + end + + defp compile_type!({type, schema}) do + if Types.composite_type?(type) do + {type, build!(schema)} + else + raise ArgumentError, + message: + "not a valid inner type, please use `{map, inner_type}` or `{array, inner_type}` for nested associations" + end + end + + defp compile_type!(type) when is_atom(type) do + type + end + + defp validate_default( + %Field{default: default, load_default: load_default, dump_default: dump_default} = field + ) do + with :ok <- validate_default(field, default), + :ok <- validate_default(field, load_default), + do: validate_default(field, dump_default) + end + + defp validate_default(_field, nil) do + :ok + end + + defp validate_default(%Field{name: name} = field, default_value) do + Parameter.validate([field], %{name => default_value}) + end + + def validate_nested_opts!(opts) do + keys = Keyword.keys(opts) + + if :validator in keys do + raise ArgumentError, "validator cannot be used on nested fields" + end + + if :on_load in keys do + raise ArgumentError, "on_load cannot be used on nested fields" + end + + if :on_dump in keys do + raise ArgumentError, "on_dump cannot be used on nested fields" + end + + opts + end +end diff --git a/mix.exs b/mix.exs index 6c36d0e..aaadd30 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule Parameter.MixProject do app: :parameter, version: @version, elixir: "~> 1.13", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), @@ -36,6 +37,9 @@ defmodule Parameter.MixProject do ] end + defp elixirc_paths(env) when env in [:test], do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp deps do [ {:decimal, "~> 2.0", optional: true}, diff --git a/test/parameter/engine_test.exs b/test/parameter/engine_test.exs new file mode 100644 index 0000000..ff36201 --- /dev/null +++ b/test/parameter/engine_test.exs @@ -0,0 +1,186 @@ +defmodule Parameter.EngineTest do + use ExUnit.Case + + alias Parameter.Engine + alias Parameter.Schema + alias Parameter.Factory.NestedSchema + alias Parameter.Factory.SimpleSchema + + describe "build/1" do + test "build %Engine{} with runtime or compile time schemas" do + field_names = Schema.field_names(SimpleSchema) + fields = Schema.fields(SimpleSchema) + + assert %Engine{ + schema: SimpleSchema, + cast_fields: field_names, + fields: fields + } == Engine.build(SimpleSchema) + + assert %Engine{ + schema: nil, + cast_fields: field_names, + fields: fields + } == Engine.build(Schema.runtime_schema(SimpleSchema)) + end + + test "create engine with only a few fields" do + engine = Engine.build(SimpleSchema) + + assert %Engine{cast_fields: [:first_name, :last_name]} = + Engine.cast_only(engine, [:first_name, :last_name]) + + assert %Engine{cast_fields: [:age]} = Engine.cast_only(engine, [:age]) + assert %Engine{cast_fields: []} = Engine.cast_only(engine, [:not_a_field]) + end + end + + describe "load/3" do + import Parameter.Engine + + test "load fields in a simple schema" do + field_names = Schema.field_names(SimpleSchema) + fields = Schema.fields(SimpleSchema) + params = %{"firstName" => "John", "lastName" => "Doe", "age" => "40"} + + assert %Engine{ + schema: SimpleSchema, + changes: %{first_name: "John", last_name: "Doe", age: 40}, + data: params, + cast_fields: field_names, + fields: fields, + operation: :load + } == + SimpleSchema + |> build() + |> load(params) + end + + test "filtering fields on simple schema" do + fields = Schema.fields(SimpleSchema) + params = %{"firstName" => "John", "lastName" => "Doe", "age" => "40"} + + assert %Engine{ + schema: SimpleSchema, + changes: %{first_name: "John", last_name: "Doe"}, + data: params, + cast_fields: [:first_name, :last_name], + fields: fields, + operation: :load + } == + SimpleSchema + |> build() + |> cast_only([:first_name, :last_name]) + |> load(params) + end + + test "load fields in a nested schema" do + field_names = Schema.field_names(NestedSchema) + fields = Schema.fields(NestedSchema) + + params = %{ + "addresses" => [ + %{"street" => "some street", "number" => 4, "state" => "state"} + ], + "phone" => %{"code" => 1, "number" => "123123"} + } + + assert %Engine{ + schema: NestedSchema, + changes: %{ + addresses: [ + %Engine{ + schema: NestedSchema.Address, + changes: %{street: "some street", number: 4, state: "state"}, + data: %{"street" => "some street", "number" => 4, "state" => "state"}, + cast_fields: [:state, :number, :street], + fields: Schema.fields(NestedSchema.Address), + operation: :load + } + ], + phone: %Engine{ + schema: NestedSchema.Phone, + changes: %{code: "1", number: "123123"}, + data: %{"code" => 1, "number" => "123123"}, + cast_fields: [:code, :number], + fields: Schema.fields(NestedSchema.Phone), + operation: :load + } + }, + data: params, + cast_fields: field_names, + fields: fields, + operation: :load + } == + NestedSchema + |> build() + |> load(params) + end + end + + describe "apply_operation/1" do + import Parameter.Engine + + test "load operation on SimpleSchema" do + params = %{"firstName" => "John", "lastName" => "Doe", "age" => "22"} + + assert {:ok, %{first_name: "John", last_name: "Doe", age: 22}} == + SimpleSchema + |> load(params) + |> apply_operation() + end + + test "validate on SimpleSchema" do + params = %{"lastName" => "Doe", "age" => "22a"} + + assert {:error, %{first_name: "is required", age: "invalid integer type"}} == + SimpleSchema + |> load(params) + |> apply_operation() + end + + test "load operation on NestedSchema" do + params = %{ + "addresses" => [ + %{"street" => "some street", "number" => 1, "state" => "some state"}, + %{"street" => "other street", "number" => 5, "state" => "other state"} + ], + "phone" => %{"code" => 55, "number" => "123555"} + } + + assert {:ok, + %{ + addresses: [ + %{state: "some state", number: 1, street: "some street"}, + %{state: "other state", number: 5, street: "other street"} + ], + phone: %{code: "55", number: "123555"} + }} == + NestedSchema + |> load(params) + |> apply_operation() + end + + test "validate on NestedSchema" do + params = %{ + "addresses" => [ + %{"street" => "some street", "number" => "1A"}, + %{"number" => 5, "state" => "other state"} + ], + "phone" => %{"code" => 55} + } + + assert {:error, + %{ + addresses: [ + %{0 => %{number: "invalid integer type"}}, + %{1 => %{street: "is required"}} + ], + phone: %{number: "is required"} + }} == + NestedSchema + |> load(params) + |> apply_operation() + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000..dcee241 --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,5 @@ +defmodule Parameter.Factory do + @moduledoc """ + Factory module to create data in tests. + """ +end diff --git a/test/support/schemas/nested_schema.ex b/test/support/schemas/nested_schema.ex new file mode 100644 index 0000000..5da6914 --- /dev/null +++ b/test/support/schemas/nested_schema.ex @@ -0,0 +1,16 @@ +defmodule Parameter.Factory.NestedSchema do + use Parameter.Schema + + param do + has_many :addresses, Address, required: true do + field :street, :string, required: true + field :number, :integer, default: 0 + field :state, :string + end + + has_one :phone, Phone do + field :code, :string + field :number, :string, required: true + end + end +end diff --git a/test/support/schemas/simple_schema.ex b/test/support/schemas/simple_schema.ex new file mode 100644 index 0000000..0127167 --- /dev/null +++ b/test/support/schemas/simple_schema.ex @@ -0,0 +1,9 @@ +defmodule Parameter.Factory.SimpleSchema do + use Parameter.Schema + + param do + field :first_name, :string, key: "firstName", required: true + field :last_name, :string, key: "lastName" + field :age, :integer, default: 0 + end +end