diff --git a/.formatter.exs b/.formatter.exs index 661cf98..01effc0 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,7 +1,8 @@ locals_without_parens = [ + data: 1, param: 1, param: 2, - data: 1, + param: 3, pipeline: 1 ] diff --git a/lib/commandex.ex b/lib/commandex.ex index 5d7965b..7413d18 100644 --- a/lib/commandex.ex +++ b/lib/commandex.ex @@ -13,8 +13,8 @@ defmodule Commandex do import Commandex command do - param :email - param :password + param :email, :string, required: true + param :password, :string, required: true data :password_hash data :user @@ -53,12 +53,15 @@ defmodule Commandex do The `command/1` macro will define a struct that looks like: %RegisterUser{ + __meta__: %{ + params: %{email: {:string, [required: true]}, password: {:string, [required: true]}}, + pipelines: [:hash_password, :create_user, :send_welcome_email] + }, success: false, halted: false, errors: %{}, params: %{email: nil, password: nil}, - data: %{password_hash: nil, user: nil}, - pipelines: [:hash_password, :create_user, :send_welcome_email] + data: %{password_hash: nil, user: nil} } As well as two functions: @@ -66,8 +69,11 @@ defmodule Commandex do &RegisterUser.new/1 &RegisterUser.run/1 - `&new/1` parses parameters into a new struct. These can be either a keyword list - or map with atom/string keys. + `&new/1` parses and casts parameters into a new struct. These can be either a + keyword list or map with atom/string keys. If a parameter has a type declared, + the value will be cast to that type. If the cast fails, the parameter is set to + `nil` and an `:invalid` error is added. If a parameter is marked as `required: true` + and its value is `nil` after casting, a `:required` error is added. `&run/1` takes a command struct and runs it through the pipeline functions defined in the command. **Functions are executed in the order in which they are defined**. @@ -94,15 +100,29 @@ defmodule Commandex do If a command does not have any parameters defined, a `run/0` will be generated automatically. Useful for diagnostic jobs and internal tasks. - iex> GenerateReport.run() - %GenerateReport{ - pipelines: [:fetch_data, :calculate_results], - data: %{total_valid: 183220, total_invalid: 781215}, - params: %{}, - halted: false, - errors: %{}, - success: true - } + iex> command = GenerateReport.run() + iex> command.success + true + iex> command.data.total_valid + 183220 + iex> command.data.total_invalid + 781215 + + ## Typed Parameters + + Parameters can declare a type for automatic casting: + + param :age, :integer + param :email, :string, required: true + param :score, :float, default: 0.0 + + Built-in types: `:string`, `:integer`, `:float`, `:boolean`, `:any`. + Use `{:array, type}` for lists: `param :tags, {:array, :string}`. + + Custom type modules implementing the `Commandex.Type` behaviour (or any module + with a compatible `cast/1` function, such as an Ecto type) can also be used: + + param :color, MyApp.Types.Color """ @typedoc """ @@ -129,21 +149,21 @@ defmodule Commandex do ## Attributes + - `__meta__` - Compile-time schema containing param types and pipeline definitions. - `data` - Data generated during the pipeline, defined by `Commandex.data/1`. - `errors` - Errors generated during the pipeline with `Commandex.put_error/3` - `halted` - Whether or not the pipeline was halted. - `params` - Parameters given to the command, defined by `Commandex.param/1`. - - `pipelines` - A list of pipeline functions to execute, defined by `Commandex.pipeline/1`. - `success` - Whether or not the command was successful. This is only set to `true` if the command was not halted after running all of the pipelines. """ @type command :: %{ __struct__: atom(), + __meta__: map(), data: map(), errors: map(), halted: boolean(), params: map(), - pipelines: [pipeline()], success: boolean() } @@ -173,34 +193,58 @@ defmodule Commandex do postlude = quote unquote: false do - params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair + raw_params = Module.get_attribute(__MODULE__, :params) + + param_defaults = + for {name, {_type, opts}} <- raw_params, into: %{} do + {name, Keyword.get(opts, :default)} + end + + param_schema = + for {name, {type, opts}} <- raw_params, into: %{} do + {name, {type, Keyword.delete(opts, :default)}} + end + data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair + pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse() - Module.put_attribute(__MODULE__, :struct_fields, {:params, params}) + meta = %{params: param_schema, pipelines: pipelines} + + Module.put_attribute(__MODULE__, :struct_fields, {:__meta__, meta}) + Module.put_attribute(__MODULE__, :struct_fields, {:params, param_defaults}) Module.put_attribute(__MODULE__, :struct_fields, {:data, data}) - Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines}) defstruct @struct_fields + param_type_specs = + for {name, {type, _opts}} <- raw_params do + {name, Commandex.type_to_spec(type)} + end + + data_type_specs = + for {name, _} <- Module.get_attribute(__MODULE__, :data) do + {name, quote(do: term())} + end + @typedoc """ Command struct. ## Attributes + - `__meta__` - Compile-time schema containing param types and pipeline definitions. - `data` - Data generated during the pipeline, defined by `Commandex.data/1`. - `errors` - Errors generated during the pipeline with `Commandex.put_error/3` - `halted` - Whether or not the pipeline was halted. - `params` - Parameters given to the command, defined by `Commandex.param/1`. - - `pipelines` - A list of pipeline functions to execute, defined by `Commandex.pipeline/1`. - `success` - Whether or not the command was successful. This is only set to `true` if the command was not halted after running all of the pipelines. """ @type t :: %__MODULE__{ - data: map(), + __meta__: map(), + data: %{unquote_splicing(data_type_specs)}, errors: map(), halted: boolean(), - params: map(), - pipelines: [Commandex.pipeline()], + params: %{unquote_splicing(param_type_specs)}, success: boolean() } @@ -209,10 +253,10 @@ defmodule Commandex do """ @spec new(map() | Keyword.t()) :: t() def new(opts \\ []) do - Commandex.parse_params(%__MODULE__{}, opts) + Commandex.Parameter.cast_params(%__MODULE__{}, opts) end - if Enum.empty?(params) do + if Enum.empty?(param_defaults) do @doc """ Runs given pipelines in order and returns command struct. """ @@ -229,15 +273,10 @@ defmodule Commandex do or the command struct itself. """ @spec run(map() | Keyword.t() | t()) :: t() - def run(%unquote(__MODULE__){pipelines: pipelines} = command) do - pipelines - |> Enum.reduce_while(command, fn fun, acc -> - case acc do - %{halted: false} -> {:cont, Commandex.apply_fun(acc, fun)} - _ -> {:halt, acc} - end - end) - |> Commandex.maybe_mark_successful() + def run(%unquote(__MODULE__){__meta__: %{pipelines: pipelines}} = command) do + command + |> Commandex.halt_on_errors() + |> Commandex.run_pipelines(pipelines) end def run(params) do @@ -256,18 +295,37 @@ defmodule Commandex do Parameters are supplied at struct creation, before any pipelines are run. + ## Untyped + command do param :email - param :password + param :name, default: "Anonymous" + end - # ...data - # ...pipelines + ## Typed + + Typed parameters are automatically cast in `new/1`: + + command do + param :email, :string, required: true + param :age, :integer + param :score, :float, default: 0.0 + param :tags, {:array, :string} + param :color, MyApp.Types.Color end + + Built-in types: `:string`, `:integer`, `:float`, `:boolean`, `:any`. + + ## Options + + - `:default` - Default value if not provided. Defaults to `nil`. + - `:required` - If `true`, adds a `:required` error when the value is `nil` + after casting. """ - @spec param(atom(), Keyword.t()) :: no_return() - defmacro param(name, opts \\ []) do + @spec param(atom(), atom() | {:array, atom()} | Keyword.t(), Keyword.t()) :: no_return() + defmacro param(name, type_or_opts \\ :any, opts \\ []) do quote do - Commandex.__param__(__MODULE__, unquote(name), unquote(opts)) + Commandex.__param__(__MODULE__, unquote(name), unquote(type_or_opts), unquote(opts)) end end @@ -389,23 +447,48 @@ defmodule Commandex do %{command | halted: true, success: success} end - @doc false - @spec maybe_mark_successful(command()) :: command() - def maybe_mark_successful(%{halted: false} = command), do: %{command | success: true} - def maybe_mark_successful(command), do: command + @doc """ + Halts the command if any errors are present. + + This is automatically called before pipelines run to catch any casting + or required validation errors from `new/1`. It can also be used explicitly + as a pipeline gate to aggregate custom validation errors before continuing. + + command do + param :email, :string, required: true + param :age, :integer + + pipeline :validate_age + pipeline &Commandex.halt_on_errors/1 + pipeline :create_user + end + """ + @spec halt_on_errors(command()) :: command() + def halt_on_errors(%{errors: errors} = command) when errors == %{}, do: command + def halt_on_errors(command), do: halt(command) @doc false - @spec parse_params(command(), map() | Keyword.t()) :: command() - def parse_params(%{params: p} = struct, params) when is_list(params) do - params = for {key, _} <- p, into: %{}, do: {key, Keyword.get(params, key, p[key])} - %{struct | params: params} + @spec run_pipelines(command(), [pipeline()]) :: command() + def run_pipelines(%{halted: true} = command, _pipelines) do + command end - def parse_params(%{params: p} = struct, %{} = params) do - params = for {key, _} <- p, into: %{}, do: {key, get_param(params, key, p[key])} - %{struct | params: params} + def run_pipelines(command, pipelines) do + pipelines + |> Enum.reduce_while(command, fn fun, acc -> + case acc do + %{halted: false} -> {:cont, apply_fun(acc, fun)} + _ -> {:halt, acc} + end + end) + |> maybe_mark_successful() end + @doc false + @spec maybe_mark_successful(command()) :: command() + def maybe_mark_successful(%{halted: false} = command), do: %{command | success: true} + def maybe_mark_successful(command), do: command + @doc false @spec apply_fun(command(), pipeline()) :: command() def apply_fun(%mod{params: params, data: data} = command, name) when is_atom(name) do @@ -429,16 +512,21 @@ defmodule Commandex do end @doc false - @spec __param__(module(), atom(), Keyword.t()) :: :ok - def __param__(mod, name, opts) do + @spec __param__(module(), atom(), atom() | {:array, atom()} | Keyword.t(), Keyword.t()) :: :ok + def __param__(mod, name, type_or_opts, opts) + + def __param__(mod, name, opts, []) when is_list(opts) do + __param__(mod, name, :any, opts) + end + + def __param__(mod, name, type, opts) do params = Module.get_attribute(mod, :params) if List.keyfind(params, name, 0) do raise ArgumentError, "param #{inspect(name)} is already set on command" end - default = Keyword.get(opts, :default) - Module.put_attribute(mod, :params, {name, default}) + Module.put_attribute(mod, :params, {name, {type, opts}}) end @doc false @@ -479,14 +567,31 @@ defmodule Commandex do raise ArgumentError, "pipeline #{inspect(name)} is not valid" end - @spec get_param(map(), atom(), term()) :: term() - defp get_param(params, key, default) do - case Map.get(params, key) do - nil -> - Map.get(params, to_string(key), default) + @doc false + @spec type_to_spec(atom() | {:array, atom()}) :: Macro.t() + def type_to_spec(:any), do: quote(do: term()) + def type_to_spec(:string), do: quote(do: String.t() | nil) + def type_to_spec(:integer), do: quote(do: integer() | nil) + def type_to_spec(:float), do: quote(do: float() | nil) + def type_to_spec(:boolean), do: quote(do: boolean() | nil) + + def type_to_spec({:array, inner_type}) do + inner = type_to_spec_inner(inner_type) + quote(do: [unquote(inner)] | nil) + end - val -> - val - end + def type_to_spec(module) when is_atom(module) do + quote(do: unquote(module).t() | nil) + end + + @spec type_to_spec_inner(atom()) :: Macro.t() + defp type_to_spec_inner(:any), do: quote(do: term()) + defp type_to_spec_inner(:string), do: quote(do: String.t()) + defp type_to_spec_inner(:integer), do: quote(do: integer()) + defp type_to_spec_inner(:float), do: quote(do: float()) + defp type_to_spec_inner(:boolean), do: quote(do: boolean()) + + defp type_to_spec_inner(module) when is_atom(module) do + quote(do: unquote(module).t()) end end diff --git a/lib/commandex/parameter.ex b/lib/commandex/parameter.ex new file mode 100644 index 0000000..b5d507a --- /dev/null +++ b/lib/commandex/parameter.ex @@ -0,0 +1,52 @@ +defmodule Commandex.Parameter do + @moduledoc false + + @spec cast_params(struct(), map() | Keyword.t()) :: struct() + def cast_params(command, input) when is_list(input) do + cast_params(command, Enum.into(input, %{})) + end + + def cast_params(%{__meta__: %{params: schema}, params: defaults} = command, %{} = input) do + Enum.reduce(schema, command, fn {key, {type, opts}}, acc -> + default = Map.get(defaults, key) + raw = get_param(input, key, default) + + acc + |> cast_value(key, raw, type) + |> check_required(key, opts) + end) + end + + @spec get_param(map(), atom(), term()) :: term() + defp get_param(params, key, default) do + case Map.get(params, key) do + nil -> Map.get(params, to_string(key), default) + val -> val + end + end + + @spec cast_value(struct(), atom(), term(), atom() | {:array, atom()}) :: struct() + defp cast_value(command, key, raw, type) do + case Commandex.Type.cast(raw, type) do + {:ok, cast_value} -> + put_in(command, [Access.key!(:params), Access.key!(key)], cast_value) + + :error -> + command + |> put_in([Access.key!(:params), Access.key!(key)], nil) + |> Commandex.put_error(key, :invalid) + end + end + + @spec check_required(struct(), atom(), Keyword.t()) :: struct() + defp check_required(command, key, opts) do + if Keyword.get(opts, :required, false) and not Map.has_key?(command.errors, key) do + case get_in(command, [Access.key!(:params), Access.key!(key)]) do + nil -> Commandex.put_error(command, key, :required) + _ -> command + end + else + command + end + end +end diff --git a/lib/commandex/type.ex b/lib/commandex/type.ex new file mode 100644 index 0000000..52fdaf3 --- /dev/null +++ b/lib/commandex/type.ex @@ -0,0 +1,67 @@ +defmodule Commandex.Type do + @moduledoc """ + Behaviour for custom Commandex types. + + A type module must implement the `cast/1` callback, which converts a raw + input value into the expected type. + + ## Example + + defmodule MyApp.Types.Color do + @behaviour Commandex.Type + + @type t :: %{r: integer(), g: integer(), b: integer(), a: float()} + + @impl true + def cast(%{r: r, g: g, b: b, a: a} = color) + when is_integer(r) and is_integer(g) and is_integer(b) and is_float(a) do + {:ok, color} + end + + def cast(_), do: :error + end + + The `cast/1` callback should not handle `nil` -- nil is handled centrally + by `Commandex.Type.cast/2` before delegating to individual type modules. + + ## Compatible with Ecto.Type + + Any module implementing `Ecto.Type`'s `cast/1` callback (returning + `{:ok, value} | :error`) is compatible as a Commandex type. + """ + + @callback cast(term()) :: {:ok, term()} | :error + + @doc """ + Casts a value to the given type. + + Returns `{:ok, cast_value}` on success or `:error` on failure. + `nil` always returns `{:ok, nil}` regardless of type. + """ + @spec cast(term(), atom() | {:array, atom()}) :: {:ok, term()} | :error + def cast(nil, _type), do: {:ok, nil} + def cast(value, :any), do: {:ok, value} + def cast(value, :boolean), do: Commandex.Type.Boolean.cast(value) + def cast(value, :float), do: Commandex.Type.Float.cast(value) + def cast(value, :integer), do: Commandex.Type.Integer.cast(value) + def cast(value, :string), do: Commandex.Type.String.cast(value) + def cast(value, {:array, type}), do: cast_array(value, type) + def cast(value, module) when is_atom(module), do: module.cast(value) + + @spec cast_array(term(), atom()) :: {:ok, [term()]} | :error + defp cast_array(value, type) when is_list(value) do + value + |> Enum.reduce_while({:ok, []}, fn element, {:ok, acc} -> + case cast(element, type) do + {:ok, cast_value} -> {:cont, {:ok, [cast_value | acc]}} + :error -> {:halt, :error} + end + end) + |> case do + {:ok, acc} -> {:ok, Enum.reverse(acc)} + :error -> :error + end + end + + defp cast_array(_value, _type), do: :error +end diff --git a/lib/commandex/type/boolean.ex b/lib/commandex/type/boolean.ex new file mode 100644 index 0000000..46031c0 --- /dev/null +++ b/lib/commandex/type/boolean.ex @@ -0,0 +1,20 @@ +defmodule Commandex.Type.Boolean do + @moduledoc """ + Casts values to booleans. + """ + + @behaviour Commandex.Type + + @type t :: boolean() + + @impl true + @spec cast(term()) :: {:ok, boolean()} | :error + def cast(value) when is_boolean(value), do: {:ok, value} + def cast("true"), do: {:ok, true} + def cast("false"), do: {:ok, false} + def cast("1"), do: {:ok, true} + def cast("0"), do: {:ok, false} + def cast(1), do: {:ok, true} + def cast(0), do: {:ok, false} + def cast(_), do: :error +end diff --git a/lib/commandex/type/float.ex b/lib/commandex/type/float.ex new file mode 100644 index 0000000..ccd18c8 --- /dev/null +++ b/lib/commandex/type/float.ex @@ -0,0 +1,23 @@ +defmodule Commandex.Type.Float do + @moduledoc """ + Casts values to floats. + """ + + @behaviour Commandex.Type + + @type t :: float() + + @impl true + @spec cast(term()) :: {:ok, float()} | :error + def cast(value) when is_float(value), do: {:ok, value} + def cast(value) when is_integer(value), do: {:ok, value / 1} + + def cast(value) when is_binary(value) do + case Float.parse(value) do + {float, ""} -> {:ok, float} + _ -> :error + end + end + + def cast(_), do: :error +end diff --git a/lib/commandex/type/integer.ex b/lib/commandex/type/integer.ex new file mode 100644 index 0000000..023d574 --- /dev/null +++ b/lib/commandex/type/integer.ex @@ -0,0 +1,23 @@ +defmodule Commandex.Type.Integer do + @moduledoc """ + Casts values to integers. + """ + + @behaviour Commandex.Type + + @type t :: integer() + + @impl true + @spec cast(term()) :: {:ok, integer()} | :error + def cast(value) when is_integer(value), do: {:ok, value} + + def cast(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> :error + end + end + + def cast(value) when is_float(value), do: {:ok, trunc(value)} + def cast(_), do: :error +end diff --git a/lib/commandex/type/string.ex b/lib/commandex/type/string.ex new file mode 100644 index 0000000..075847f --- /dev/null +++ b/lib/commandex/type/string.ex @@ -0,0 +1,17 @@ +defmodule Commandex.Type.String do + @moduledoc """ + Casts values to strings. + """ + + @behaviour Commandex.Type + + @type t :: String.t() + + @impl true + @spec cast(term()) :: {:ok, String.t()} | :error + def cast(value) when is_binary(value), do: {:ok, value} + def cast(value) when is_atom(value), do: {:ok, Atom.to_string(value)} + def cast(value) when is_integer(value), do: {:ok, Integer.to_string(value)} + def cast(value) when is_float(value), do: {:ok, Float.to_string(value)} + def cast(_), do: :error +end diff --git a/test/commandex/type/boolean_test.exs b/test/commandex/type/boolean_test.exs new file mode 100644 index 0000000..0ab87d3 --- /dev/null +++ b/test/commandex/type/boolean_test.exs @@ -0,0 +1,32 @@ +defmodule Commandex.Type.BooleanTest do + use ExUnit.Case + + alias Commandex.Type.Boolean, as: BooleanType + + test "casts booleans" do + assert BooleanType.cast(true) == {:ok, true} + assert BooleanType.cast(false) == {:ok, false} + end + + test "casts string booleans" do + assert BooleanType.cast("true") == {:ok, true} + assert BooleanType.cast("false") == {:ok, false} + end + + test "casts numeric booleans" do + assert BooleanType.cast(1) == {:ok, true} + assert BooleanType.cast(0) == {:ok, false} + assert BooleanType.cast("1") == {:ok, true} + assert BooleanType.cast("0") == {:ok, false} + end + + test "casts atom booleans" do + assert BooleanType.cast(true) == {:ok, true} + assert BooleanType.cast(false) == {:ok, false} + end + + test "rejects other values" do + assert BooleanType.cast("yes") == :error + assert BooleanType.cast(2) == :error + end +end diff --git a/test/commandex/type/float_test.exs b/test/commandex/type/float_test.exs new file mode 100644 index 0000000..387b1db --- /dev/null +++ b/test/commandex/type/float_test.exs @@ -0,0 +1,27 @@ +defmodule Commandex.Type.FloatTest do + use ExUnit.Case + + alias Commandex.Type.Float, as: FloatType + + test "casts floats" do + assert FloatType.cast(3.14) == {:ok, 3.14} + end + + test "casts integers to floats" do + assert FloatType.cast(42) == {:ok, 42.0} + end + + test "casts string floats" do + assert FloatType.cast("3.14") == {:ok, 3.14} + end + + test "rejects non-numeric strings" do + assert FloatType.cast("abc") == :error + assert FloatType.cast("3.14abc") == :error + end + + test "rejects other types" do + assert FloatType.cast(:atom) == :error + assert FloatType.cast([1.0]) == :error + end +end diff --git a/test/commandex/type/integer_test.exs b/test/commandex/type/integer_test.exs new file mode 100644 index 0000000..dc6a1b7 --- /dev/null +++ b/test/commandex/type/integer_test.exs @@ -0,0 +1,28 @@ +defmodule Commandex.Type.IntegerTest do + use ExUnit.Case + + alias Commandex.Type.Integer, as: IntegerType + + test "casts integers" do + assert IntegerType.cast(42) == {:ok, 42} + end + + test "casts string integers" do + assert IntegerType.cast("42") == {:ok, 42} + assert IntegerType.cast("-10") == {:ok, -10} + end + + test "casts floats by truncating" do + assert IntegerType.cast(3.9) == {:ok, 3} + end + + test "rejects non-numeric strings" do + assert IntegerType.cast("abc") == :error + assert IntegerType.cast("12abc") == :error + end + + test "rejects other types" do + assert IntegerType.cast(:atom) == :error + assert IntegerType.cast([1]) == :error + end +end diff --git a/test/commandex/type/string_test.exs b/test/commandex/type/string_test.exs new file mode 100644 index 0000000..ed016fd --- /dev/null +++ b/test/commandex/type/string_test.exs @@ -0,0 +1,26 @@ +defmodule Commandex.Type.StringTest do + use ExUnit.Case + + alias Commandex.Type.String, as: StringType + + test "casts binaries" do + assert StringType.cast("hello") == {:ok, "hello"} + end + + test "casts atoms" do + assert StringType.cast(:hello) == {:ok, "hello"} + end + + test "casts integers" do + assert StringType.cast(42) == {:ok, "42"} + end + + test "casts floats" do + assert StringType.cast(3.14) == {:ok, "3.14"} + end + + test "rejects non-stringable types" do + assert StringType.cast({:tuple}) == :error + assert StringType.cast([1, 2]) == :error + end +end diff --git a/test/commandex/type_test.exs b/test/commandex/type_test.exs new file mode 100644 index 0000000..3d406d2 --- /dev/null +++ b/test/commandex/type_test.exs @@ -0,0 +1,59 @@ +defmodule Commandex.TypeTest do + use ExUnit.Case + + describe "cast/2 nil handling" do + test "nil returns {:ok, nil} for all types" do + for type <- [:any, :string, :integer, :float, :boolean, {:array, :string}] do + assert Commandex.Type.cast(nil, type) == {:ok, nil} + end + end + end + + describe "cast/2 :any" do + test "passes through any value" do + assert Commandex.Type.cast("hello", :any) == {:ok, "hello"} + assert Commandex.Type.cast(42, :any) == {:ok, 42} + assert Commandex.Type.cast({:tuple}, :any) == {:ok, {:tuple}} + end + end + + describe "Array type" do + test "casts array of integers" do + assert Commandex.Type.cast(["1", "2", "3"], {:array, :integer}) == {:ok, [1, 2, 3]} + end + + test "casts array of strings" do + assert Commandex.Type.cast([:a, :b], {:array, :string}) == {:ok, ["a", "b"]} + end + + test "fails if any element fails" do + assert Commandex.Type.cast(["1", "bad"], {:array, :integer}) == :error + end + + test "rejects non-list" do + assert Commandex.Type.cast("hello", {:array, :string}) == :error + end + + test "handles empty list" do + assert Commandex.Type.cast([], {:array, :integer}) == {:ok, []} + end + end + + describe "custom type module" do + defmodule UpperString do + @behaviour Commandex.Type + + @impl true + def cast(value) when is_binary(value), do: {:ok, String.upcase(value)} + def cast(_), do: :error + end + + test "dispatches to custom module" do + assert Commandex.Type.cast("hello", UpperString) == {:ok, "HELLO"} + end + + test "custom module failure returns :error" do + assert Commandex.Type.cast(123, UpperString) == :error + end + end +end diff --git a/test/commandex_test.exs b/test/commandex_test.exs index 956c65d..b0ed171 100644 --- a/test/commandex_test.exs +++ b/test/commandex_test.exs @@ -23,6 +23,21 @@ defmodule CommandexTest do end end + test "sets __meta__ with param schema and pipelines" do + meta = %RegisterUser{}.__meta__ + + assert meta.params.email == {:string, []} + assert meta.params.password == {:string, []} + assert meta.params.agree_tos == {:boolean, []} + + assert meta.pipelines == [ + :check_already_registered, + :verify_tos, + :create_user, + :record_auth_attempt + ] + end + test "handles atom-key map params correctly" do params = %{ email: @email, @@ -164,6 +179,51 @@ defmodule CommandexTest do end end + describe "halt_on_errors/1" do + test "passes through when no errors" do + command = RegisterUser.new(%{email: "a@b.com", password: "pass", agree_tos: true}) + result = Commandex.halt_on_errors(command) + + refute result.halted + end + + test "halts when errors present" do + command = + RegisterUser.new() + |> Commandex.put_error(:email, :invalid) + |> Commandex.halt_on_errors() + + assert command.halted + refute command.success + end + + test "auto-inserted as first pipeline" do + defmodule HaltOnErrorsExample do + import Commandex + + command do + param :value, :integer + + data :result + + pipeline :process + end + + def process(command, %{value: value}, _data) do + put_data(command, :result, value * 2) + end + end + + halted = HaltOnErrorsExample.run(%{value: "abc"}) + assert halted.halted + assert halted.errors.value == :invalid + + success = HaltOnErrorsExample.run(%{value: "42"}) + assert success.success + assert success.data.result == 84 + end + end + describe "run/0" do test "is defined if no params are defined" do assert Kernel.function_exported?(GenerateReport, :run, 0) @@ -257,6 +317,313 @@ defmodule CommandexTest do end end + describe "typed param casting" do + defmodule TypedParams do + import Commandex + + command do + param :name, :string + param :age, :integer + param :score, :float + param :active, :boolean + param :untyped + end + end + + test "casts string values to declared types" do + command = + TypedParams.new(%{ + "name" => "Alice", + "age" => "25", + "score" => "3.14", + "active" => "true" + }) + + assert command.params.name == "Alice" + assert command.params.age == 25 + assert command.params.score == 3.14 + assert command.params.active == true + end + + test "passes through natively-typed values" do + command = TypedParams.new(%{name: "Bob", age: 30, score: 9.5, active: false}) + + assert command.params.name == "Bob" + assert command.params.age == 30 + assert command.params.score == 9.5 + assert command.params.active == false + end + + test "puts :invalid error on cast failure" do + command = TypedParams.new(%{age: "not_a_number", score: "nope"}) + + refute command.params.age + refute command.params.score + assert command.errors.age == :invalid + assert command.errors.score == :invalid + end + + test "untyped params pass through without casting" do + command = TypedParams.new(%{untyped: {:complex, "value"}}) + + assert command.params.untyped == {:complex, "value"} + end + + test "nil values are not cast failures" do + command = TypedParams.new(%{age: nil}) + + refute command.params.age + assert command.errors == %{} + end + end + + describe "required params" do + defmodule RequiredParams do + import Commandex + + command do + param :email, :string, required: true + param :name, :string + param :age, :integer, required: true + end + end + + test "puts :required error when value is nil" do + command = RequiredParams.new(%{}) + + assert command.errors.email == :required + assert command.errors.age == :required + refute Map.has_key?(command.errors, :name) + end + + test "no error when required value is provided" do + command = RequiredParams.new(%{email: "a@b.com", age: 25}) + + assert command.errors == %{} + assert command.params.email == "a@b.com" + assert command.params.age == 25 + end + + test ":invalid takes precedence over :required for failed casts" do + command = RequiredParams.new(%{email: "a@b.com", age: "not_a_number"}) + + assert command.errors.age == :invalid + end + end + + describe "error accumulation" do + defmodule MultiError do + import Commandex + + command do + param :email, :string, required: true + param :age, :integer, required: true + param :score, :float + end + end + + test "accumulates all errors in a single new/1 call" do + command = MultiError.new(%{age: "bad", score: "bad"}) + + assert command.errors.email == :required + assert command.errors.age == :invalid + assert command.errors.score == :invalid + assert map_size(command.errors) == 3 + end + end + + describe "array types" do + defmodule ArrayParams do + import Commandex + + command do + param :tags, {:array, :string} + param :scores, {:array, :integer} + end + end + + test "casts array elements" do + command = ArrayParams.new(%{tags: ["a", "b"], scores: ["1", "2", "3"]}) + + assert command.params.tags == ["a", "b"] + assert command.params.scores == [1, 2, 3] + end + + test "puts :invalid if any element fails casting" do + command = ArrayParams.new(%{scores: ["1", "bad", "3"]}) + + refute command.params.scores + assert command.errors.scores == :invalid + end + + test "puts :invalid for non-list input" do + command = ArrayParams.new(%{tags: "not_a_list"}) + + refute command.params.tags + assert command.errors.tags == :invalid + end + + test "nil array is not a cast failure" do + command = ArrayParams.new(%{}) + + refute command.params.tags + assert command.errors == %{} + end + end + + describe "custom type module" do + defmodule UpperString do + @behaviour Commandex.Type + + @type t :: String.t() + + @impl true + def cast(value) when is_binary(value), do: {:ok, String.upcase(value)} + def cast(_), do: :error + end + + defmodule Role do + @behaviour Commandex.Type + + @type t :: :admin | :user | :guest + @allowed [:admin, :user, :guest] + + @impl true + def cast(value) when is_atom(value) and value in @allowed, do: {:ok, value} + + def cast(value) when is_binary(value) do + atom = String.to_existing_atom(value) + if atom in @allowed, do: {:ok, atom}, else: :error + rescue + ArgumentError -> :error + end + + def cast(_), do: :error + end + + defmodule CustomTypeCommand do + import Commandex + + command do + param :name, CommandexTest.UpperString + param :role, CommandexTest.Role, required: true + end + end + + test "casts using custom type module" do + command = CustomTypeCommand.new(%{name: "alice", role: :admin}) + + assert command.params.name == "ALICE" + assert command.params.role == :admin + end + + test "casts string to atom for enum-style type" do + command = CustomTypeCommand.new(%{role: "guest"}) + + assert command.params.role == :guest + end + + test "rejects values outside the allowed set" do + command = CustomTypeCommand.new(%{role: "superadmin"}) + + refute command.params.role + assert command.errors.role == :invalid + end + + test "rejects non-string non-atom values" do + command = CustomTypeCommand.new(%{role: 123}) + + refute command.params.role + assert command.errors.role == :invalid + end + + test "returns :invalid on custom type cast failure" do + command = CustomTypeCommand.new(%{name: 123, role: :admin}) + + refute command.params.name + assert command.errors.name == :invalid + end + + test "nil passes through custom type" do + command = CustomTypeCommand.new(%{role: :user}) + + refute command.params.name + refute Map.has_key?(command.errors, :name) + end + end + + describe "backwards compatibility" do + defmodule UntypedCommand do + import Commandex + + command do + param :name + param :count, default: 10 + + data :result + + pipeline :process + end + + def process(command, %{name: name, count: count}, _data) do + Commandex.put_data(command, :result, {name, count}) + end + end + + test "untyped param works without casting" do + command = UntypedCommand.new(%{name: "test"}) + + assert command.params.name == "test" + assert command.params.count == 10 + end + + test "untyped param accepts any value" do + command = UntypedCommand.new(%{name: {:tuple, "value"}, count: [1, 2, 3]}) + + assert command.params.name == {:tuple, "value"} + assert command.params.count == [1, 2, 3] + end + + test "full pipeline works with untyped params" do + command = UntypedCommand.run(%{name: "hello"}) + + assert command.success + assert command.data.result == {"hello", 10} + end + + test "new/0 still works" do + command = UntypedCommand.new() + + refute command.params.name + assert command.params.count == 10 + end + end + + describe "default option with typed params" do + defmodule DefaultTyped do + import Commandex + + command do + param :score, :float, default: 0.0 + param :name, :string, default: "Anonymous" + end + end + + test "uses default when param not provided" do + command = DefaultTyped.new(%{}) + + assert command.params.score == 0.0 + assert command.params.name == "Anonymous" + end + + test "overrides default with provided value" do + command = DefaultTyped.new(%{score: "9.5", name: "Alice"}) + + assert command.params.score == 9.5 + assert command.params.name == "Alice" + end + end + defp assert_params(command) do assert command.params.email == @email assert command.params.password == @password diff --git a/test/support/register_user.ex b/test/support/register_user.ex index 6e307d9..be7f3fc 100644 --- a/test/support/register_user.ex +++ b/test/support/register_user.ex @@ -6,9 +6,9 @@ defmodule RegisterUser do import Commandex command do - param :email, default: "test@test.com" - param :password - param :agree_tos + param(:email, :string, default: "test@test.com") + param :password, :string + param :agree_tos, :boolean data :user data :auth