diff --git a/.formatter.exs b/.formatter.exs index 661cf98..ed57706 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,7 @@ locals_without_parens = [ param: 1, param: 2, + param: 3, data: 1, pipeline: 1 ] diff --git a/lib/commandex.ex b/lib/commandex.ex index 0ff63f6..6ad5c2d 100644 --- a/lib/commandex.ex +++ b/lib/commandex.ex @@ -53,12 +53,14 @@ defmodule Commandex do The `command/1` macro will define a struct that looks like: %RegisterUser{ + __meta__: %{ + 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] } As well as two functions: @@ -96,12 +98,16 @@ defmodule Commandex do iex> GenerateReport.run() %GenerateReport{ - pipelines: [:fetch_data, :calculate_results], + __meta__: %{ + params: %{}, + pipelines: [:fetch_data, :calculate_results], + }, data: %{total_valid: 183220, total_invalid: 781215}, - params: %{}, - halted: false, errors: %{}, - success: true + halted: false, + params: %{}, + success: true, + valid: true } """ @@ -139,11 +145,13 @@ defmodule Commandex do """ @type command :: %{ __struct__: atom, + __meta__: %{ + pipelines: [pipeline()] + }, data: map, errors: map, halted: boolean, params: map, - pipelines: [pipeline()], success: boolean } @@ -158,10 +166,6 @@ defmodule Commandex do Module.register_attribute(__MODULE__, name, accumulate: true) end - for field <- [{:success, false}, {:errors, %{}}, {:halted, false}] do - Module.put_attribute(__MODULE__, :struct_fields, field) - end - try do import Commandex unquote(block) @@ -172,13 +176,20 @@ defmodule Commandex do postlude = quote unquote: false do - params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair - data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair + data = __MODULE__ |> Module.get_attribute(:data) |> Enum.into(%{}) + params = __MODULE__ |> Module.get_attribute(:params) |> Enum.into(%{}) pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse() - - Module.put_attribute(__MODULE__, :struct_fields, {:params, params}) + schema = %{params: params, pipelines: pipelines} + + # Added in reverse order so the struct fields sort alphabetically. + Module.put_attribute(__MODULE__, :struct_fields, {:valid, false}) + Module.put_attribute(__MODULE__, :struct_fields, {:success, false}) + Module.put_attribute(__MODULE__, :struct_fields, {:params, %{}}) + Module.put_attribute(__MODULE__, :struct_fields, {:halted, false}) + Module.put_attribute(__MODULE__, :struct_fields, {:errors, %{}}) Module.put_attribute(__MODULE__, :struct_fields, {:data, data}) - Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines}) + Module.put_attribute(__MODULE__, :struct_fields, {:__meta__, schema}) + defstruct @struct_fields @typedoc """ @@ -195,20 +206,23 @@ defmodule Commandex do `true` if the command was not halted after running all of the pipelines. """ @type t :: %__MODULE__{ + __meta__: %{ + pipelines: [Commandex.pipeline()] + }, data: map, errors: map, halted: boolean, params: map, - pipelines: [Commandex.pipeline()], - success: boolean + success: boolean | nil, + valid: boolean | nil } @doc """ Creates a new struct from given parameters. """ @spec new(map | Keyword.t()) :: t - def new(opts \\ []) do - Commandex.parse_params(%__MODULE__{}, opts) + def new(params \\ []) do + Commandex.Parameter.cast_params(%__MODULE__{}, params) end if Enum.empty?(params) do @@ -217,8 +231,7 @@ defmodule Commandex do """ @spec run :: t def run do - new() - |> run() + new() |> run() end end @@ -229,7 +242,13 @@ defmodule Commandex do or the command struct itself. """ @spec run(map | Keyword.t() | t) :: t - def run(%unquote(__MODULE__){pipelines: pipelines} = command) do + def run(%unquote(__MODULE__){valid: false} = command) do + command + |> halt() + |> Commandex.maybe_mark_successful() + end + + def run(%unquote(__MODULE__){__meta__: %{pipelines: pipelines}} = command) do pipelines |> Enum.reduce_while(command, fn fun, acc -> case acc do @@ -267,9 +286,9 @@ defmodule Commandex do end """ @spec param(atom, Keyword.t()) :: no_return - defmacro param(name, opts \\ []) do + defmacro param(name, type \\ :any, opts \\ []) do quote do - Commandex.__param__(__MODULE__, unquote(name), unquote(opts)) + Commandex.__param__(__MODULE__, unquote(name), unquote(type), unquote(opts)) end end @@ -348,7 +367,7 @@ defmodule Commandex do @doc """ Sets error for given key and value. - `:errors` is a map. Putting an error on the same key will overwrite the previous value. + `:errors` is a map. Putting an error on the same key will create a list. def hash_password(command, %{password: nil} = _params, _data) do command @@ -357,8 +376,12 @@ defmodule Commandex do end """ @spec put_error(command, any, any) :: command - def put_error(%{errors: error} = command, key, val) do - %{command | errors: Map.put(error, key, val)} + def put_error(%{errors: errors} = command, key, val) do + case Map.get(errors, key) do + nil -> %{command | errors: Map.put(errors, key, val)} + vals when is_list(vals) -> %{command | errors: Map.put(errors, key, [val | vals])} + value -> %{command | errors: Map.put(errors, key, [val, value])} + end end @doc """ @@ -378,17 +401,11 @@ defmodule Commandex do @doc false def maybe_mark_successful(%{halted: false} = command), do: %{command | success: true} - def maybe_mark_successful(command), do: command + def maybe_mark_successful(command), do: %{command | success: false} @doc false - 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} - 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 maybe_mark_invalid(command) do + %{command | valid: Enum.empty?(command.errors)} end @doc false @@ -412,15 +429,22 @@ defmodule Commandex do :erlang.apply(m, f, [command, params, data] ++ a) end - def __param__(mod, name, opts) do + # If no type is defined, opts keyword list becomes third argument. + # Run this again with the :any type. + def __param__(mod, name, opts, []) when is_list(opts) do + __param__(mod, name, :any, opts) + end + + def __param__(mod, name, type, opts) do + Commandex.Parameter.check_type!(name, type) + params = Module.get_attribute(mod, :params) - if List.keyfind(params, name, 0) do + if Enum.any?(params, fn {p_name, _opts} -> p_name == name end) 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 def __data__(mod, name) do @@ -456,14 +480,4 @@ defmodule Commandex do def __pipeline__(_mod, name) do raise ArgumentError, "pipeline #{inspect(name)} is not valid" end - - 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 end diff --git a/lib/commandex/parameter.ex b/lib/commandex/parameter.ex new file mode 100644 index 0000000..b755b67 --- /dev/null +++ b/lib/commandex/parameter.ex @@ -0,0 +1,78 @@ +defmodule Commandex.Parameter do + @moduledoc false + + @base ~w(any boolean float integer string)a + + def check_type!(name, {_outer, inner}) do + check_type!(name, inner) + end + + def check_type!(_name, type) when type in @base do + type + end + + def check_type!(name, type) do + raise ArgumentError, "unknown type #{inspect(type)} for param #{inspect(name)}" + end + + def cast_params(%{__meta__: %{params: schema_params}} = command, params) + when is_map(params) or is_list(params) do + schema_params + |> extract_params(params) + |> Enum.reduce(command, fn {key, val}, command -> + {type, _opts} = command.__meta__.params[key] + + case Commandex.Type.cast(val, type) do + {:ok, :"$undefined"} -> + command + + {:ok, cast_value} -> + put_param(command, key, cast_value) + + :error -> + command + |> put_param(key, val) + |> Commandex.put_error(key, :invalid) + end + end) + |> validate_required() + |> Commandex.maybe_mark_invalid() + end + + def validate_required(%{__meta__: %{params: schema_params}} = command) do + schema_params + |> Enum.reduce(command, fn {key, {_type, opts}}, command -> + case {Keyword.get(opts, :required), Map.has_key?(command.params, key)} do + {true, true} -> command + {true, false} -> Commandex.put_error(command, key, :required) + {_, _} -> command + end + end) + end + + defp extract_params(schema_params, input_params) do + schema_params + |> Enum.map(fn {key, {_type, opts}} -> + default = Keyword.get(opts, :default, :"$undefined") + {key, get_param(input_params, key, default)} + end) + |> Enum.into(%{}) + end + + defp get_param(params, key, default) when is_list(params) do + Keyword.get(params, key, default) + end + + defp get_param(params, key, default) when is_map(params) do + with nil <- Map.get(params, key), + nil <- Map.get(params, to_string(key)) do + default + else + value -> value + end + end + + defp put_param(command, name, value) do + put_in(command.params[name], value) + end +end diff --git a/lib/commandex/type.ex b/lib/commandex/type.ex new file mode 100644 index 0000000..3c776bd --- /dev/null +++ b/lib/commandex/type.ex @@ -0,0 +1,63 @@ +defmodule Commandex.Type do + @undefined :"$undefined" + + @callback cast(term) :: {:ok, term} | :error + + @doc ~S""" + Casts value for given type. + + ## Examples + + iex> cast([3, true], :array) + {:ok, [3, true]} + + iex> cast([1, 2], {:array, :integer}) + {:ok, [1,2]} + + iex> cast([1, "what"], {:array, :integer}) + :error + + iex> cast(1, {:array, :integer}) + :error + + iex> cast(1, :any) + {:ok, 1} + + iex> cast(true, :boolean) + {:ok, true} + + iex> cast(1.5, :float) + {:ok, 1.5} + + iex> cast(2, :integer) + {:ok, 2} + + iex> cast("example", :string) + {:ok, "example"} + + iex> cast(:"$undefined", :boolean) + {:ok, :"$undefined"} + + iex> cast(1234, "not a type") + :error + """ + def cast(@undefined, _type), do: {:ok, @undefined} + + def cast(value, :array), do: cast(value, {:array, :any}) + + def cast(value, {:array, type}) when is_list(value) do + if Enum.any?(value, fn val -> cast(val, type) == :error end), + do: :error, + else: {:ok, value} + end + + def cast(_value, {:array, _type}), do: :error + + 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, module) when is_atom(module), do: module.cast(value) + def cast(_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..22b0e26 --- /dev/null +++ b/lib/commandex/type/boolean.ex @@ -0,0 +1,35 @@ +defmodule Commandex.Type.Boolean do + @moduledoc false + + @behaviour Commandex.Type + + @doc ~S""" + ## Examples + + iex> cast(true) + {:ok, true} + + iex> cast(false) + {:ok, false} + + iex> cast("true") + {:ok, true} + + iex> cast("false") + {:ok, false} + + iex> cast("1") + {:ok, true} + + iex> cast("0") + {:ok, false} + + iex> cast("not-boolean") + :error + """ + @impl true + def cast(value) when is_boolean(value), do: {:ok, value} + def cast(value) when value in ~w(true 1), do: {:ok, true} + def cast(value) when value in ~w(false 0), do: {:ok, false} + def cast(_value), do: :error +end diff --git a/lib/commandex/type/float.ex b/lib/commandex/type/float.ex new file mode 100644 index 0000000..3ba56be --- /dev/null +++ b/lib/commandex/type/float.ex @@ -0,0 +1,33 @@ +defmodule Commandex.Type.Float do + @behaviour Commandex.Type + + @doc ~S""" + ## Examples + + iex> cast(1.234) + {:ok, 1.234} + + iex> cast("1.5") + {:ok, 1.5} + + iex> cast("1.5.1") + :error + + iex> cast(10) + {:ok, 10.0} + + iex> cast(false) + :error + """ + @impl true + def cast(value) when is_binary(value) do + case Float.parse(value) do + {float, ""} -> {:ok, float} + _ -> :error + end + end + + def cast(value) when is_float(value), do: {:ok, value} + def cast(value) when is_integer(value), do: {:ok, :erlang.float(value)} + 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..ca27ad5 --- /dev/null +++ b/lib/commandex/type/integer.ex @@ -0,0 +1,30 @@ +defmodule Commandex.Type.Integer do + @behaviour Commandex.Type + + @doc ~S""" + ## Examples + + iex> cast(12) + {:ok, 12} + + iex> cast("15") + {:ok, 15} + + iex> cast("1.5") + :error + + iex> cast(false) + :error + """ + @impl true + def cast(value) when is_integer(value), do: {:ok, value} + + def cast(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + def cast(_value), do: :error +end diff --git a/lib/commandex/type/string.ex b/lib/commandex/type/string.ex new file mode 100644 index 0000000..8cabf71 --- /dev/null +++ b/lib/commandex/type/string.ex @@ -0,0 +1,16 @@ +defmodule Commandex.Type.String do + @behaviour Commandex.Type + + @doc ~S""" + ## Examples + + iex> cast("thing") + {:ok, "thing"} + + iex> cast(1234) + :error + """ + @impl true + def cast(value) when is_binary(value), do: {:ok, value} + def cast(_value), do: :error +end diff --git a/mix.exs b/mix.exs index 7e40223..8ccb58c 100644 --- a/mix.exs +++ b/mix.exs @@ -69,10 +69,11 @@ defmodule Commandex.MixProject do defp test_coverage do [ ignore_modules: [ + FetchUserPosts, GenerateReport, RegisterUser ], - summary: [threshold: 70] + summary: [threshold: 50] ] end diff --git a/test/commandex/type_test.exs b/test/commandex/type_test.exs new file mode 100644 index 0000000..5f49dc7 --- /dev/null +++ b/test/commandex/type_test.exs @@ -0,0 +1,9 @@ +defmodule Commandex.TypeTest do + use ExUnit.Case + + doctest Commandex.Type, import: true + doctest Commandex.Type.Boolean, import: true + doctest Commandex.Type.Float, import: true + doctest Commandex.Type.Integer, import: true + doctest Commandex.Type.String, import: true +end diff --git a/test/commandex_test.exs b/test/commandex_test.exs index 227e282..b11f26f 100644 --- a/test/commandex_test.exs +++ b/test/commandex_test.exs @@ -7,22 +7,14 @@ defmodule CommandexTest do @agree_tos false describe "struct assembly" do - test "sets :params map" do - for key <- [:email, :password, :agree_tos] do - assert Map.has_key?(%RegisterUser{}.params, key) - end - end - - test "sets param default if specified" do - assert %RegisterUser{}.params.email == "test@test.com" - end - test "sets :data map" do for key <- [:user, :auth] do assert Map.has_key?(%RegisterUser{}.data, key) end end + end + describe "new/1" do test "handles atom-key map params correctly" do params = %{ email: @email, @@ -36,9 +28,9 @@ defmodule CommandexTest do test "handles string-key map params correctly" do params = %{ - email: @email, - password: @password, - agree_tos: @agree_tos + "email" => @email, + "password" => @password, + "agree_tos" => @agree_tos } command = RegisterUser.new(params) @@ -60,7 +52,7 @@ defmodule CommandexTest do describe "param/2 macro" do test "raises if duplicate defined" do assert_raise ArgumentError, fn -> - defmodule ExampleParamInvalid do + defmodule ExampleParamInvalidDuplicate do import Commandex command do @@ -71,6 +63,33 @@ defmodule CommandexTest do end end end + + test "raises if invalid type" do + assert_raise ArgumentError, fn -> + defmodule ExampleParamInvalidType do + import Commandex + + command do + param :key_1, :string + param :key_2, :what + end + end + end + end + + test "defaults to :any type if not given" do + defmodule ExampleParamValidAny do + import Commandex + + command do + param :key_1, :string + param :key_2, default: true + end + end + + command = ExampleParamValidAny.new() + assert command.__meta__[:key_2] == {:any, [default: true]} + end end describe "data/1 macro" do @@ -137,13 +156,46 @@ defmodule CommandexTest do describe "halt/1" do test "ignores remaining pipelines" do - command = RegisterUser.run(%{agree_tos: false}) + command = RegisterUser.run(%{email: @email, password: @password, agree_tos: false}) refute command.success assert command.errors === %{tos: :not_accepted} end end + describe "put_error/3" do + test "puts error value for given key" do + command = + %{email: @email, password: @password, agree_tos: true} + |> RegisterUser.new() + |> Commandex.put_error(:example, true) + + assert command.errors === %{example: true} + end + + test "stacks error values for given key" do + command = + %{email: @email, password: @password, agree_tos: true} + |> RegisterUser.new() + |> Commandex.put_error(:example, "foo") + |> Commandex.put_error(:example, "bar") + |> Commandex.put_error(:example, "baz") + + assert command.errors === %{example: ["baz", "bar", "foo"]} + end + end + + describe "put_data/3" do + test "puts data value for given key" do + command = + %{email: @email, password: @password, agree_tos: true} + |> RegisterUser.new() + |> Commandex.put_data(:auth, true) + + assert command.data === %{auth: true} + end + end + describe "run/0" do test "is defined if no params are defined" do assert Kernel.function_exported?(GenerateReport, :run, 0) diff --git a/test/support/fetch_user_posts.ex b/test/support/fetch_user_posts.ex new file mode 100644 index 0000000..9f5edcc --- /dev/null +++ b/test/support/fetch_user_posts.ex @@ -0,0 +1,30 @@ +defmodule FetchUserPosts do + @moduledoc """ + Example command that fetches posts for a given user. + """ + + import Commandex + + command do + param :user_id, :integer, required: true + param :limit, :integer, default: 20 + param :offset, :integer, default: 0 + param :sort_by, {:array, :string}, default: ["created_at"] + param :sort_dir, {:array, :string}, default: ["asc"] + + data :posts + + pipeline :fetch_posts + end + + # def validate(command, params, _data) do + # command + # |> validate_number(:limit, in: 0..100) + # |> validate_number(:offset, greater_than_or_equal: 0) + # |> halt_if_invalid() + # end + + def fetch_posts(command, _params, _data) do + command + end +end diff --git a/test/support/register_user.ex b/test/support/register_user.ex index 56a40e0..2853fd5 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, required: true + param :password, :string, required: true + param :agree_tos, :boolean, default: false data :user data :auth