From b5374c1f9e8fb710f0fd118134776b98c117b0d7 Mon Sep 17 00:00:00 2001 From: Henry Popp Date: Sat, 7 Sep 2024 15:48:01 -0500 Subject: [PATCH 1/5] feat: initial type casting --- .formatter.exs | 1 + lib/commandex.ex | 22 ++++++++++++++++++---- lib/commandex/parameter.ex | 24 ++++++++++++++++++++++++ test/support/register_user.ex | 7 ++++--- 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 lib/commandex/parameter.ex 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 0ee42e6..660b8ea 100644 --- a/lib/commandex.ex +++ b/lib/commandex.ex @@ -139,7 +139,7 @@ defmodule Commandex do defmacro command(do: block) do prelude = quote do - for name <- [:struct_fields, :params, :data, :pipelines] do + for name <- [:struct_fields, :params, :data, :pipelines, :__schema__] do Module.register_attribute(__MODULE__, name, accumulate: true) end @@ -160,7 +160,9 @@ defmodule Commandex do params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse() + schema = for pair <- Module.get_attribute(__MODULE__, :__schema__), into: %{}, do: pair + Module.put_attribute(__MODULE__, :struct_fields, {:__schema__, schema}) Module.put_attribute(__MODULE__, :struct_fields, {:params, params}) Module.put_attribute(__MODULE__, :struct_fields, {:data, data}) Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines}) @@ -252,9 +254,9 @@ defmodule Commandex do end """ @spec param(atom, Keyword.t()) :: no_return - defmacro param(name, opts \\ []) do + defmacro param(name, type, opts \\ []) do quote do - Commandex.__param__(__MODULE__, unquote(name), unquote(opts)) + Commandex.__param__(__MODULE__, unquote(name), unquote(type), unquote(opts)) end end @@ -368,6 +370,17 @@ defmodule Commandex do @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])} + + params = + Enum.map(params, fn {key, val} -> + {type, opts} = struct.__schema__[key] + + case Commandex.Parameter.cast(val, type, opts) do + {:ok, cast_value} -> {key, cast_value} + _else -> {key, val} + end + end) + %{struct | params: params} end @@ -397,7 +410,7 @@ defmodule Commandex do :erlang.apply(m, f, [command, params, data] ++ a) end - def __param__(mod, name, opts) do + def __param__(mod, name, type, opts) do params = Module.get_attribute(mod, :params) if List.keyfind(params, name, 0) do @@ -406,6 +419,7 @@ defmodule Commandex do default = Keyword.get(opts, :default) Module.put_attribute(mod, :params, {name, default}) + Module.put_attribute(mod, :__schema__, {name, {type, opts}}) end def __data__(mod, name) do diff --git a/lib/commandex/parameter.ex b/lib/commandex/parameter.ex new file mode 100644 index 0000000..5ed60c7 --- /dev/null +++ b/lib/commandex/parameter.ex @@ -0,0 +1,24 @@ +defmodule Commandex.Parameter do + def cast(value, type, opts \\ []) + + def cast(nil, _type, opts) do + case Keyword.get(opts, :default) do + nil -> {:ok, nil} + default -> {:ok, default} + end + end + + def cast(value, :boolean, _opts) when is_boolean(value), do: {:ok, value} + def cast("true", :boolean, _opts), do: {:ok, true} + def cast("false", :boolean, _opts), do: {:ok, false} + + def cast(value, :integer, _opts) when is_integer(value) do + {:ok, value} + end + + def cast(value, :integer, opts) when is_binary(value) do + cast(String.to_integer(value), :integer, opts) + end + + def cast(value, _type, _opts), do: {:ok, value} +end diff --git a/test/support/register_user.ex b/test/support/register_user.ex index d5b23fe..5c5a267 100644 --- a/test/support/register_user.ex +++ b/test/support/register_user.ex @@ -6,9 +6,10 @@ defmodule Commandex.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, default: false + param :limit, :integer, min: 0, max: 20 data :user data :auth From 7edcaef38c34ba91ac50e68060896bd3f9d5fb0b Mon Sep 17 00:00:00 2001 From: Henry Popp Date: Sun, 8 Sep 2024 13:07:07 -0500 Subject: [PATCH 2/5] feat: types made optional --- lib/commandex.ex | 92 +++++++++++++++++++++-------------- lib/commandex/parameter.ex | 25 ++++++++-- test/support/register_user.ex | 7 +-- 3 files changed, 80 insertions(+), 44 deletions(-) diff --git a/lib/commandex.ex b/lib/commandex.ex index 66579b6..52acaef 100644 --- a/lib/commandex.ex +++ b/lib/commandex.ex @@ -154,11 +154,11 @@ defmodule Commandex do defmacro command(do: block) do prelude = quote do - for name <- [:struct_fields, :params, :data, :pipelines, :__schema__] do + for name <- [:struct_fields, :params, :data, :pipelines] do Module.register_attribute(__MODULE__, name, accumulate: true) end - for field <- [{:success, false}, {:errors, %{}}, {:halted, false}] do + for field <- [{:success, false}, {:errors, %{}}, {:halted, false}, {:valid, false}] do Module.put_attribute(__MODULE__, :struct_fields, field) end @@ -175,10 +175,9 @@ defmodule Commandex do params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse() - schema = for pair <- Module.get_attribute(__MODULE__, :__schema__), into: %{}, do: pair - Module.put_attribute(__MODULE__, :struct_fields, {:__schema__, schema}) - Module.put_attribute(__MODULE__, :struct_fields, {:params, params}) + Module.put_attribute(__MODULE__, :struct_fields, {:__schema__, %{params: params}}) + Module.put_attribute(__MODULE__, :struct_fields, {:params, %{}}) Module.put_attribute(__MODULE__, :struct_fields, {:data, data}) Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines}) defstruct @struct_fields @@ -269,7 +268,7 @@ defmodule Commandex do end """ @spec param(atom, Keyword.t()) :: no_return - defmacro param(name, type, opts \\ []) do + defmacro param(name, type \\ :any, opts \\ []) do quote do Commandex.__param__(__MODULE__, unquote(name), unquote(type), unquote(opts)) end @@ -350,7 +349,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 @@ -359,8 +358,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 """ @@ -383,25 +386,48 @@ defmodule Commandex do def maybe_mark_successful(command), do: command @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])} + def parse_params(%{__schema__: %{params: p}} = command, params) when is_list(params) do + params = for {key, _} <- p, into: %{}, do: {key, Keyword.get(params, key, :"$undefined")} - params = - Enum.map(params, fn {key, val} -> - {type, opts} = struct.__schema__[key] + command + |> do_cast_params(params) + |> maybe_mark_invalid() + end - case Commandex.Parameter.cast(val, type, opts) do - {:ok, cast_value} -> {key, cast_value} - _else -> {key, val} - end - end) + def parse_params(%{__schema__: %{params: p}} = command, params) when is_map(params) do + params = for {key, _} <- p, into: %{}, do: {key, get_param(params, key)} + + command + |> do_cast_params(params) + |> maybe_mark_invalid() + end + + defp do_cast_params(command, params) do + Enum.reduce(params, command, fn {key, val}, command -> + {type, opts} = command.__schema__.params[key] + + case Commandex.Parameter.cast(val, type, opts) do + {:ok, :"$undefined"} -> command + {:ok, cast_value} -> put_param(command, key, cast_value) + {:error, reason} -> put_error(command, key, reason) + end + end) + end + + defp get_param(params, key) do + case Map.get(params, key) do + nil -> Map.get(params, to_string(key), :"$undefined") + val -> val + end + end - %{struct | params: params} + defp put_param(command, name, value) do + params = Map.put(command.params, name, value) + %{command | 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} + defp maybe_mark_invalid(command) do + %{command | valid: Enum.empty?(command.errors)} end @doc false @@ -425,16 +451,18 @@ defmodule Commandex do :erlang.apply(m, f, [command, params, data] ++ a) end + 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 + 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, :__schema__, {name, {type, opts}}) + Module.put_attribute(mod, :params, {name, {type, opts}}) end def __data__(mod, name) do @@ -470,14 +498,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 index 5ed60c7..5723970 100644 --- a/lib/commandex/parameter.ex +++ b/lib/commandex/parameter.ex @@ -1,16 +1,25 @@ defmodule Commandex.Parameter do def cast(value, type, opts \\ []) - def cast(nil, _type, opts) do + def cast(:"$undefined", type, opts) do case Keyword.get(opts, :default) do - nil -> {:ok, nil} - default -> {:ok, default} + nil -> + case Keyword.get(opts, :required) do + true -> {:error, :required} + _ -> {:ok, :"$undefined"} + end + + default -> + cast(default, type, opts) end end + def cast(value, :any, _opts), do: {:ok, value} + def cast(value, :boolean, _opts) when is_boolean(value), do: {:ok, value} def cast("true", :boolean, _opts), do: {:ok, true} def cast("false", :boolean, _opts), do: {:ok, false} + def cast(_value, :boolean, _opts), do: {:error, :invalid} def cast(value, :integer, _opts) when is_integer(value) do {:ok, value} @@ -20,5 +29,13 @@ defmodule Commandex.Parameter do cast(String.to_integer(value), :integer, opts) end - def cast(value, _type, _opts), do: {:ok, value} + def cast(value, :string, _opts) when is_binary(value) do + {:ok, value} + end + + def cast(value, :string, opts) do + cast(inspect(value), :string, opts) + end + + def cast(_value, _type, _opts), do: {:error, :invalid} end diff --git a/test/support/register_user.ex b/test/support/register_user.ex index d7e03f6..cbf5f92 100644 --- a/test/support/register_user.ex +++ b/test/support/register_user.ex @@ -6,10 +6,11 @@ defmodule RegisterUser do import Commandex command do - param :email, :string, default: "test@test.com" - param :password, :string + param :email, default: "test@test.com" + param :password, :string, required: true param :agree_tos, :boolean, default: false - param :limit, :integer, min: 0, max: 20 + param :limit, :integer, min: 0, max: 50, default: 20 + param :offset, :integer, min: 0, default: 0 data :user data :auth From 04450ed84c9a36777d425fe71e773351746bcba8 Mon Sep 17 00:00:00 2001 From: Henry Popp Date: Sun, 8 Sep 2024 16:50:21 -0500 Subject: [PATCH 3/5] refactor: various updates WIP --- lib/commandex.ex | 102 ++++++++++++------------------- lib/commandex/parameter.ex | 79 ++++++++++++++++-------- lib/commandex/type.ex | 56 +++++++++++++++++ mix.exs | 1 + test/commandex_test.exs | 14 +---- test/support/fetch_user_posts.ex | 23 +++++++ test/support/register_user.ex | 2 +- 7 files changed, 175 insertions(+), 102 deletions(-) create mode 100644 lib/commandex/type.ex create mode 100644 test/support/fetch_user_posts.ex diff --git a/lib/commandex.ex b/lib/commandex.ex index 52acaef..e251318 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}, {:valid, false}] do - Module.put_attribute(__MODULE__, :struct_fields, field) - end - try do import Commandex unquote(block) @@ -172,14 +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() + schema = %{params: params, pipelines: pipelines} - Module.put_attribute(__MODULE__, :struct_fields, {:__schema__, %{params: params}}) + # 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 """ @@ -196,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 @@ -218,8 +231,7 @@ defmodule Commandex do """ @spec run :: t def run do - new() - |> run() + new() |> run() end end @@ -230,7 +242,7 @@ 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__){__meta__: %{pipelines: pipelines}} = command) do pipelines |> Enum.reduce_while(command, fn fun, acc -> case acc do @@ -383,50 +395,10 @@ 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(%{__schema__: %{params: p}} = command, params) when is_list(params) do - params = for {key, _} <- p, into: %{}, do: {key, Keyword.get(params, key, :"$undefined")} - - command - |> do_cast_params(params) - |> maybe_mark_invalid() - end - - def parse_params(%{__schema__: %{params: p}} = command, params) when is_map(params) do - params = for {key, _} <- p, into: %{}, do: {key, get_param(params, key)} - - command - |> do_cast_params(params) - |> maybe_mark_invalid() - end - - defp do_cast_params(command, params) do - Enum.reduce(params, command, fn {key, val}, command -> - {type, opts} = command.__schema__.params[key] - - case Commandex.Parameter.cast(val, type, opts) do - {:ok, :"$undefined"} -> command - {:ok, cast_value} -> put_param(command, key, cast_value) - {:error, reason} -> put_error(command, key, reason) - end - end) - end - - defp get_param(params, key) do - case Map.get(params, key) do - nil -> Map.get(params, to_string(key), :"$undefined") - val -> val - end - end - - defp put_param(command, name, value) do - params = Map.put(command.params, name, value) - %{command | params: params} - end - - defp maybe_mark_invalid(command) do + def maybe_mark_invalid(command) do %{command | valid: Enum.empty?(command.errors)} end @@ -451,11 +423,15 @@ defmodule Commandex do :erlang.apply(m, f, [command, params, data] ++ a) end + # 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 Enum.any?(params, fn {p_name, _opts} -> p_name == name end) do diff --git a/lib/commandex/parameter.ex b/lib/commandex/parameter.ex index 5723970..55ab6e3 100644 --- a/lib/commandex/parameter.ex +++ b/lib/commandex/parameter.ex @@ -1,41 +1,66 @@ defmodule Commandex.Parameter do - def cast(value, type, opts \\ []) - - def cast(:"$undefined", type, opts) do - case Keyword.get(opts, :default) do - nil -> - case Keyword.get(opts, :required) do - true -> {:error, :required} - _ -> {:ok, :"$undefined"} - end - - default -> - cast(default, type, opts) - end + @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 cast(value, :any, _opts), do: {:ok, value} + 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 - def cast(value, :boolean, _opts) when is_boolean(value), do: {:ok, value} - def cast("true", :boolean, _opts), do: {:ok, true} - def cast("false", :boolean, _opts), do: {:ok, false} - def cast(_value, :boolean, _opts), do: {:error, :invalid} + {:ok, cast_value} -> + put_param(command, key, cast_value) - def cast(value, :integer, _opts) when is_integer(value) do - {:ok, value} + :error -> + command + |> put_param(key, val) + |> Commandex.put_error(key, :invalid) + end + end) + |> Commandex.maybe_mark_invalid() end - def cast(value, :integer, opts) when is_binary(value) do - cast(String.to_integer(value), :integer, opts) + 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 - def cast(value, :string, _opts) when is_binary(value) do - {:ok, value} + defp get_param(params, key, default) when is_list(params) do + Keyword.get(params, key, default) end - def cast(value, :string, opts) do - cast(inspect(value), :string, opts) + 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 - def cast(_value, _type, _opts), do: {:error, :invalid} + 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..5ff92aa --- /dev/null +++ b/lib/commandex/type.ex @@ -0,0 +1,56 @@ +defmodule Commandex.Type do + @undefined :"$undefined" + + @callback cast(term) :: {:ok, term} | :error + @callback validate(term) :: {:ok, term} | {:error, term, atom} + + # def cast(@undefined, type, opts) do + # case Keyword.get(opts, :default) do + # nil -> + # case Keyword.get(opts, :required) do + # true -> {:error, @undefined, :required} + # _ -> {:ok, @undefined} + # end + + # default -> + # cast(default, type, opts) + # end + # end + + def cast(@undefined, _type), do: {:ok, @undefined} + + def cast(value, :any), do: {:ok, value} + + def cast(value, :boolean) when is_boolean(value), do: {:ok, value} + def cast(value, :boolean) when value in ~w(true 1), do: {:ok, true} + def cast(value, :boolean) when value in ~w(false 0), do: {:ok, false} + def cast(_value, :boolean), do: :error + + def cast(value, :float) when is_binary(value) do + case Float.parse(value) do + {float, ""} -> {:ok, float} + _ -> :error + end + end + + def cast(value, :float) when is_float(value), do: {:ok, value} + def cast(value, :float) when is_integer(value), do: {:ok, :erlang.float(value)} + def cast(_, :float), do: :error + + def cast(value, :integer) when is_integer(value), do: {:ok, value} + def cast(value, :integer) when is_binary(value), do: cast(String.to_integer(value), :integer) + def cast(_value, :integer), do: :error + + def cast(value, :string) when is_binary(value), do: {:ok, value} + def cast(_value, :string), do: :error + + def cast(value, {:array, type}) when is_list(value) do + if Enum.all?(value, fn val -> cast(val, type) |> elem(0) == :ok end), + do: {:ok, value}, + else: :error + end + + def cast(_value, {:array, _type}), do: :error + + def cast(_value, _type), do: :error +end diff --git a/mix.exs b/mix.exs index 7e40223..1ffd70c 100644 --- a/mix.exs +++ b/mix.exs @@ -69,6 +69,7 @@ defmodule Commandex.MixProject do defp test_coverage do [ ignore_modules: [ + FetchUserPosts, GenerateReport, RegisterUser ], diff --git a/test/commandex_test.exs b/test/commandex_test.exs index 227e282..deedf29 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, @@ -137,7 +129,7 @@ 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} diff --git a/test/support/fetch_user_posts.ex b/test/support/fetch_user_posts.ex new file mode 100644 index 0000000..8356d4b --- /dev/null +++ b/test/support/fetch_user_posts.ex @@ -0,0 +1,23 @@ +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, min: 0, max: 50, default: 20 + param :offset, :integer, min: 0, default: 0 + param :sort_by, {:array, :string}, default: ["created_at"] + param :sort_dir, {:array, :string}, default: ["asc"] + + data :posts + + pipeline :fetch_posts + 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 cbf5f92..4a266ed 100644 --- a/test/support/register_user.ex +++ b/test/support/register_user.ex @@ -6,7 +6,7 @@ defmodule RegisterUser do import Commandex command do - param :email, default: "test@test.com" + param :email, :string, required: true param :password, :string, required: true param :agree_tos, :boolean, default: false param :limit, :integer, min: 0, max: 50, default: 20 From 4ab5ca2afb8b667b3c7dee6fb316d5d38901592b Mon Sep 17 00:00:00 2001 From: Henry Popp Date: Sun, 8 Sep 2024 19:45:33 -0500 Subject: [PATCH 4/5] refactor: type behaviour impls Also: - Validate required --- lib/commandex.ex | 6 ++++ lib/commandex/parameter.ex | 12 ++++++++ lib/commandex/type.ex | 51 +++++++------------------------- lib/commandex/type/boolean.ex | 9 ++++++ lib/commandex/type/float.ex | 15 ++++++++++ lib/commandex/type/integer.ex | 15 ++++++++++ lib/commandex/type/string.ex | 7 +++++ mix.exs | 2 +- test/support/fetch_user_posts.ex | 11 +++++-- test/support/register_user.ex | 2 -- 10 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 lib/commandex/type/boolean.ex create mode 100644 lib/commandex/type/float.ex create mode 100644 lib/commandex/type/integer.ex create mode 100644 lib/commandex/type/string.ex diff --git a/lib/commandex.ex b/lib/commandex.ex index e251318..6ad5c2d 100644 --- a/lib/commandex.ex +++ b/lib/commandex.ex @@ -242,6 +242,12 @@ defmodule Commandex do or the command struct itself. """ @spec run(map | Keyword.t() | t) :: t + 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 -> diff --git a/lib/commandex/parameter.ex b/lib/commandex/parameter.ex index 55ab6e3..b755b67 100644 --- a/lib/commandex/parameter.ex +++ b/lib/commandex/parameter.ex @@ -35,9 +35,21 @@ defmodule Commandex.Parameter do |> 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}} -> diff --git a/lib/commandex/type.ex b/lib/commandex/type.ex index 5ff92aa..e29eef3 100644 --- a/lib/commandex/type.ex +++ b/lib/commandex/type.ex @@ -2,55 +2,24 @@ defmodule Commandex.Type do @undefined :"$undefined" @callback cast(term) :: {:ok, term} | :error - @callback validate(term) :: {:ok, term} | {:error, term, atom} - - # def cast(@undefined, type, opts) do - # case Keyword.get(opts, :default) do - # nil -> - # case Keyword.get(opts, :required) do - # true -> {:error, @undefined, :required} - # _ -> {:ok, @undefined} - # end - - # default -> - # cast(default, type, opts) - # end - # end def cast(@undefined, _type), do: {:ok, @undefined} - def cast(value, :any), do: {:ok, value} - - def cast(value, :boolean) when is_boolean(value), do: {:ok, value} - def cast(value, :boolean) when value in ~w(true 1), do: {:ok, true} - def cast(value, :boolean) when value in ~w(false 0), do: {:ok, false} - def cast(_value, :boolean), do: :error - - def cast(value, :float) when is_binary(value) do - case Float.parse(value) do - {float, ""} -> {:ok, float} - _ -> :error - end - end - - def cast(value, :float) when is_float(value), do: {:ok, value} - def cast(value, :float) when is_integer(value), do: {:ok, :erlang.float(value)} - def cast(_, :float), do: :error - - def cast(value, :integer) when is_integer(value), do: {:ok, value} - def cast(value, :integer) when is_binary(value), do: cast(String.to_integer(value), :integer) - def cast(_value, :integer), do: :error - - def cast(value, :string) when is_binary(value), do: {:ok, value} - def cast(_value, :string), do: :error + def cast(value, :array), do: cast(value, {:array, :any}) def cast(value, {:array, type}) when is_list(value) do - if Enum.all?(value, fn val -> cast(val, type) |> elem(0) == :ok end), - do: {:ok, value}, - else: :error + 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..44ba358 --- /dev/null +++ b/lib/commandex/type/boolean.ex @@ -0,0 +1,9 @@ +defmodule Commandex.Type.Boolean do + @behaviour Commandex.Type + + @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..35bd101 --- /dev/null +++ b/lib/commandex/type/float.ex @@ -0,0 +1,15 @@ +defmodule Commandex.Type.Float do + @behaviour Commandex.Type + + @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..feceb90 --- /dev/null +++ b/lib/commandex/type/integer.ex @@ -0,0 +1,15 @@ +defmodule Commandex.Type.Integer do + @behaviour Commandex.Type + + @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..9eefa97 --- /dev/null +++ b/lib/commandex/type/string.ex @@ -0,0 +1,7 @@ +defmodule Commandex.Type.String do + @behaviour Commandex.Type + + @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 1ffd70c..8ccb58c 100644 --- a/mix.exs +++ b/mix.exs @@ -73,7 +73,7 @@ defmodule Commandex.MixProject do GenerateReport, RegisterUser ], - summary: [threshold: 70] + summary: [threshold: 50] ] end diff --git a/test/support/fetch_user_posts.ex b/test/support/fetch_user_posts.ex index 8356d4b..9f5edcc 100644 --- a/test/support/fetch_user_posts.ex +++ b/test/support/fetch_user_posts.ex @@ -7,8 +7,8 @@ defmodule FetchUserPosts do command do param :user_id, :integer, required: true - param :limit, :integer, min: 0, max: 50, default: 20 - param :offset, :integer, min: 0, default: 0 + 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"] @@ -17,6 +17,13 @@ defmodule FetchUserPosts do 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 diff --git a/test/support/register_user.ex b/test/support/register_user.ex index 4a266ed..2853fd5 100644 --- a/test/support/register_user.ex +++ b/test/support/register_user.ex @@ -9,8 +9,6 @@ defmodule RegisterUser do param :email, :string, required: true param :password, :string, required: true param :agree_tos, :boolean, default: false - param :limit, :integer, min: 0, max: 50, default: 20 - param :offset, :integer, min: 0, default: 0 data :user data :auth From 652fc81c3144f15ad8254d9e38a1d0b3dc20bf6a Mon Sep 17 00:00:00 2001 From: Henry Popp Date: Sun, 8 Sep 2024 22:47:18 -0500 Subject: [PATCH 5/5] test: add a lot of tests --- lib/commandex/type.ex | 38 ++++++++++++++++++++ lib/commandex/type/boolean.ex | 26 ++++++++++++++ lib/commandex/type/float.ex | 18 ++++++++++ lib/commandex/type/integer.ex | 15 ++++++++ lib/commandex/type/string.ex | 9 +++++ test/commandex/type_test.exs | 9 +++++ test/commandex_test.exs | 68 ++++++++++++++++++++++++++++++++--- 7 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 test/commandex/type_test.exs diff --git a/lib/commandex/type.ex b/lib/commandex/type.ex index e29eef3..3c776bd 100644 --- a/lib/commandex/type.ex +++ b/lib/commandex/type.ex @@ -3,6 +3,44 @@ defmodule Commandex.Type do @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}) diff --git a/lib/commandex/type/boolean.ex b/lib/commandex/type/boolean.ex index 44ba358..22b0e26 100644 --- a/lib/commandex/type/boolean.ex +++ b/lib/commandex/type/boolean.ex @@ -1,6 +1,32 @@ 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} diff --git a/lib/commandex/type/float.ex b/lib/commandex/type/float.ex index 35bd101..3ba56be 100644 --- a/lib/commandex/type/float.ex +++ b/lib/commandex/type/float.ex @@ -1,6 +1,24 @@ 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 diff --git a/lib/commandex/type/integer.ex b/lib/commandex/type/integer.ex index feceb90..ca27ad5 100644 --- a/lib/commandex/type/integer.ex +++ b/lib/commandex/type/integer.ex @@ -1,6 +1,21 @@ 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} diff --git a/lib/commandex/type/string.ex b/lib/commandex/type/string.ex index 9eefa97..8cabf71 100644 --- a/lib/commandex/type/string.ex +++ b/lib/commandex/type/string.ex @@ -1,6 +1,15 @@ 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 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 deedf29..b11f26f 100644 --- a/test/commandex_test.exs +++ b/test/commandex_test.exs @@ -28,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) @@ -52,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 @@ -63,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 @@ -136,6 +163,39 @@ defmodule CommandexTest do 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)