Skip to content

Commit 9446bc7

Browse files
committed
feat: typed parameters
1 parent 8d61324 commit 9446bc7

14 files changed

Lines changed: 894 additions & 52 deletions

File tree

lib/commandex.ex

Lines changed: 142 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ defmodule Commandex do
1313
import Commandex
1414
1515
command do
16-
param :email
17-
param :password
16+
param :email, :string, required: true
17+
param :password, :string, required: true
1818
1919
data :password_hash
2020
data :user
@@ -53,21 +53,27 @@ defmodule Commandex do
5353
The `command/1` macro will define a struct that looks like:
5454
5555
%RegisterUser{
56+
__meta__: %{
57+
params: %{email: {:string, [required: true]}, password: {:string, [required: true]}},
58+
pipelines: [:hash_password, :create_user, :send_welcome_email]
59+
},
5660
success: false,
5761
halted: false,
5862
errors: %{},
5963
params: %{email: nil, password: nil},
60-
data: %{password_hash: nil, user: nil},
61-
pipelines: [:hash_password, :create_user, :send_welcome_email]
64+
data: %{password_hash: nil, user: nil}
6265
}
6366
6467
As well as two functions:
6568
6669
&RegisterUser.new/1
6770
&RegisterUser.run/1
6871
69-
`&new/1` parses parameters into a new struct. These can be either a keyword list
70-
or map with atom/string keys.
72+
`&new/1` parses and casts parameters into a new struct. These can be either a
73+
keyword list or map with atom/string keys. If a parameter has a type declared,
74+
the value will be cast to that type. If the cast fails, the parameter is set to
75+
`nil` and an `:invalid` error is added. If a parameter is marked as `required: true`
76+
and its value is `nil` after casting, a `:required` error is added.
7177
7278
`&run/1` takes a command struct and runs it through the pipeline functions defined
7379
in the command. **Functions are executed in the order in which they are defined**.
@@ -96,13 +102,29 @@ defmodule Commandex do
96102
97103
iex> GenerateReport.run()
98104
%GenerateReport{
99-
pipelines: [:fetch_data, :calculate_results],
105+
__meta__: %{params: %{}, pipelines: [:fetch_data, :calculate_results]},
100106
data: %{total_valid: 183220, total_invalid: 781215},
101107
params: %{},
102108
halted: false,
103109
errors: %{},
104110
success: true
105111
}
112+
113+
## Typed Parameters
114+
115+
Parameters can declare a type for automatic casting:
116+
117+
param :age, :integer
118+
param :email, :string, required: true
119+
param :score, :float, default: 0.0
120+
121+
Built-in types: `:string`, `:integer`, `:float`, `:boolean`, `:any`.
122+
Use `{:array, type}` for lists: `param :tags, {:array, :string}`.
123+
124+
Custom type modules implementing the `Commandex.Type` behaviour (or any module
125+
with a compatible `cast/1` function, such as an Ecto type) can also be used:
126+
127+
param :color, MyApp.Types.Color
106128
"""
107129

108130
@typedoc """
@@ -129,21 +151,21 @@ defmodule Commandex do
129151
130152
## Attributes
131153
154+
- `__meta__` - Compile-time schema containing param types and pipeline definitions.
132155
- `data` - Data generated during the pipeline, defined by `Commandex.data/1`.
133156
- `errors` - Errors generated during the pipeline with `Commandex.put_error/3`
134157
- `halted` - Whether or not the pipeline was halted.
135158
- `params` - Parameters given to the command, defined by `Commandex.param/1`.
136-
- `pipelines` - A list of pipeline functions to execute, defined by `Commandex.pipeline/1`.
137159
- `success` - Whether or not the command was successful. This is only set to
138160
`true` if the command was not halted after running all of the pipelines.
139161
"""
140162
@type command :: %{
141163
__struct__: atom(),
164+
__meta__: map(),
142165
data: map(),
143166
errors: map(),
144167
halted: boolean(),
145168
params: map(),
146-
pipelines: [pipeline()],
147169
success: boolean()
148170
}
149171

@@ -173,34 +195,57 @@ defmodule Commandex do
173195

174196
postlude =
175197
quote unquote: false do
176-
params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair
198+
raw_params = Module.get_attribute(__MODULE__, :params)
199+
200+
param_defaults =
201+
for {name, {_type, opts}} <- raw_params, into: %{} do
202+
{name, Keyword.get(opts, :default)}
203+
end
204+
205+
param_schema =
206+
for {name, {type, opts}} <- raw_params, into: %{} do
207+
{name, {type, Keyword.delete(opts, :default)}}
208+
end
209+
177210
data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair
178211
pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse()
179212

180-
Module.put_attribute(__MODULE__, :struct_fields, {:params, params})
213+
meta = %{params: param_schema, pipelines: pipelines}
214+
215+
Module.put_attribute(__MODULE__, :struct_fields, {:__meta__, meta})
216+
Module.put_attribute(__MODULE__, :struct_fields, {:params, param_defaults})
181217
Module.put_attribute(__MODULE__, :struct_fields, {:data, data})
182-
Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines})
183218
defstruct @struct_fields
184219

220+
param_type_specs =
221+
for {name, {type, _opts}} <- raw_params do
222+
{name, Commandex.type_to_spec(type)}
223+
end
224+
225+
data_type_specs =
226+
for {name, _} <- Module.get_attribute(__MODULE__, :data) do
227+
{name, quote(do: term())}
228+
end
229+
185230
@typedoc """
186231
Command struct.
187232
188233
## Attributes
189234
235+
- `__meta__` - Compile-time schema containing param types and pipeline definitions.
190236
- `data` - Data generated during the pipeline, defined by `Commandex.data/1`.
191237
- `errors` - Errors generated during the pipeline with `Commandex.put_error/3`
192238
- `halted` - Whether or not the pipeline was halted.
193239
- `params` - Parameters given to the command, defined by `Commandex.param/1`.
194-
- `pipelines` - A list of pipeline functions to execute, defined by `Commandex.pipeline/1`.
195240
- `success` - Whether or not the command was successful. This is only set to
196241
`true` if the command was not halted after running all of the pipelines.
197242
"""
198243
@type t :: %__MODULE__{
199-
data: map(),
244+
__meta__: map(),
245+
data: %{unquote_splicing(data_type_specs)},
200246
errors: map(),
201247
halted: boolean(),
202-
params: map(),
203-
pipelines: [Commandex.pipeline()],
248+
params: %{unquote_splicing(param_type_specs)},
204249
success: boolean()
205250
}
206251

@@ -209,10 +254,10 @@ defmodule Commandex do
209254
"""
210255
@spec new(map() | Keyword.t()) :: t()
211256
def new(opts \\ []) do
212-
Commandex.parse_params(%__MODULE__{}, opts)
257+
Commandex.Parameter.cast_params(%__MODULE__{}, opts)
213258
end
214259

215-
if Enum.empty?(params) do
260+
if Enum.empty?(param_defaults) do
216261
@doc """
217262
Runs given pipelines in order and returns command struct.
218263
"""
@@ -229,7 +274,7 @@ defmodule Commandex do
229274
or the command struct itself.
230275
"""
231276
@spec run(map() | Keyword.t() | t()) :: t()
232-
def run(%unquote(__MODULE__){pipelines: pipelines} = command) do
277+
def run(%unquote(__MODULE__){__meta__: %{pipelines: pipelines}} = command) do
233278
pipelines
234279
|> Enum.reduce_while(command, fn fun, acc ->
235280
case acc do
@@ -256,18 +301,37 @@ defmodule Commandex do
256301
257302
Parameters are supplied at struct creation, before any pipelines are run.
258303
304+
## Untyped
305+
259306
command do
260307
param :email
261-
param :password
308+
param :name, default: "Anonymous"
309+
end
262310
263-
# ...data
264-
# ...pipelines
311+
## Typed
312+
313+
Typed parameters are automatically cast in `new/1`:
314+
315+
command do
316+
param :email, :string, required: true
317+
param :age, :integer
318+
param :score, :float, default: 0.0
319+
param :tags, {:array, :string}
320+
param :color, MyApp.Types.Color
265321
end
322+
323+
Built-in types: `:string`, `:integer`, `:float`, `:boolean`, `:any`.
324+
325+
## Options
326+
327+
- `:default` - Default value if not provided. Defaults to `nil`.
328+
- `:required` - If `true`, adds a `:required` error when the value is `nil`
329+
after casting.
266330
"""
267-
@spec param(atom(), Keyword.t()) :: no_return()
268-
defmacro param(name, opts \\ []) do
331+
@spec param(atom(), atom() | {:array, atom()} | Keyword.t(), Keyword.t()) :: no_return()
332+
defmacro param(name, type_or_opts \\ :any, opts \\ []) do
269333
quote do
270-
Commandex.__param__(__MODULE__, unquote(name), unquote(opts))
334+
Commandex.__param__(__MODULE__, unquote(name), unquote(type_or_opts), unquote(opts))
271335
end
272336
end
273337

@@ -389,23 +453,30 @@ defmodule Commandex do
389453
%{command | halted: true, success: success}
390454
end
391455

456+
@doc """
457+
Halts the command if any errors are present.
458+
459+
Useful as a pipeline gate after casting and custom validation pipelines
460+
to aggregate all errors before stopping execution.
461+
462+
command do
463+
param :email, :string, required: true
464+
param :age, :integer
465+
466+
pipeline :validate_age
467+
pipeline &Commandex.halt_on_errors/1
468+
pipeline :create_user
469+
end
470+
"""
471+
@spec halt_on_errors(command()) :: command()
472+
def halt_on_errors(%{errors: errors} = command) when errors == %{}, do: command
473+
def halt_on_errors(command), do: halt(command)
474+
392475
@doc false
393476
@spec maybe_mark_successful(command()) :: command()
394477
def maybe_mark_successful(%{halted: false} = command), do: %{command | success: true}
395478
def maybe_mark_successful(command), do: command
396479

397-
@doc false
398-
@spec parse_params(command(), map() | Keyword.t()) :: command()
399-
def parse_params(%{params: p} = struct, params) when is_list(params) do
400-
params = for {key, _} <- p, into: %{}, do: {key, Keyword.get(params, key, p[key])}
401-
%{struct | params: params}
402-
end
403-
404-
def parse_params(%{params: p} = struct, %{} = params) do
405-
params = for {key, _} <- p, into: %{}, do: {key, get_param(params, key, p[key])}
406-
%{struct | params: params}
407-
end
408-
409480
@doc false
410481
@spec apply_fun(command(), pipeline()) :: command()
411482
def apply_fun(%mod{params: params, data: data} = command, name) when is_atom(name) do
@@ -429,16 +500,21 @@ defmodule Commandex do
429500
end
430501

431502
@doc false
432-
@spec __param__(module(), atom(), Keyword.t()) :: :ok
433-
def __param__(mod, name, opts) do
503+
@spec __param__(module(), atom(), atom() | {:array, atom()} | Keyword.t(), Keyword.t()) :: :ok
504+
def __param__(mod, name, type_or_opts, opts)
505+
506+
def __param__(mod, name, opts, []) when is_list(opts) do
507+
__param__(mod, name, :any, opts)
508+
end
509+
510+
def __param__(mod, name, type, opts) do
434511
params = Module.get_attribute(mod, :params)
435512

436513
if List.keyfind(params, name, 0) do
437514
raise ArgumentError, "param #{inspect(name)} is already set on command"
438515
end
439516

440-
default = Keyword.get(opts, :default)
441-
Module.put_attribute(mod, :params, {name, default})
517+
Module.put_attribute(mod, :params, {name, {type, opts}})
442518
end
443519

444520
@doc false
@@ -479,14 +555,31 @@ defmodule Commandex do
479555
raise ArgumentError, "pipeline #{inspect(name)} is not valid"
480556
end
481557

482-
@spec get_param(map(), atom(), term()) :: term()
483-
defp get_param(params, key, default) do
484-
case Map.get(params, key) do
485-
nil ->
486-
Map.get(params, to_string(key), default)
558+
@doc false
559+
@spec type_to_spec(atom() | {:array, atom()}) :: Macro.t()
560+
def type_to_spec(:any), do: quote(do: term())
561+
def type_to_spec(:string), do: quote(do: String.t() | nil)
562+
def type_to_spec(:integer), do: quote(do: integer() | nil)
563+
def type_to_spec(:float), do: quote(do: float() | nil)
564+
def type_to_spec(:boolean), do: quote(do: boolean() | nil)
565+
566+
def type_to_spec({:array, inner_type}) do
567+
inner = type_to_spec_inner(inner_type)
568+
quote(do: [unquote(inner)] | nil)
569+
end
487570

488-
val ->
489-
val
490-
end
571+
def type_to_spec(module) when is_atom(module) do
572+
quote(do: unquote(module).t() | nil)
573+
end
574+
575+
@spec type_to_spec_inner(atom()) :: Macro.t()
576+
defp type_to_spec_inner(:any), do: quote(do: term())
577+
defp type_to_spec_inner(:string), do: quote(do: String.t())
578+
defp type_to_spec_inner(:integer), do: quote(do: integer())
579+
defp type_to_spec_inner(:float), do: quote(do: float())
580+
defp type_to_spec_inner(:boolean), do: quote(do: boolean())
581+
582+
defp type_to_spec_inner(module) when is_atom(module) do
583+
quote(do: unquote(module).t())
491584
end
492585
end

lib/commandex/parameter.ex

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule Commandex.Parameter do
2+
@moduledoc false
3+
4+
@spec cast_params(struct(), map() | Keyword.t()) :: struct()
5+
def cast_params(command, input) when is_list(input) do
6+
cast_params(command, Enum.into(input, %{}))
7+
end
8+
9+
def cast_params(%{__meta__: %{params: schema}, params: defaults} = command, %{} = input) do
10+
Enum.reduce(schema, command, fn {key, {type, opts}}, acc ->
11+
default = Map.get(defaults, key)
12+
raw = get_param(input, key, default)
13+
14+
acc
15+
|> cast_value(key, raw, type)
16+
|> check_required(key, opts)
17+
end)
18+
end
19+
20+
@spec get_param(map(), atom(), term()) :: term()
21+
defp get_param(params, key, default) do
22+
case Map.get(params, key) do
23+
nil -> Map.get(params, to_string(key), default)
24+
val -> val
25+
end
26+
end
27+
28+
@spec cast_value(struct(), atom(), term(), atom() | {:array, atom()}) :: struct()
29+
defp cast_value(command, key, raw, type) do
30+
case Commandex.Type.cast(raw, type) do
31+
{:ok, cast_value} ->
32+
put_in(command, [Access.key!(:params), Access.key!(key)], cast_value)
33+
34+
:error ->
35+
command
36+
|> put_in([Access.key!(:params), Access.key!(key)], nil)
37+
|> Commandex.put_error(key, :invalid)
38+
end
39+
end
40+
41+
@spec check_required(struct(), atom(), Keyword.t()) :: struct()
42+
defp check_required(command, key, opts) do
43+
if Keyword.get(opts, :required, false) do
44+
case get_in(command, [Access.key!(:params), Access.key!(key)]) do
45+
nil -> Commandex.put_error(command, key, :required)
46+
_ -> command
47+
end
48+
else
49+
command
50+
end
51+
end
52+
end

0 commit comments

Comments
 (0)