substation 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. data/.gitignore +37 -0
  2. data/.rspec +4 -0
  3. data/.rvmrc +1 -0
  4. data/.travis.yml +16 -0
  5. data/Gemfile +8 -0
  6. data/Gemfile.devtools +60 -0
  7. data/Guardfile +18 -0
  8. data/LICENSE +20 -0
  9. data/README.md +322 -0
  10. data/Rakefile +6 -0
  11. data/TODO +0 -0
  12. data/config/devtools.yml +2 -0
  13. data/config/flay.yml +3 -0
  14. data/config/flog.yml +2 -0
  15. data/config/mutant.yml +3 -0
  16. data/config/reek.yml +103 -0
  17. data/config/yardstick.yml +2 -0
  18. data/lib/substation/dispatcher.rb +262 -0
  19. data/lib/substation/observer.rb +66 -0
  20. data/lib/substation/request.rb +69 -0
  21. data/lib/substation/response.rb +178 -0
  22. data/lib/substation/support/utils.rb +68 -0
  23. data/lib/substation/version.rb +4 -0
  24. data/lib/substation.rb +40 -0
  25. data/spec/spec_helper.rb +34 -0
  26. data/spec/unit/substation/dispatcher/action/call_spec.rb +23 -0
  27. data/spec/unit/substation/dispatcher/action/class_methods/coerce_spec.rb +46 -0
  28. data/spec/unit/substation/dispatcher/action_names_spec.rb +13 -0
  29. data/spec/unit/substation/dispatcher/call_spec.rb +47 -0
  30. data/spec/unit/substation/dispatcher/class_methods/coerce_spec.rb +18 -0
  31. data/spec/unit/substation/observer/chain/call_spec.rb +26 -0
  32. data/spec/unit/substation/observer/class_methods/coerce_spec.rb +33 -0
  33. data/spec/unit/substation/observer/null/call_spec.rb +12 -0
  34. data/spec/unit/substation/request/env_spec.rb +14 -0
  35. data/spec/unit/substation/request/error_spec.rb +15 -0
  36. data/spec/unit/substation/request/input_spec.rb +14 -0
  37. data/spec/unit/substation/request/success_spec.rb +15 -0
  38. data/spec/unit/substation/response/env_spec.rb +16 -0
  39. data/spec/unit/substation/response/failure/success_predicate_spec.rb +15 -0
  40. data/spec/unit/substation/response/input_spec.rb +16 -0
  41. data/spec/unit/substation/response/output_spec.rb +16 -0
  42. data/spec/unit/substation/response/success/success_predicate_spec.rb +15 -0
  43. data/spec/unit/substation/utils/class_methods/coerce_callable_spec.rb +34 -0
  44. data/spec/unit/substation/utils/class_methods/const_get_spec.rb +46 -0
  45. data/spec/unit/substation/utils/class_methods/symbolize_keys_spec.rb +20 -0
  46. data/substation.gemspec +25 -0
  47. metadata +177 -0
@@ -0,0 +1,262 @@
1
+ module Substation
2
+
3
+ # Encapsulates all registered actions and their observers
4
+ #
5
+ # The only protocol actions must support is +#call(request)+.
6
+ # Actions are intended to be classes that handle one specific
7
+ # application use case.
8
+ class Dispatcher
9
+
10
+ # Encapsulates access to one registered action
11
+ class Action
12
+
13
+ # Raised when no action class name is configured
14
+ MissingHandlerError = Class.new(StandardError)
15
+
16
+ # Coerce the given +name+ and +config+ to an {Action} instance
17
+ #
18
+ # @param [Hash<Symbol, Object>] config
19
+ # the configuration hash
20
+ #
21
+ # @return [Action]
22
+ # the coerced instance
23
+ #
24
+ # @raise [MissingHandlerError]
25
+ # if no action handler is configured
26
+ #
27
+ # @raise [ArgumentError]
28
+ # if action or observer handlers are not coercible
29
+ #
30
+ # @api private
31
+ def self.coerce(config)
32
+ handler = config.fetch(:action) { raise(MissingHandlerError) }
33
+ observer = Observer.coerce(config[:observer])
34
+
35
+ new(Utils.coerce_callable(handler), observer)
36
+ end
37
+
38
+ include Concord.new(:handler, :observer)
39
+ include Adamantium
40
+
41
+ # Call the action
42
+ #
43
+ # @param [Substation::Request] request
44
+ # the request passed to the registered action
45
+ #
46
+ # @return [Substation::Response]
47
+ # the response returned when calling the action
48
+ #
49
+ # @api private
50
+ def call(request)
51
+ response = handler.call(request)
52
+ observer.call(response)
53
+ response
54
+ end
55
+
56
+ end # class Action
57
+
58
+ # Raised when trying to dispatch to an unregistered action
59
+ UnknownActionError = Class.new(StandardError)
60
+
61
+ # Coerce the given +config+ to a {Dispatcher} instance
62
+ #
63
+ # @example without observers
64
+ #
65
+ # dispatcher = Substation::Dispatcher.coerce({
66
+ # 'some_use_case' => { 'action' => 'SomeUseCase' }
67
+ # })
68
+ #
69
+ # @example with a single observer
70
+ #
71
+ # dispatcher = Substation::Dispatcher.coerce({
72
+ # 'some_use_case' => {
73
+ # 'action' => 'SomeUseCase',
74
+ # 'observer' => 'SomeObserver'
75
+ # }
76
+ # })
77
+ #
78
+ # @example with multiple observers
79
+ #
80
+ # dispatcher = Substation::Dispatcher.coerce({
81
+ # 'some_use_case' => {
82
+ # 'action' => 'SomeUseCase',
83
+ # 'observer' => [
84
+ # 'SomeObserver',
85
+ # 'AnotherObserver'
86
+ # ]
87
+ # }
88
+ # })
89
+ #
90
+ # @example with Symbol keys and const handlers
91
+ #
92
+ # module App
93
+ # class SomeUseCase
94
+ # def self.call(request)
95
+ # data = perform_work
96
+ # request.success(data)
97
+ # end
98
+ # end
99
+ #
100
+ # class SomeObserver
101
+ # def self.call(response)
102
+ # # do something
103
+ # end
104
+ # end
105
+ # end
106
+ #
107
+ # dispatcher = Substation::Dispatcher.coerce({
108
+ # :some_use_case => {
109
+ # :action => App::SomeUseCase,
110
+ # :observer => App::SomeObserver
111
+ # }
112
+ # })
113
+ #
114
+ # @example with Symbol keys and proc handlers
115
+ #
116
+ # dispatcher = Substation::Dispatcher.coerce({
117
+ # :some_use_case => {
118
+ # :action => Proc.new { |request| request.success(:foo) },
119
+ # :observer => Proc.new { |response| do_something }
120
+ # }
121
+ # })
122
+ #
123
+ # @param [Hash<#to_sym, Object>] config
124
+ # the action configuration
125
+ #
126
+ # @return [Dispatcher]
127
+ # the coerced instance
128
+ #
129
+ # @raise [Action::MissingHandlerError]
130
+ # if no action handler is configured
131
+ #
132
+ # @raise [ArgumentError]
133
+ # if action or observer handlers are not coercible
134
+ #
135
+ # @api public
136
+ def self.coerce(config)
137
+ new(normalize_config(config))
138
+ end
139
+
140
+ # Normalize the given +config+
141
+ #
142
+ # @param [Hash<#to_sym, Object>] config
143
+ # the action configuration
144
+ #
145
+ # @return [Hash<Symbol, Action>]
146
+ # the normalized config hash
147
+ #
148
+ # @raise [Action::MissingHandlerError]
149
+ # if no action handler is configured
150
+ #
151
+ # @raise [ArgumentError]
152
+ # if action or observer handlers are not coercible
153
+ #
154
+ # @api private
155
+ def self.normalize_config(config)
156
+ Utils.symbolize_keys(config).each_with_object({}) { |(name, hash), actions|
157
+ actions[name] = Action.coerce(hash)
158
+ }
159
+ end
160
+
161
+ private_class_method :normalize_config
162
+
163
+ include Concord.new(:actions)
164
+ include Adamantium
165
+
166
+ # Invoke the action identified by +name+
167
+ #
168
+ # @example
169
+ #
170
+ # module App
171
+ # class Environment
172
+ # def initialize(dispatcher, logger)
173
+ # @dispatcher, @logger = dispatcher, logger
174
+ # end
175
+ # end
176
+ #
177
+ # class SomeUseCase
178
+ # def self.call(request)
179
+ # data = perform_work
180
+ # request.success(data)
181
+ # end
182
+ # end
183
+ # end
184
+ #
185
+ # dispatcher = Substation::Dispatcher.coerce({
186
+ # :some_use_case => { :action => App::SomeUseCase }
187
+ # })
188
+ #
189
+ # env = App::Environment.new(dispatcher, Logger.new($stdout))
190
+ #
191
+ # response = dispatcher.call(:some_use_case, :some_input, env)
192
+ # response.success? # => true
193
+ #
194
+ # @param [Symbol] name
195
+ # a registered action name
196
+ #
197
+ # @param [Object] input
198
+ # the input model instance to pass to the action
199
+ #
200
+ # @param [Object] env
201
+ # the application environment
202
+ #
203
+ # @return [Response]
204
+ # the response returned when calling the action
205
+ #
206
+ # @raise [UnknownActionError]
207
+ # if no action is registered for +name+
208
+ #
209
+ # @api public
210
+ def call(name, input, env)
211
+ fetch(name).call(Request.new(env, input))
212
+ end
213
+
214
+ # The names of all registered actions
215
+ #
216
+ # @example
217
+ #
218
+ # module App
219
+ # class SomeUseCase
220
+ # def self.call(request)
221
+ # data = perform_work
222
+ # request.success(data)
223
+ # end
224
+ # end
225
+ # end
226
+ #
227
+ # dispatcher = Substation::Dispatcher.coerce({
228
+ # :some_use_case => { :action => App::SomeUseCase }
229
+ # })
230
+ #
231
+ # dispatcher.action_names # => #<Set: {:some_use_case}>
232
+ #
233
+ # @return [Set<Symbol>]
234
+ # the set of registered action names
235
+ #
236
+ # @api public
237
+ def action_names
238
+ Set.new(actions.keys)
239
+ end
240
+
241
+ memoize :action_names
242
+
243
+ private
244
+
245
+ # The action registered with +name+
246
+ #
247
+ # @param [Symbol] name
248
+ # a name for which an action is registered
249
+ #
250
+ # @return [Action]
251
+ # the action configuration registered for +name+
252
+ #
253
+ # @raise [KeyError]
254
+ # if no action is registered with +name+
255
+ #
256
+ # @api private
257
+ def fetch(name)
258
+ actions.fetch(name) { raise(UnknownActionError) }
259
+ end
260
+
261
+ end # class Dispatcher
262
+ end # module Substation
@@ -0,0 +1,66 @@
1
+ module Substation
2
+
3
+ # Abstract observer base class
4
+ #
5
+ # @abstract
6
+ class Observer
7
+
8
+ include AbstractType
9
+ include Adamantium::Flat
10
+
11
+ # Notify the observer
12
+ #
13
+ # @param [Response] response
14
+ # the response returned when calling the observed action
15
+ #
16
+ # @return [self]
17
+ #
18
+ # @api private
19
+ abstract_method :call
20
+
21
+ # Coerce +input+ to an instance of {Observer}
22
+ #
23
+ # @param [NilClass, String, Array<String>] input
24
+ # 0..n observer class names
25
+ #
26
+ # @return [Observer::NULL, Object, Observer::Chain]
27
+ # a null observer, an observer object, or a chain of observers
28
+ #
29
+ # @api private
30
+ def self.coerce(input)
31
+ case input
32
+ when NilClass
33
+ NULL
34
+ when Array
35
+ Chain.new(input.map { |item| coerce(item) })
36
+ else
37
+ Utils.coerce_callable(input)
38
+ end
39
+ end
40
+
41
+ # Null observer
42
+ NULL = Class.new(self) { def call(_response); self; end; }.new.freeze
43
+
44
+ # Chain of observers
45
+ class Chain < self
46
+
47
+ include Concord.new(:observers)
48
+
49
+ # Notify the observer
50
+ #
51
+ # @param [Response] response
52
+ # the response returned when calling the observed action
53
+ #
54
+ # @return [self]
55
+ #
56
+ # @api private
57
+ def call(response)
58
+ observers.each do |observer|
59
+ observer.call(response)
60
+ end
61
+ self
62
+ end
63
+
64
+ end # Chain
65
+ end # Observer
66
+ end # Substation
@@ -0,0 +1,69 @@
1
+ module Substation
2
+
3
+ # Encapsulates the application environment and an input model instance
4
+ class Request
5
+
6
+ include Concord.new(:env, :input)
7
+ include Adamantium
8
+
9
+ # Create a new successful response
10
+ #
11
+ # @example
12
+ #
13
+ # class SomeUseCase
14
+ # def self.call(request)
15
+ # data = perform_use_case
16
+ # request.success(data)
17
+ # end
18
+ # end
19
+ #
20
+ # @param [Object] output
21
+ # the data associated with the response
22
+ #
23
+ # @return [Response::Success]
24
+ #
25
+ # @api public
26
+ def success(output)
27
+ respond_with(Response::Success, output)
28
+ end
29
+
30
+ # Create a new failure response
31
+ #
32
+ # @example
33
+ #
34
+ # class SomeUseCase
35
+ # def self.call(request)
36
+ # error = perform_use_case
37
+ # request.error(error)
38
+ # end
39
+ # end
40
+ #
41
+ # @param [Object] output
42
+ # the data associated with the response
43
+ #
44
+ # @return [Response::Failure]
45
+ #
46
+ # @api public
47
+ def error(output)
48
+ respond_with(Response::Failure, output)
49
+ end
50
+
51
+ private
52
+
53
+ # Instantiate an instance of +klass+ and pass +output+
54
+ #
55
+ # @param [Response::Success, Response::Failure] klass
56
+ # the response class
57
+ #
58
+ # @param [Object] output
59
+ # the data associated with the response
60
+ #
61
+ # @return [Response::Success, Response::Failure]
62
+ #
63
+ # @api private
64
+ def respond_with(klass, output)
65
+ klass.new(self, output)
66
+ end
67
+
68
+ end # class Request
69
+ end # module Substation
@@ -0,0 +1,178 @@
1
+ module Substation
2
+
3
+ # Base class for action responses
4
+ #
5
+ # @abstract
6
+ class Response
7
+
8
+ include AbstractType
9
+ include Equalizer.new(:request, :output)
10
+ include Adamantium
11
+
12
+ # The environment used to return this response
13
+ #
14
+ # @return [Environment]
15
+ #
16
+ # @api private
17
+ attr_reader :env
18
+
19
+ # The request model instance passed into an action
20
+ #
21
+ # @example
22
+ #
23
+ # class SomeUseCase
24
+ # def self.call(request)
25
+ # data = perform_work
26
+ # request.success(data)
27
+ # end
28
+ # end
29
+ #
30
+ # env = Substation::Environment.coerce({
31
+ # 'some_use_case' => { 'action' => 'SomeUseCase' }
32
+ # })
33
+ #
34
+ # response = env.dispatch(:some_use_case, :input)
35
+ # response.input # => :input
36
+ #
37
+ # @see Request#input
38
+ #
39
+ # @return [Object]
40
+ #
41
+ # @api public
42
+ attr_reader :input
43
+
44
+ # The data wrapped inside an action {Response}
45
+ #
46
+ # @example
47
+ #
48
+ # class SomeUseCase
49
+ # def self.call(request)
50
+ # request.success(:output)
51
+ # end
52
+ # end
53
+ #
54
+ # env = Substation::Environment.coerce({
55
+ # 'some_use_case' => { 'action' => 'SomeUseCase' }
56
+ # })
57
+ #
58
+ # response = env.dispatch(:some_use_case, :input)
59
+ # response.output # => :output
60
+ #
61
+ # @return [Object]
62
+ #
63
+ # @api public
64
+ attr_reader :output
65
+
66
+ # Initialize a new instance
67
+ #
68
+ # @param [Request] request
69
+ # the request passed to the action that returned this response
70
+ #
71
+ # @param [Object] output
72
+ # the data returned from the action that returned this response
73
+ #
74
+ # @return [undefined]
75
+ #
76
+ # @api private
77
+ def initialize(request, output)
78
+ @request = request
79
+ @env = @request.env
80
+ @input = @request.input
81
+ @output = output
82
+ end
83
+
84
+ # Indicates wether this is a successful response or not
85
+ #
86
+ # @abstract
87
+ #
88
+ # @see Success#success?
89
+ # @see Failure#success?
90
+ #
91
+ # @example
92
+ #
93
+ # class SomeUseCase
94
+ # def self.call(request)
95
+ # request.success(:data)
96
+ # end
97
+ # end
98
+ #
99
+ # env = Substation::Environment.coerce({
100
+ # 'some_use_case' => { 'action' => 'SomeUseCase' }
101
+ # })
102
+ #
103
+ # response = env.dispatch(:some_use_case, :input)
104
+ # response.class # Substation::Response::Success
105
+ # response.success? # => true
106
+ #
107
+ # @return [Boolean]
108
+ # true if successful, false otherwise
109
+ #
110
+ # @api public
111
+ abstract_method :success?
112
+
113
+ protected
114
+
115
+ # The request that lead to this response
116
+ #
117
+ # @return [Request]
118
+ #
119
+ # @api private
120
+ attr_reader :request
121
+
122
+ # An errorneous {Response}
123
+ class Failure < self
124
+
125
+ # Tests wether this response was successful
126
+ #
127
+ # @example
128
+ #
129
+ # class SomeUseCase
130
+ # def self.call(request)
131
+ # request.error(:output)
132
+ # end
133
+ # end
134
+ #
135
+ # env = Substation::Environment.coerce({
136
+ # 'some_use_case' => { 'action' => 'SomeUseCase' }
137
+ # })
138
+ #
139
+ # response = env.dispatch(:some_use_case, :input)
140
+ # response.success? # => false
141
+ #
142
+ # @return [false]
143
+ #
144
+ # @api public
145
+ def success?
146
+ false
147
+ end
148
+ end
149
+
150
+ # A successful {Response}
151
+ class Success < self
152
+
153
+ # Tests wether this response was successful
154
+ #
155
+ # @example
156
+ #
157
+ # class SomeUseCase
158
+ # def self.call(request)
159
+ # request.success(:data)
160
+ # end
161
+ # end
162
+ #
163
+ # env = Substation::Environment.coerce({
164
+ # 'some_use_case' => { 'action' => 'SomeUseCase' }
165
+ # })
166
+ #
167
+ # response = env.dispatch(:some_use_case, :input)
168
+ # response.success? # => true
169
+ #
170
+ # @return [true]
171
+ #
172
+ # @api public
173
+ def success?
174
+ true
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,68 @@
1
+ module Substation
2
+
3
+ # A collection of utility methods
4
+ module Utils
5
+
6
+ # Get the constant for the given FQN
7
+ #
8
+ # @param [#to_s] name
9
+ # the FQN denoting a constant
10
+ #
11
+ # @return [Class, nil]
12
+ #
13
+ # @api private
14
+ def self.const_get(name)
15
+ list = name.to_s.split("::")
16
+ list.shift if list.first.empty?
17
+ obj = Object
18
+ list.each do |const|
19
+ # This is required because const_get tries to look for constants in the
20
+ # ancestor chain, but we only want constants that are HERE
21
+ obj =
22
+ if obj.const_defined?(const)
23
+ obj.const_get(const)
24
+ else
25
+ obj.const_missing(const)
26
+ end
27
+ end
28
+ obj
29
+ end
30
+
31
+ # Converts string keys into symbol keys
32
+ #
33
+ # @param [Hash<#to_sym, Object>] hash
34
+ # a hash with keys that respond to `#to_sym`
35
+ #
36
+ # @return [Hash<Symbol, Object>]
37
+ # a hash with symbol keys
38
+ #
39
+ # @api private
40
+ def self.symbolize_keys(hash)
41
+ hash.each_with_object({}) { |(key, value), normalized_hash|
42
+ normalized_value = value.is_a?(Hash) ? symbolize_keys(value) : value
43
+ normalized_hash[key.to_sym] = normalized_value
44
+ }
45
+ end
46
+
47
+ # Coerce the given +handler+ object
48
+ #
49
+ # @param [Symbol, String, Proc] handler
50
+ # a name denoting a const that responds to `#call(object)`, or a proc
51
+ #
52
+ # @return [Class, Proc]
53
+ # the callable action handler
54
+ #
55
+ # @api private
56
+ def self.coerce_callable(handler)
57
+ case handler
58
+ when Symbol, String
59
+ Utils.const_get(handler)
60
+ when Proc
61
+ handler
62
+ else
63
+ raise(ArgumentError)
64
+ end
65
+ end
66
+
67
+ end # module Utils
68
+ end # module Substation
@@ -0,0 +1,4 @@
1
+ module Substation
2
+ # Gem version
3
+ VERSION = '0.0.1'.freeze
4
+ end
data/lib/substation.rb ADDED
@@ -0,0 +1,40 @@
1
+ require 'set'
2
+ require 'forwardable'
3
+
4
+ require 'adamantium'
5
+ require 'equalizer'
6
+ require 'abstract_type'
7
+ require 'concord'
8
+
9
+ # Substation can be thought of as a domain level request router. It assumes
10
+ # that every usecase in your application has a name and is implemented in a
11
+ # dedicated class that will be referred to as an *action* in the context of
12
+ # substation. The only protocol such actions must support is `#call(request)`.
13
+ #
14
+ # The contract for actions specifies that when invoked, actions can
15
+ # receive arbitrary input data which will be available in `request.input`.
16
+ # Additionally, `request.env` contains an arbitrary object that
17
+ # represents your application environment and will typically provide access
18
+ # to useful things like a logger or a storage engine abstraction.
19
+ #
20
+ # The contract further specifies that every action must return an instance
21
+ # of either `Substation::Response::Success` or `Substation::Response::Failure`.
22
+ # Again, arbitrary data can be associated with any kind of response, and will
23
+ # be available in `response.data`. In addition to that, `response.success?` is
24
+ # available and will indicate wether invoking the action was successful or not.
25
+ #
26
+ # `Substation::Dispatcher` stores a mapping of action names to the actual
27
+ # objects implementing the action. Clients can use
28
+ # `Substation::Dispatcher#call(name, input, env)` to dispatch to any
29
+ # registered action. For example, a web application could map an http
30
+ # route to a specific action name and pass relevant http params on to the
31
+ # action.
32
+
33
+ module Substation
34
+ end
35
+
36
+ require 'substation/request'
37
+ require 'substation/response'
38
+ require 'substation/observer'
39
+ require 'substation/dispatcher'
40
+ require 'substation/support/utils'