substation 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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'