slayer 0.3.1 → 0.4.0.beta2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: edff62ac6c3e45fdd46766caf1c9c49633413f48
4
- data.tar.gz: 433b5038d863ad187fbe4537fb535dbb31c428c4
3
+ metadata.gz: b6ddcd92133adc30cc39358c00cfd4cf8494d879
4
+ data.tar.gz: b427f2b04fe18881c4448de84e06e2593a1bcece
5
5
  SHA512:
6
- metadata.gz: 4c3a4d548e02caccc81c52d431146c345f3a4c5a8e74ce1e5141c971c8097cc9f4ee304e63aaadc4333cb4dfdc846ad2fccac1e20abe25890684b1122657d766
7
- data.tar.gz: ef8cc22437e472d3f59e5056441735550e24e5b6fb440b382df8c1cc0e9176451b40f80dae05cecb22f87d2ea84368124456f3e5113f318c9d545aadb6b3ede9
6
+ metadata.gz: 578355462582a7e68883d7aa7d73f9e52f4d8870d797c63f3f75061c9ccdd445342e9f51c0348583a022403e3a7569503808952764b07816536627eca2cf5a95
7
+ data.tar.gz: 6ce3eda0fce30e75602a50fefd714d3df12baf17a89f1fdd37914c6b47120fd1488830ac4e6a8a63fbae6b8c2410a51ddce178296b56788ebba1d9543a102865
data/.gitignore CHANGED
@@ -9,4 +9,6 @@
9
9
  /spec/reports/
10
10
  /tmp/
11
11
  /log*/*
12
- .byebug_history
12
+ .byebug_history
13
+
14
+ .DS_Store
data/README.md CHANGED
@@ -16,13 +16,15 @@ Slayer provides 3 base classes for organizing your business logic: `Forms`, `Com
16
16
 
17
17
  ### Commands
18
18
 
19
- `Slayer::Commands` are the bread and butter of your application's business logic. `Commands` are where you compose services, and perform one-off business logic tasks. In our applications, we usually create a single `Command` per `Controller` endpoint.
19
+ `Slayer::Commands` are the bread and butter of your application's business logic, and a specific implementation of the `Slayer::Service` object. `Commands` are where you compose services, and perform one-off business logic tasks. In our applications, we usually create a single `Command` per `Controller` endpoint.
20
20
 
21
- `Commands` should call `Services`, but `Services` should never call `Commands`.
21
+ `Slayer::Commands` must implement a `call` method, which always return a structured `Slayer::Result` object making operating on results straightforward. The `call` method can also take a block, which provides `Slayer::ResultMatcher` object, and enforces handling of both `pass` and `fail` conditions for that result.
22
+
23
+ This helps provide confidence that your core business logic is behaving in expected ways, and helps coerce you to develop in a clean and testable way.
22
24
 
23
25
  ### Services
24
26
 
25
- `Services` are the building blocks of `Commands`, and encapsulate re-usable chunks of application logic.
27
+ `Slayer::Service`s are the base class of `Slayer::Command`s, and encapsulate re-usable chunks of application logic. `Services` also return structured `Slayer::Result` objects.
26
28
 
27
29
  ## Installation
28
30
 
@@ -44,6 +46,224 @@ Or install it yourself as:
44
46
  $ gem install slayer
45
47
  ```
46
48
 
49
+ ## Usage
50
+
51
+ ### Commands
52
+
53
+ Slayer Commands should implement `call`, which will `pass` or `fail` the service based on input. Commands return a `Slayer::Result` which has a predictable interface for determining `success?` or `failure?`, a 'value' payload object, a 'status' value, and a user presentable `message`.
54
+
55
+ ```ruby
56
+ # A Command that passes when given the string "foo"
57
+ # and fails if given anything else.
58
+ class FooCommand < Slayer::Command
59
+ def call(foo:)
60
+ unless foo == "foo"
61
+ flunk! value: foo, message: "Argument must be foo!"
62
+ end
63
+
64
+ pass! value: foo
65
+ end
66
+ end
67
+ ```
68
+
69
+ Handling the results of a command can be done in two ways. The primary way is through a handler block. This block is passed a handler object, which is in turn given blocks to handle different result outcomes:
70
+
71
+ ```ruby
72
+ FooCommand.call(foo: "foo") do |m|
73
+ m.pass do |result|
74
+ puts "This code runs on success"
75
+ end
76
+
77
+ m.fail do |result|
78
+ puts "This code runs on failure. Message: #{result.message}"
79
+ end
80
+
81
+ m.all do
82
+ puts "This code runs on failure or success"
83
+ end
84
+
85
+ m.ensure do
86
+ puts "This code always runs after other handler blocks"
87
+ end
88
+ end
89
+ ```
90
+
91
+ The second is less comprehensive, but can be useful for very simple commands. The `call` method on a `Command` returns its result object, which has statuses set on itself:
92
+
93
+ ```ruby
94
+ result = FooCommand.call(foo: "foo")
95
+ puts result.success? # => true
96
+
97
+ result = FooCommand.call(foo: "bar")
98
+ puts result.success? # => false
99
+ ```
100
+
101
+ Here's a more complex example demonstrating how the command pattern can be used to encapuslate the logic for validating and creating a new user. This example is shown using a `rails` controller, but the same approach can be used regardless of the framework.
102
+
103
+ ```ruby
104
+ # commands/user_controller.rb
105
+ class CreateUserCommand < Slayer::Command
106
+ def call(create_user_form:)
107
+ unless arguments_valid?(create_user_form)
108
+ flunk! value: create_user_form, status: :arguments_invalid
109
+ end
110
+
111
+ user = nil
112
+ transaction do
113
+ user = User.create(create_user_form.attributes)
114
+ end
115
+
116
+ unless user.persisted?
117
+ flunk! message: I18n.t('user.create.error'), status: :unprocessible_entity
118
+ end
119
+
120
+ pass! value: user
121
+ end
122
+
123
+ def arguments_valid?(create_user_form)
124
+ create_user_form.kind_of?(CreateUserForm) &&
125
+ create_user_form.valid? &&
126
+ !User.exists?(email: create_user_form.email)
127
+ end
128
+ end
129
+
130
+ # controllers/user_controller.rb
131
+ class UsersController < ApplicationController
132
+ def create
133
+ @create_user_form = CreateUserForm.from_params(create_user_params)
134
+
135
+ CreateUserCommand.call(create_user_form: @create_user_form) do |m|
136
+ m.pass do |user|
137
+ auto_login(user)
138
+ redirect_to root_path, notice: t('user.create.success')
139
+ end
140
+
141
+ m.fail(:arguments_invalid) do |result|
142
+ flash[:error] = result.errors.full_messages.to_sentence
143
+ render :new, status: :unprocessible_entity
144
+ end
145
+
146
+ m.fail do |result|
147
+ flash[:error] = t('user.create.error')
148
+ render :new, status: :bad_request
149
+ end
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def required_user_params
156
+ [:first_name, :last_name, :email, :password]
157
+ end
158
+
159
+ def create_user_params
160
+ permitted_params = required_user_params << :password_confirmation
161
+ params.require(:user).permit(permitted_params)
162
+ end
163
+ end
164
+ ```
165
+
166
+ ### Result Matcher
167
+
168
+ The result matcher is an object that is used to handle `Slayer::Result` objects based on their status.
169
+
170
+ #### Handlers: `pass`, `fail`, `all`, `ensure`
171
+
172
+ The result matcher block can take 4 types of handler blocks: `pass`, `fail`, `all`, and `ensure`. They operate as you would expect based on their names.
173
+
174
+ * The `pass` block runs if the command was successful.
175
+ * The `fail` block runs if the command was `flunked`.
176
+ * The `all` block runs on any type of result --- `pass` or `fail` --- unless the result has already been handled.
177
+ * The `ensure` block always runs after the result has been handled.
178
+
179
+ #### Handler Params
180
+
181
+ Every handler in the result matcher block is given three arguments: `value`, `result`, and `command`. These encapsulate the `value` provided in the `pass!` or `flunk!` call from the `Command`, the returned `Slayer::Result` object, and the `Slayer::Command` instance that was just run:
182
+
183
+ ```ruby
184
+ class NoArgCommand < Slayer::Command
185
+ def call
186
+ @instance_var = 'instance'
187
+ pass value: 'pass'
188
+ end
189
+ end
190
+
191
+
192
+ NoArgCommand.call do |m|
193
+ m.all do |value, result, command|
194
+ puts value # => 'pass'
195
+ puts result.success? # => true
196
+ puts command.instance_var # => 'instance'
197
+ end
198
+ endpoint
199
+ ```
200
+
201
+ #### Statuses
202
+
203
+ You can pass a `status` flag to both the `pass!` and `flunk!` methods that allows the result matcher to process different kinds of successes and failures differently:
204
+
205
+ ```ruby
206
+ class StatusCommand < Slayer::Command
207
+ def call
208
+ flunk! message: "Generic flunk"
209
+ flunk! message: "Specific flunk", status: :specific_flunk
210
+ flunk! message: "Extra specific flunk", status: :extra_specific_flunk
211
+
212
+ pass! message: "Generic pass"
213
+ pass! message: "Specific pass", status: :specific_pass
214
+ end
215
+ end
216
+
217
+ StatusCommand.call do |m|
218
+ m.fail { puts "generic fail" }
219
+ m.fail(:specific_flunk) { puts "specific flunk" }
220
+ m.fail(:extra_specific_flunk) { puts "extra specific flunk" }
221
+
222
+ m.pass { puts "generic pass" }
223
+ m.pass(:specific_pass) { puts "specific pass" }
224
+ end
225
+ ```
226
+
227
+ ### Forms
228
+
229
+ ### Services
230
+
231
+ ## RSpec & Minitest Integrations
232
+
233
+ `Slayer` provides assertions and matchers that make testing your `Commands` simpler.
234
+
235
+ ### RSpec
236
+
237
+ To use with RSpec, update your `spec_helper.rb` file to include:
238
+
239
+ `require 'slayer/rspec'`
240
+
241
+ This provides you with two new matchers: `be_successful_result` and `be_failed_result`, both of which can be chained with a `with_status` expectation:
242
+
243
+ ```ruby
244
+ RSpec.describe RSpecCommand do
245
+ describe '#call' do
246
+ context 'should pass' do
247
+ subject(:result) { RSpecCommand.call(should_pass: true) }
248
+
249
+ it { is_expected.to be_success_result }
250
+ it { is_expected.not_to be_failed_result }
251
+ it { is_expected.not_to be_successful_result.with_status(:no_status) }
252
+ end
253
+
254
+ context 'should fail' do
255
+ subject(:result) { RSpecCommand.call(should_pass: false) }
256
+
257
+ it { is_expected.to be_failed_result }
258
+ it { is_expected.not_to be_failed_result }
259
+ it { is_expected.not_to be_failed_result.with_status(:no_status) }
260
+ end
261
+ end
262
+ end
263
+ ```
264
+
265
+ ### Minitest
266
+
47
267
  ## Rails Integration
48
268
 
49
269
  While Slayer is independent of any framework, we do offer a first-class integration with Ruby on Rails. To install the Rails extensions, add this line to your application's Gemfile:
@@ -121,58 +341,6 @@ $ bin/rails g slayer:command foo_command
121
341
  $ bin/rails g slayer:service foo_service
122
342
  ```
123
343
 
124
- ## Usage
125
-
126
- ### Commands
127
-
128
- Slayer Commands should implement `call`, which will `pass` or `fail` the service based on input. Commands return a `Slayer::Result` which has a predictable interface for determining `success?` or `failure?`, a 'value' payload object, a 'status' value, and a user presentable `message`.
129
-
130
- ```ruby
131
- # A Command that passes when given the string "foo"
132
- # and fails if given anything else.
133
- class FooCommand < Slayer::Command
134
- def call(foo:)
135
- if foo == "foo"
136
- pass! value: foo, message: "Passing FooCommand"
137
- else
138
- fail! value: foo, message: "Failing FooCommand"
139
- end
140
- end
141
- end
142
-
143
- result = FooCommand.call(foo: "foo")
144
- result.success? # => true
145
-
146
- result = FooCommand.call(foo: "bar")
147
- result.success? # => false
148
- ```
149
-
150
- ### Forms
151
-
152
- ### Services
153
-
154
- Slayer Services are objects that should implement re-usable pieces of application logic or common tasks. To prevent circular dependencies Services are required to declare which other Service classes they depend on. If a circular dependency is detected an error is raised.
155
-
156
- In order to enforce the lack of circular dependencies, Service objects can only call other Services that are declared in their dependencies.
157
-
158
- ```ruby
159
- class NetworkService < Slayer::Service
160
- def self.post()
161
- ...
162
- end
163
- end
164
-
165
- class StripeService < Slayer::Service
166
- dependencies NetworkService
167
-
168
- def self.pay()
169
- ...
170
- NetworkService.post(url: "stripe.com", body: my_payload)
171
- ...
172
- end
173
- end
174
- ```
175
-
176
344
  ## Development
177
345
 
178
346
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -185,7 +353,6 @@ To generate documentation run `yard`. To view undocumented files run `yard stats
185
353
 
186
354
  Bug reports and pull requests are welcome on GitHub at https://github.com/apsislabs/slayer.
187
355
 
188
-
189
356
  ## License
190
357
 
191
358
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -7,4 +7,4 @@ Rake::TestTask.new(:test) do |t|
7
7
  t.test_files = FileList['test/**/*_test.rb']
8
8
  end
9
9
 
10
- task :default => :test
10
+ task default: :test
@@ -2,6 +2,7 @@ require 'ext/string_ext' unless defined?(Rails)
2
2
 
3
3
  require 'slayer/version'
4
4
  require 'slayer/errors'
5
+ require 'slayer/hook'
5
6
  require 'slayer/result'
6
7
  require 'slayer/result_matcher'
7
8
  require 'slayer/service'
@@ -1,73 +1,18 @@
1
1
  module Slayer
2
- class Command
3
- attr_accessor :result
2
+ class Command < Service
3
+ singleton_skip_hook :call
4
4
 
5
- # Internal: Command Class Methods
6
5
  class << self
7
- def call(*args, &block)
8
- execute_call(block, *args) { |c, *a| c.run(*a) }
9
- end
10
-
11
- def call!(*args, &block)
12
- execute_call(block, *args) { |c, *a| c.run!(*a) }
6
+ def method_added(name)
7
+ return unless name == :call
8
+ super(name)
13
9
  end
14
10
 
15
- private
16
-
17
- def execute_call(command_block, *args)
18
- # Run the Command and capture the result
19
- command = self.new
20
- result = command.tap { yield(command, *args) }.result
21
-
22
- # Throw an exception if we don't return a result
23
- raise CommandNotImplementedError unless result.is_a? Result
24
-
25
- # Run the command block if one was provided
26
- unless command_block.nil?
27
- matcher = Slayer::ResultMatcher.new(result, command)
28
-
29
- command_block.call(matcher)
30
-
31
- # raise error if not all defaults were handled
32
- unless matcher.handled_defaults?
33
- raise(CommandResultNotHandledError, 'The pass or fail condition of a result was not handled')
34
- end
35
-
36
- begin
37
- matcher.execute_matching_block
38
- ensure
39
- matcher.execute_ensure_block
40
- end
41
- end
42
-
43
- return result
11
+ def call(*args, &block)
12
+ self.new.call(*args, &block)
44
13
  end
45
- end # << self
46
-
47
- def run(*args)
48
- call(*args)
49
- rescue CommandFailureError
50
- # Swallow the Command Failure
51
- end
52
-
53
- # Run the Command
54
- def run!(*args)
55
- call(*args)
56
- end
57
-
58
- # Fail the Command
59
-
60
- def fail!(value: nil, status: :default, message: nil)
61
- @result = Result.new(value, status, message)
62
- @result.fail!
63
- end
64
-
65
- # Pass the Command
66
- def pass!(value: nil, status: :default, message: nil)
67
- @result = Result.new(value, status, message)
68
14
  end
69
15
 
70
- # Call the command
71
16
  def call
72
17
  raise NotImplementedError, 'Commands must define method `#call`.'
73
18
  end
@@ -1,5 +1,5 @@
1
1
  module Slayer
2
- class CommandFailureError < StandardError
2
+ class ResultFailureError < StandardError
3
3
  attr_reader :result
4
4
 
5
5
  def initialize(result)
@@ -10,13 +10,12 @@ module Slayer
10
10
 
11
11
  class CommandNotImplementedError < StandardError
12
12
  def initialize(message = nil)
13
- message ||= 'Command implementation must call `fail!` or `pass!`, or '\
14
- 'return a <Slayer::Result> object'
13
+ message ||= 'Command implementation must return a <Slayer::Result> object'
15
14
  super message
16
15
  end
17
16
  end
18
17
 
19
- class CommandResultNotHandledError < StandardError; end
18
+ class ResultNotHandledError < StandardError; end
20
19
  class FormValidationError < StandardError; end
21
20
  class ServiceDependencyError < StandardError; end
22
21
  end
@@ -0,0 +1,154 @@
1
+ module Slayer
2
+ # Hook adds the ability to wrap all calls to a class in a wrapper method. The
3
+ # wrapper method is provided with a block that can be used to invoke the called
4
+ # method.
5
+ #
6
+ # The methods #skip_hook and #only_hook can be used to control which methods are
7
+ # and are not wrapped with the hook call.
8
+ #
9
+ # @example Including Hook on a class.
10
+ # class MyHookedClass
11
+ # include Hook
12
+ #
13
+ # hook :say_hello_and_goodbye
14
+ #
15
+ # def self.say_hello_and_goodbye
16
+ # puts "hello!"
17
+ #
18
+ # yield # calls hooked method
19
+ #
20
+ # puts "goodbye!"
21
+ # end
22
+ #
23
+ # def self.say_something
24
+ # puts "something"
25
+ # end
26
+ # end
27
+ #
28
+ # MyHookedClass.say_something
29
+ # # => "hello!"
30
+ # # "something"
31
+ # # "goodbye!"
32
+ #
33
+ # @example Skipping Hooks
34
+ #
35
+ # skip_hook :say_something, :do_something # the hook method will not be invoked for
36
+ # # these methods. They will be called directly.
37
+ #
38
+ # @example Only hooking
39
+ #
40
+ # only_hook :see_something, :hear_something # These are the only methods that will be
41
+ # # be hooked. All other methods will be
42
+ # # called directly.
43
+ module Hook
44
+ def self.included(klass)
45
+ klass.extend ClassMethods
46
+ end
47
+
48
+ # Everything in Hook::ClassMethods automatically get extended onto the
49
+ # class that includes Hook.
50
+ module ClassMethods
51
+ # Define the method that will be invoked whenever another method is invoked.
52
+ # This should be a class method.
53
+ def hook(hook_method)
54
+ @__hook = hook_method
55
+ end
56
+
57
+ # Define the set of methods that should always be called directly, and should
58
+ # never be hooked
59
+ def skip_hook(*hook_skips)
60
+ @__hook_skips = hook_skips
61
+ end
62
+
63
+ def singleton_skip_hook(*hook_skips)
64
+ @__singleton_hook_skips = hook_skips
65
+ end
66
+
67
+ # If only_hook is called then only the methods provided will be hooked. All
68
+ # other methods will be called directly.
69
+ def only_hook(*hook_only)
70
+ @__hook_only = hook_only
71
+ end
72
+
73
+ private
74
+
75
+ def __hook
76
+ @__hook ||= nil
77
+ end
78
+
79
+ def __hook_skips
80
+ @__hook_skips ||= []
81
+ end
82
+
83
+ def __singleton_hook_skips
84
+ @__singleton_hook_skips ||= []
85
+ end
86
+
87
+ def __hook_only
88
+ @__hook_only ||= nil
89
+ end
90
+
91
+ def __current_methods
92
+ @__current_methods ||= nil
93
+ end
94
+
95
+ def run_hook(name, instance, passed_block, &block)
96
+ if __hook
97
+ send(__hook, name, instance, passed_block, &block)
98
+ else
99
+ # rubocop:disable Performance/RedundantBlockCall
100
+ block.call
101
+ # rubocop:enable Performance/RedundantBlockCall
102
+ end
103
+ end
104
+
105
+ def singleton_method_added(name)
106
+ insert_hook_for(name,
107
+ define_method_fn: :define_singleton_method,
108
+ hook_target: self,
109
+ alias_target: singleton_class,
110
+ skip_methods: blacklist(__singleton_hook_skips))
111
+ end
112
+
113
+ def method_added(name)
114
+ insert_hook_for(name,
115
+ define_method_fn: :define_method,
116
+ hook_target: self,
117
+ alias_target: self,
118
+ skip_methods: blacklist(__hook_skips))
119
+ end
120
+
121
+ def insert_hook_for(name, define_method_fn:, hook_target:, alias_target:, skip_methods: [])
122
+ return if __current_methods && __current_methods.include?(name)
123
+
124
+ with_hooks = :"__#{name}_with_hooks"
125
+ without_hooks = :"__#{name}_without_hooks"
126
+
127
+ return if __hook_only && !__hook_only.include?(name.to_sym)
128
+ return if skip_methods.include? name.to_sym
129
+
130
+ @__current_methods = [name, with_hooks, without_hooks]
131
+ send(define_method_fn, with_hooks) do |*args, &block|
132
+ hook_target.send(:run_hook, name, self, block) do
133
+ send(without_hooks, *args)
134
+ end
135
+ end
136
+
137
+ alias_target.send(:alias_method, without_hooks, name)
138
+ alias_target.send(:alias_method, name, with_hooks)
139
+
140
+ @__current_methods = nil
141
+ end
142
+
143
+ def blacklist(additional = [])
144
+ list = Object.methods(false) + Object.private_methods(false)
145
+ list += Slayer::Hook::ClassMethods.private_instance_methods(false)
146
+ list += Slayer::Hook::ClassMethods.instance_methods(false)
147
+ list += additional
148
+ list << __hook if __hook
149
+
150
+ list
151
+ end
152
+ end
153
+ end
154
+ end
@@ -13,12 +13,12 @@ module Slayer
13
13
  end
14
14
 
15
15
  def failure?
16
- @failure || false
16
+ @failure ||= false
17
17
  end
18
18
 
19
- def fail!
20
- @failure = true
21
- raise CommandFailureError, self
19
+ def fail
20
+ @failure ||= true
21
+ self
22
22
  end
23
23
  end
24
24
  end
@@ -30,7 +30,7 @@ module Slayer
30
30
  #
31
31
  # If the block form of a {Command.call} is invoked, both the block must handle the default
32
32
  # status for both a {Result#success?} and a {Result#failure?}. If both are not handled,
33
- # the matching block will not be invoked and a {CommandResultNotHandledError} will be
33
+ # the matching block will not be invoked and a {ResultNotHandledError} will be
34
34
  # raised.
35
35
  #
36
36
  # @example Matcher invokes the matching pass block, with precedence given to {#pass} and {#fail}
@@ -74,7 +74,7 @@ module Slayer
74
74
  # m.pass(:ok) { puts "Pass, OK!"}
75
75
  # m.fail { puts "Fail!" }
76
76
  # end
77
- # # => raises CommandResultNotHandledError (because no default pass was provided)
77
+ # # => raises ResultNotHandledError (because no default pass was provided)
78
78
  #
79
79
  # # Call produces a successful Result with status :ok
80
80
  # SuccessCommand.call do |m|
@@ -94,7 +94,7 @@ module Slayer
94
94
  # SuccessCommand.call do |m|
95
95
  # m.pass(:ok, :default) { puts "Pass, OK!"}
96
96
  # end
97
- # # => raises CommandResultNotHandledError (because no default fail was provided)
97
+ # # => raises ResultNotHandledError (because no default fail was provided)
98
98
  class ResultMatcher
99
99
  attr_reader :result, :command
100
100
 
@@ -0,0 +1,42 @@
1
+ require 'rspec/expectations'
2
+
3
+ RSpec::Matchers.define :be_success_result do
4
+ match do |result|
5
+ result.success?
6
+ end
7
+
8
+ chain :with_status do |status|
9
+ @status = status
10
+ end
11
+
12
+ failure_message do |result|
13
+ return 'expected command to succeed' if @status.nil?
14
+ return "expected command to succeed with status :#{@status}, but got :#{result.status}"
15
+ end
16
+
17
+ failure_message_when_negated do |result|
18
+ return "expected command not to have status :#{@status}" if @status.present? && result.status == @status
19
+ return 'expected command to fail'
20
+ end
21
+ end
22
+
23
+ RSpec::Matchers.define :be_failed_result do
24
+ match do |result|
25
+ return result.failure? if @status.nil?
26
+ return result.failure? && (result.status == @status)
27
+ end
28
+
29
+ chain :with_status do |status|
30
+ @status = status
31
+ end
32
+
33
+ failure_message do |result|
34
+ return 'expected command to fail' if @status.nil?
35
+ return "expected command to fail with status :#{@status}, but got :#{result.status}"
36
+ end
37
+
38
+ failure_message_when_negated do |result|
39
+ return "expected command not to have status :#{@status}" if @status.present? && result.status == @status
40
+ return 'expected command to succeed'
41
+ end
42
+ end
@@ -1,182 +1,98 @@
1
1
  module Slayer
2
2
  # Slayer Services are objects that should implement re-usable pieces of
3
- # application logic or common tasks. To prevent circular dependencies Services
4
- # are required to declare which other Service classes they depend on. If a
5
- # circular dependency is detected an error is raised.
6
- #
7
- # In order to enforce the lack of circular dependencies, Service objects can
8
- # only call other Services that are declared in their dependencies.
3
+ # application logic or common tasks. All methods in a service are wrapped
4
+ # by default to enforce the return of a +Slayer::Result+ object.
9
5
  class Service
10
- # List the other Service class that this service class depends on. Only
11
- # dependencies that are included in this call my be invoked from class
12
- # or instances methods of this service class.
13
- #
14
- # If no dependencies are provided, no other Service classes may be used by
15
- # this Service class.
16
- #
17
- # @param deps [Array<Class>] An array of the other Slayer::Service classes that are used as dependencies
18
- #
19
- # @example Service calls with dependency declared
20
- # class StripeService < Slayer::Service
21
- # dependencies NetworkService
22
- #
23
- # def self.pay()
24
- # ...
25
- # NetworkService.post(url: "stripe.com", body: my_payload) # OK
26
- # ...
27
- # end
28
- # end
29
- #
30
- # @example Service calls without a dependency declared
31
- # class JiraApiService < Slayer::Service
32
- #
33
- # def self.create_issue()
34
- # ...
35
- # NetworkService.post(url: "stripe.com", body: my_payload) # Raises Slayer::ServiceDependencyError
36
- # ...
37
- # end
38
- # end
39
- #
40
- # @return [Array<Class>] The transitive closure of dependencies for this object.
41
- def self.dependencies(*deps)
42
- raise(ServiceDependencyError, "There were multiple dependencies calls of #{self}") if @deps
43
-
44
- deps.each do |dep|
45
- unless dep.is_a?(Class)
46
- raise(ServiceDependencyError, "The object #{dep} passed to dependencies service was not a class")
47
- end
48
-
49
- unless dep < Slayer::Service
50
- raise(ServiceDependencyError, "The object #{dep} passed to dependencies was not a subclass of #{self}")
51
- end
52
- end
6
+ include Hook
53
7
 
54
- unless deps.uniq.length == deps.length
55
- raise(ServiceDependencyError, "There were duplicate dependencies in #{self}")
56
- end
57
-
58
- @deps = deps
8
+ skip_hook :pass, :flunk, :flunk!, :try!
9
+ singleton_skip_hook :pass, :flunk, :flunk!, :try!
59
10
 
60
- # Calculate the transitive dependencies and raise an error if there are circular dependencies
61
- transitive_dependencies
62
- end
11
+ attr_accessor :result
63
12
 
64
13
  class << self
14
+ # Create a passing Result
15
+ def pass(value: nil, status: :default, message: nil)
16
+ Result.new(value, status, message)
17
+ end
65
18
 
66
- attr_reader :deps
67
-
68
- def transitive_dependencies(dependency_hash = {}, visited = [])
69
- return @transitive_dependencies if @transitive_dependencies
70
-
71
- @deps ||= []
19
+ # Create a failing Result
20
+ def flunk(value: nil, status: :default, message: nil)
21
+ Result.new(value, status, message).fail
22
+ end
72
23
 
73
- # If we've already visited ourself, bail out. This is necessary to halt
74
- # execution for a circular chain of dependencies. #halting-problem-solved
75
- return dependency_hash[self] if visited.include?(self)
24
+ # Create a failing Result and halt execution of the Command
25
+ def flunk!(value: nil, status: :default, message: nil)
26
+ raise ResultFailureError, flunk(value: value, status: status, message: message)
27
+ end
76
28
 
77
- visited << self
78
- dependency_hash[self] ||= []
29
+ # If the block produces a successful result the value of the result will be
30
+ # returned. Otherwise, this will create a failing result and halt the execution
31
+ # of the Command.
32
+ def try!(value: nil, status: nil, message: nil)
33
+ r = yield
34
+ flunk!(value: value, status: status || :default, message: message) unless r.is_a?(Result)
35
+ return r.value if r.success?
36
+ flunk!(value: value || r.value, status: status || r.status, message: message || r.message)
37
+ end
38
+ end
79
39
 
80
- # Add each of our dependencies (and it's transitive dependency chain) to our
81
- # own dependencies.
40
+ def pass(*args)
41
+ self.class.pass(*args)
42
+ end
82
43
 
83
- @deps.each do |dep|
84
- dependency_hash[self] << dep
44
+ def flunk(*args)
45
+ self.class.flunk(*args)
46
+ end
85
47
 
86
- unless visited.include?(dep)
87
- child_transitive_dependencies = dep.transitive_dependencies(dependency_hash, visited)
88
- dependency_hash[self].concat(child_transitive_dependencies)
89
- end
48
+ def flunk!(*args)
49
+ self.class.flunk!(*args)
50
+ end
90
51
 
91
- dependency_hash[self].uniq
92
- end
52
+ def try!(*args, &block)
53
+ self.class.try!(*args, &block)
54
+ end
93
55
 
94
- # NO CIRCULAR DEPENDENCIES!
95
- if dependency_hash[self].include? self
96
- raise(ServiceDependencyError, "#{self} had a circular dependency")
97
- end
56
+ # Make sure child classes also hook correctly
57
+ def self.inherited(klass)
58
+ klass.include Hook
59
+ klass.hook :__service_hook
60
+ end
98
61
 
99
- # Store these now, so next time we can short-circuit.
100
- @transitive_dependencies = dependency_hash[self]
62
+ hook :__service_hook
101
63
 
102
- return @transitive_dependencies
64
+ # rubocop:disable Metrics/MethodLength
65
+ def self.__service_hook(_, instance, service_block)
66
+ begin
67
+ result = yield
68
+ rescue ResultFailureError => error
69
+ result = error.result
103
70
  end
104
71
 
105
- def before_each_method(*)
106
- @deps ||= []
107
- @@allowed_services ||= nil
72
+ raise CommandNotImplementedError unless result.is_a? Result
108
73
 
109
- # Confirm that this method call is allowed
110
- raise_if_not_allowed
74
+ unless service_block.nil?
75
+ matcher = Slayer::ResultMatcher.new(result, instance)
111
76
 
112
- @@allowed_services ||= []
113
- @@allowed_services << (@deps + [self])
114
- end
77
+ service_block.call(matcher)
115
78
 
116
- def raise_if_not_allowed
117
- if @@allowed_services
118
- allowed = @@allowed_services.last
119
- if !allowed || !allowed.include?(self)
120
- raise(ServiceDependencyError, "Attempted to call #{self} from another #{Slayer::Service}"\
121
- ' which did not declare it as a dependency')
122
- end
79
+ # raise error if not all defaults were handled
80
+ unless matcher.handled_defaults?
81
+ raise(ResultNotHandledError, 'The pass or fail condition of a result was not handled')
123
82
  end
124
- end
125
83
 
126
- def after_each_method(*)
127
- @@allowed_services.pop
128
- @@allowed_services = nil if @@allowed_services.empty?
129
- end
130
-
131
- def singleton_method_added(name)
132
- return if self == Slayer::Service
133
- return if @__last_methods_added && @__last_methods_added.include?(name)
134
-
135
- with = :"#{name}_with_before_each_method"
136
- without = :"#{name}_without_before_each_method"
137
-
138
- @__last_methods_added = [name, with, without]
139
- define_singleton_method with do |*args, &block|
140
- before_each_method name
141
- begin
142
- send without, *args, &block
143
- rescue
144
- raise
145
- ensure
146
- after_each_method name
147
- end
84
+ begin
85
+ matcher.execute_matching_block
86
+ ensure
87
+ matcher.execute_ensure_block
148
88
  end
149
-
150
- singleton_class.send(:alias_method, without, name)
151
- singleton_class.send(:alias_method, name, with)
152
-
153
- @__last_methods_added = nil
154
89
  end
90
+ return result
91
+ end
92
+ # rubocop:enable Metrics/MethodLength
155
93
 
156
- def method_added(name)
157
- return if self == Slayer::Service
158
- return if @__last_methods_added && @__last_methods_added.include?(name)
159
-
160
- with = :"#{name}_with_before_each_method"
161
- without = :"#{name}_without_before_each_method"
162
-
163
- @__last_methods_added = [name, with, without]
164
- define_method with do |*args, &block|
165
- self.class.before_each_method name
166
- begin
167
- send without, *args, &block
168
- rescue
169
- raise
170
- ensure
171
- self.class.after_each_method name
172
- end
173
- end
174
-
175
- alias_method without, name
176
- alias_method name, with
94
+ private_class_method :inherited
95
+ private_class_method :__service_hook
177
96
 
178
- @__last_methods_added = nil
179
- end
180
- end # << self
181
97
  end # class Service
182
98
  end # module Slayer
@@ -1,3 +1,3 @@
1
1
  module Slayer
2
- VERSION = '0.3.1'
2
+ VERSION = '0.4.0.beta2'
3
3
  end
@@ -1,4 +1,4 @@
1
- # coding: utf-8
1
+
2
2
  lib = File.expand_path('../lib', __FILE__)
3
3
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
  require 'slayer/version'
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.authors = ['Wyatt Kirby', 'Noah Callaway']
10
10
  spec.email = ['wyatt@apsis.io', 'noah@apsis.io']
11
11
 
12
- spec.summary = %q{A killer service layer}
12
+ spec.summary = 'A killer service layer'
13
13
  spec.homepage = 'http://www.apsis.io'
14
14
  spec.license = 'MIT'
15
15
 
@@ -19,16 +19,15 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ['lib']
20
20
 
21
21
  spec.add_dependency 'virtus', '~> 1.0'
22
- spec.add_dependency 'dry-validation', '~> 0.10'
23
22
 
24
- spec.add_development_dependency 'coveralls'
25
- spec.add_development_dependency 'simplecov', '~> 0.13'
26
23
  spec.add_development_dependency 'bundler', '~> 1.12'
27
- spec.add_development_dependency 'rake', '~> 10.0'
24
+ spec.add_development_dependency 'byebug', '~> 9.0'
25
+ spec.add_development_dependency 'coveralls'
28
26
  spec.add_development_dependency 'minitest', '~> 5.0'
29
27
  spec.add_development_dependency 'minitest-reporters', '~> 1.1'
30
28
  spec.add_development_dependency 'mocha', '~> 1.2'
31
- spec.add_development_dependency 'byebug', '~> 9.0'
29
+ spec.add_development_dependency 'rubocop', '~> 0.48.1'
30
+ spec.add_development_dependency 'rake', '~> 10.0'
31
+ spec.add_development_dependency 'simplecov', '~> 0.13'
32
32
  spec.add_development_dependency 'yard', '~> 0.9'
33
- spec.add_development_dependency 'rubocop', '~> 0.47.1'
34
33
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slayer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wyatt Kirby
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2017-03-10 00:00:00.000000000 Z
12
+ date: 2018-05-14 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: virtus
@@ -26,131 +26,131 @@ dependencies:
26
26
  - !ruby/object:Gem::Version
27
27
  version: '1.0'
28
28
  - !ruby/object:Gem::Dependency
29
- name: dry-validation
29
+ name: bundler
30
30
  requirement: !ruby/object:Gem::Requirement
31
31
  requirements:
32
32
  - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '0.10'
35
- type: :runtime
34
+ version: '1.12'
35
+ type: :development
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: '0.10'
41
+ version: '1.12'
42
42
  - !ruby/object:Gem::Dependency
43
- name: coveralls
43
+ name: byebug
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
- - - ">="
46
+ - - "~>"
47
47
  - !ruby/object:Gem::Version
48
- version: '0'
48
+ version: '9.0'
49
49
  type: :development
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
- - - ">="
53
+ - - "~>"
54
54
  - !ruby/object:Gem::Version
55
- version: '0'
55
+ version: '9.0'
56
56
  - !ruby/object:Gem::Dependency
57
- name: simplecov
57
+ name: coveralls
58
58
  requirement: !ruby/object:Gem::Requirement
59
59
  requirements:
60
- - - "~>"
60
+ - - ">="
61
61
  - !ruby/object:Gem::Version
62
- version: '0.13'
62
+ version: '0'
63
63
  type: :development
64
64
  prerelease: false
65
65
  version_requirements: !ruby/object:Gem::Requirement
66
66
  requirements:
67
- - - "~>"
67
+ - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: '0.13'
69
+ version: '0'
70
70
  - !ruby/object:Gem::Dependency
71
- name: bundler
71
+ name: minitest
72
72
  requirement: !ruby/object:Gem::Requirement
73
73
  requirements:
74
74
  - - "~>"
75
75
  - !ruby/object:Gem::Version
76
- version: '1.12'
76
+ version: '5.0'
77
77
  type: :development
78
78
  prerelease: false
79
79
  version_requirements: !ruby/object:Gem::Requirement
80
80
  requirements:
81
81
  - - "~>"
82
82
  - !ruby/object:Gem::Version
83
- version: '1.12'
83
+ version: '5.0'
84
84
  - !ruby/object:Gem::Dependency
85
- name: rake
85
+ name: minitest-reporters
86
86
  requirement: !ruby/object:Gem::Requirement
87
87
  requirements:
88
88
  - - "~>"
89
89
  - !ruby/object:Gem::Version
90
- version: '10.0'
90
+ version: '1.1'
91
91
  type: :development
92
92
  prerelease: false
93
93
  version_requirements: !ruby/object:Gem::Requirement
94
94
  requirements:
95
95
  - - "~>"
96
96
  - !ruby/object:Gem::Version
97
- version: '10.0'
97
+ version: '1.1'
98
98
  - !ruby/object:Gem::Dependency
99
- name: minitest
99
+ name: mocha
100
100
  requirement: !ruby/object:Gem::Requirement
101
101
  requirements:
102
102
  - - "~>"
103
103
  - !ruby/object:Gem::Version
104
- version: '5.0'
104
+ version: '1.2'
105
105
  type: :development
106
106
  prerelease: false
107
107
  version_requirements: !ruby/object:Gem::Requirement
108
108
  requirements:
109
109
  - - "~>"
110
110
  - !ruby/object:Gem::Version
111
- version: '5.0'
111
+ version: '1.2'
112
112
  - !ruby/object:Gem::Dependency
113
- name: minitest-reporters
113
+ name: rubocop
114
114
  requirement: !ruby/object:Gem::Requirement
115
115
  requirements:
116
116
  - - "~>"
117
117
  - !ruby/object:Gem::Version
118
- version: '1.1'
118
+ version: 0.48.1
119
119
  type: :development
120
120
  prerelease: false
121
121
  version_requirements: !ruby/object:Gem::Requirement
122
122
  requirements:
123
123
  - - "~>"
124
124
  - !ruby/object:Gem::Version
125
- version: '1.1'
125
+ version: 0.48.1
126
126
  - !ruby/object:Gem::Dependency
127
- name: mocha
127
+ name: rake
128
128
  requirement: !ruby/object:Gem::Requirement
129
129
  requirements:
130
130
  - - "~>"
131
131
  - !ruby/object:Gem::Version
132
- version: '1.2'
132
+ version: '10.0'
133
133
  type: :development
134
134
  prerelease: false
135
135
  version_requirements: !ruby/object:Gem::Requirement
136
136
  requirements:
137
137
  - - "~>"
138
138
  - !ruby/object:Gem::Version
139
- version: '1.2'
139
+ version: '10.0'
140
140
  - !ruby/object:Gem::Dependency
141
- name: byebug
141
+ name: simplecov
142
142
  requirement: !ruby/object:Gem::Requirement
143
143
  requirements:
144
144
  - - "~>"
145
145
  - !ruby/object:Gem::Version
146
- version: '9.0'
146
+ version: '0.13'
147
147
  type: :development
148
148
  prerelease: false
149
149
  version_requirements: !ruby/object:Gem::Requirement
150
150
  requirements:
151
151
  - - "~>"
152
152
  - !ruby/object:Gem::Version
153
- version: '9.0'
153
+ version: '0.13'
154
154
  - !ruby/object:Gem::Dependency
155
155
  name: yard
156
156
  requirement: !ruby/object:Gem::Requirement
@@ -165,20 +165,6 @@ dependencies:
165
165
  - - "~>"
166
166
  - !ruby/object:Gem::Version
167
167
  version: '0.9'
168
- - !ruby/object:Gem::Dependency
169
- name: rubocop
170
- requirement: !ruby/object:Gem::Requirement
171
- requirements:
172
- - - "~>"
173
- - !ruby/object:Gem::Version
174
- version: 0.47.1
175
- type: :development
176
- prerelease: false
177
- version_requirements: !ruby/object:Gem::Requirement
178
- requirements:
179
- - - "~>"
180
- - !ruby/object:Gem::Version
181
- version: 0.47.1
182
168
  description:
183
169
  email:
184
170
  - wyatt@apsis.io
@@ -205,8 +191,10 @@ files:
205
191
  - lib/slayer/command.rb
206
192
  - lib/slayer/errors.rb
207
193
  - lib/slayer/form.rb
194
+ - lib/slayer/hook.rb
208
195
  - lib/slayer/result.rb
209
196
  - lib/slayer/result_matcher.rb
197
+ - lib/slayer/rspec.rb
210
198
  - lib/slayer/service.rb
211
199
  - lib/slayer/version.rb
212
200
  - slayer.gemspec
@@ -226,12 +214,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
226
214
  version: '0'
227
215
  required_rubygems_version: !ruby/object:Gem::Requirement
228
216
  requirements:
229
- - - ">="
217
+ - - ">"
230
218
  - !ruby/object:Gem::Version
231
- version: '0'
219
+ version: 1.3.1
232
220
  requirements: []
233
221
  rubyforge_project:
234
- rubygems_version: 2.6.8
222
+ rubygems_version: 2.6.13
235
223
  signing_key:
236
224
  specification_version: 4
237
225
  summary: A killer service layer