@@ -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
492585end
0 commit comments