slayer 0.1.0 → 0.2.0

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: 731adbde67b9dfe482a51fb05b76e3f77cd0f615
4
- data.tar.gz: 1e482c6f28b9d9173252cdce74f18995ca77f0b3
3
+ metadata.gz: 14e54657ae9bed79dbb867f2c28a0c72a57d64bb
4
+ data.tar.gz: ea6a0f28be0e58b77fdde6675f35362bd0f4a57f
5
5
  SHA512:
6
- metadata.gz: eb418185d91671b33a0433cd19c63e42e2d0dcb828b47cfbf0a477855d59ebda1a645c5b23eec0adb29cac1429fdb9f7185097295b3df01a2ef375303a77b2ed
7
- data.tar.gz: 0e823344bfcb291cbee00d09a96c5fb5023868ea5166ddd46a13214f968dff6d178e6e276f308baaa1617fda79ff96fc3d88b5a820ec944bea2609472f8b4c9a
6
+ metadata.gz: 3bd6939e5a23675abe05b13506a276bb0cc3905b554e134cd543b800a94e6f33b3ab458b52c1fd394e4b6b4adea4bbc338c292d6419a5d13a05a06518fa3e896
7
+ data.tar.gz: ff07ca08187a2c6c13ff9f41e2fbad207d4f3607671faa940795d8fc6e612bf74b7be6ccc954075c5f0fed6779b61adde7f2607ef16abfe64d40920eaf6beb53
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+
3
+ echo "Starting unit tests"
4
+ rake test
5
+ if [ $? -ne 0 ]; then
6
+ echo ""
7
+ echo ""
8
+ echo "Unit tests failed; push aborted!"
9
+ exit 1
10
+ fi
11
+
12
+ echo
13
+ echo "Starting rubocop"
14
+ rubocop --format worst --format simple --format offenses
15
+ if [ $? -ne 0 ]; then
16
+ echo ""
17
+ echo ""
18
+ echo "Rubocop failed; push aborted!"
19
+ exit 1
20
+ fi
21
+
22
+ echo
23
+ echo "All pre-push checks passed! Pushing to remote"
data/.gitignore CHANGED
@@ -9,3 +9,4 @@
9
9
  /spec/reports/
10
10
  /tmp/
11
11
  /log*/*
12
+ .byebug_history
@@ -0,0 +1,2 @@
1
+ ruby:
2
+ config_file: .rubocop.yml
@@ -0,0 +1,61 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
3
+ AllCops:
4
+ TargetRubyVersion: 2.3
5
+ Include:
6
+ - 'lib/**/*.rb'
7
+ - 'test/**/*.rb'
8
+ Exclude:
9
+ - 'bin/**/*'
10
+
11
+
12
+ Style/RedundantSelf:
13
+ Enabled: false
14
+
15
+ Style/RedundantReturn:
16
+ Enabled: false
17
+
18
+ Style/GuardClause:
19
+ Enabled: false
20
+
21
+ Style/ClassAndModuleChildren:
22
+ Enabled: false
23
+
24
+ Style/EmptyLinesAroundClassBody:
25
+ Enabled: false
26
+
27
+ Style/FrozenStringLiteralComment:
28
+ Enabled: false
29
+
30
+ Style/CommentIndentation:
31
+ Enabled: false
32
+
33
+ Style/BracesAroundHashParameters:
34
+ Enabled: false
35
+
36
+ Style/IndentationConsistency:
37
+ EnforcedStyle: rails
38
+
39
+ Metrics/LineLength:
40
+ Max: 120
41
+
42
+ Metrics/ClassLength:
43
+ Max: 120
44
+
45
+ Style/EmptyLineBetweenDefs:
46
+ AllowAdjacentOneLineDefs: true
47
+
48
+ # Temporarily disabled until this can be resolved in the todo file
49
+ # Style/Documentation:
50
+ # Exclude:
51
+ # - 'spec/**/*'
52
+ # - 'test/**/*'
53
+ # - 'lib/ext/**/*'
54
+
55
+ Style/ClassVars:
56
+ Exclude:
57
+ - 'lib/slayer/service.rb'
58
+
59
+ Style/MutableConstant:
60
+ Exclude:
61
+ - 'lib/slayer/version.rb'
@@ -0,0 +1,48 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config`
3
+ # on 2017-02-13 09:02:04 -0800 using RuboCop version 0.47.1.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 2
10
+ Lint/HandleExceptions:
11
+ Exclude:
12
+ - 'lib/slayer/command.rb'
13
+ - 'test/lib/result_matcher_test.rb'
14
+
15
+ # Offense count: 2
16
+ # Cop supports --auto-correct.
17
+ # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods.
18
+ Lint/UnusedMethodArgument:
19
+ Exclude:
20
+ - 'lib/slayer/form.rb'
21
+
22
+ # Offense count: 1
23
+ Metrics/AbcSize:
24
+ Max: 19
25
+
26
+ # Offense count: 1
27
+ Metrics/CyclomaticComplexity:
28
+ Max: 9
29
+
30
+ # Offense count: 5
31
+ # Configuration parameters: CountComments.
32
+ Metrics/MethodLength:
33
+ Max: 18
34
+
35
+ # Offense count: 1
36
+ Metrics/PerceivedComplexity:
37
+ Max: 10
38
+
39
+ # Offense count: 6
40
+ Style/Documentation:
41
+ Exclude:
42
+ - 'spec/**/*'
43
+ - 'test/**/*'
44
+ - 'lib/ext/**/*'
45
+ - 'lib/slayer/command.rb'
46
+ - 'lib/slayer/errors.rb'
47
+ - 'lib/slayer/form.rb'
48
+ - 'lib/slayer/result.rb'
@@ -1,5 +1,6 @@
1
1
  sudo: false
2
+ before_install: gem update --system
2
3
  language: ruby
3
4
  rvm:
4
5
  - 2.3.0
5
- before_install: gem install bundler -v 1.12.0
6
+ - 2.4.0
data/README.md CHANGED
@@ -1,14 +1,28 @@
1
1
  ![Slayer](https://raw.githubusercontent.com/apsislabs/slayer/master/slayer_logo.png)
2
2
 
3
- # Slayer: A Service Layer
3
+ # Slayer: A Killer Service Layer
4
4
 
5
- Slayer is intended to operate as a minimal service layer for your ruby application. To achieve this, Slayer provides base classes for writing small, composable services, which should behave as [pure functions](https://en.wikipedia.org/wiki/Pure_function).
5
+ [![Gem Version](https://badge.fury.io/rb/slayer.svg)](https://badge.fury.io/rb/slayer) [![Build Status](https://travis-ci.org/apsislabs/slayer.svg?branch=master)](https://travis-ci.org/apsislabs/slayer) [![Code Climate](https://codeclimate.com/github/apsislabs/slayer/badges/gpa.svg)](https://codeclimate.com/github/apsislabs/slayer)
6
6
 
7
- Slayer Services should implement `call`, which will `pass` or `fail` the service based on input. Services return a `Slayer::Result` which has a predictable interface for determining `success?` or `failure?`, a `message`, and a `result`.
7
+ Slayer is intended to operate as a minimal service layer for your ruby application. To achieve this, Slayer provides base classes for business logic.
8
8
 
9
- Services are composed by Composers, which describe the order and arguments for running a number of Services in sequence. If any of these Services fail, those run before it are rolled back by calling `rollback` on the service, passing the service its `result` object, and the original parameters.
9
+ ## Application Structure
10
10
 
11
- Composers also return a `Slayer::Result` object, which has as its `result` parameter a hash of the result objects from its composed services.
11
+ Slayer provides 3 base classes for organizing your business logic: `Forms`, `Commands`, and `Services`. Each of these has a distinct role in your application's structure.
12
+
13
+ ### Forms
14
+
15
+ `Slayer::Forms` are objects for wrapping a set of data, usually to be passed as a parameter to a `Command` or `Service`.
16
+
17
+ ### Commands
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.
20
+
21
+ `Commands` should call `Services`, but `Services` should never call `Commands`.
22
+
23
+ ### Services
24
+
25
+ `Services` are the building blocks of `Commands`, and encapsulate re-usable chunks of application logic.
12
26
 
13
27
  ## Installation
14
28
 
@@ -20,55 +34,66 @@ gem 'slayer'
20
34
 
21
35
  And then execute:
22
36
 
23
- $ bundle
37
+ ```sh
38
+ $ bundle
39
+ ```
24
40
 
25
41
  Or install it yourself as:
26
42
 
27
- $ gem install slayer
43
+ ```sh
44
+ $ gem install slayer
45
+ ```
28
46
 
29
47
  ## Usage
30
48
 
49
+ ### Commands
50
+
51
+ 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 `message`, and a `result` payload object.
52
+
31
53
  ```ruby
32
- # A service that passes when given the string "foo"
54
+ # A Command that passes when given the string "foo"
33
55
  # and fails if given anything else.
34
- class FooService < Slayer::Service
35
- def call(foo:)
36
- if foo == "foo"
37
- pass! result: foo, message: "Passing FooService"
38
- else
39
- fail! result: foo, message: "Failing FooService"
40
- end
56
+ class FooCommand < Slayer::Command
57
+ def call(foo:)
58
+ if foo == "foo"
59
+ pass! result: foo, message: "Passing FooCommand"
60
+ else
61
+ fail! result: foo, message: "Failing FooCommand"
41
62
  end
63
+ end
42
64
  end
43
65
 
44
- # A placeholder service that always passes with returned result
45
- # as the argument passed into it.
46
- class BarService < Slayer::Service
47
- def call(bar:)
48
- pass! result: bar, message: "Passing BarService"
49
- end
50
- end
66
+ result = FooCommand.call(foo: "foo")
67
+ result.success? # => true
68
+
69
+ result = FooCommand.call(foo: "bar")
70
+ result.success? # => false
71
+ ```
51
72
 
52
- # A simple composer which composes the FooService and BarService
53
- class FooBarComposer < Slayer::Composer
54
- compose FooService, BarService
73
+ ### Forms
55
74
 
56
- def call
57
- pass! result: @results, message: "Yay!"
58
- end
75
+ ### Services
59
76
 
60
- def foo_service_args
61
- return { foo: @composer_params[:foo] }
62
- end
77
+ 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.
63
78
 
64
- def bar_service_args
65
- return { bar: foo_service_results.message }
79
+ In order to enforce the lack of circular dependencies, Service objects can only call other Services that are declared in their dependencies.
80
+
81
+ ```ruby
82
+ class NetworkService < Slayer::Service
83
+ def self.post()
84
+ ...
66
85
  end
67
86
  end
68
87
 
88
+ class StripeService < Slayer::Service
89
+ dependencies NetworkService
69
90
 
70
- result = FooBarComposer.call(foo: "Jim", bar: "Joe")
71
- result.success? # => true
91
+ def self.pay()
92
+ ...
93
+ NetworkService.post(url: "stripe.com", body: my_payload)
94
+ ...
95
+ end
96
+ end
72
97
  ```
73
98
 
74
99
  ## Development
@@ -77,6 +102,8 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
77
102
 
78
103
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
79
104
 
105
+ To generate documentation run `yard`. To view undocumented files run `yard stats --list-undoc`.
106
+
80
107
  ## Contributing
81
108
 
82
109
  Bug reports and pull requests are welcome on GitHub at https://github.com/apsislabs/slayer.
data/Rakefile CHANGED
@@ -1,9 +1,9 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
3
 
4
4
  Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
5
+ t.libs << 'test'
6
+ t.libs << 'lib'
7
7
  t.test_files = FileList['test/**/*_test.rb']
8
8
  end
9
9
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cd "`dirname \"$0\"`/.."
4
+
5
+ echo "Installing githooks for `pwd`"
6
+ rm -rf .git/hooks
7
+ ln -s -f ../.githooks/ .git/hooks
data/bin/setup CHANGED
@@ -6,3 +6,4 @@ set -vx
6
6
  bundle install
7
7
 
8
8
  # Do any other automated setup that you need to do here
9
+ bin/install-githooks
@@ -0,0 +1,11 @@
1
+ class String
2
+ def underscore
3
+ word = dup
4
+ word.gsub!(/::/, '/')
5
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
6
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
7
+ word.tr!('-', '_')
8
+ word.downcase!
9
+ word
10
+ end
11
+ end
@@ -1,6 +1,9 @@
1
- require "slayer/version"
2
- require "slayer/string_ext"
3
- require "slayer/errors"
4
- require "slayer/result"
5
- require "slayer/service"
6
- require "slayer/composer"
1
+ require 'ext/string_ext' unless defined?(Rails)
2
+
3
+ require 'slayer/version'
4
+ require 'slayer/errors'
5
+ require 'slayer/result'
6
+ require 'slayer/result_matcher'
7
+ require 'slayer/service'
8
+ require 'slayer/command'
9
+ require 'slayer/form'
@@ -0,0 +1,74 @@
1
+ module Slayer
2
+ class Command
3
+ attr_accessor :result
4
+
5
+ # Internal: Command Class Methods
6
+ 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) }
13
+ end
14
+
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
44
+ 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
+ def fail!(result:, status: :default, message: nil)
60
+ @result = Result.new(result, status, message)
61
+ @result.fail!
62
+ end
63
+
64
+ # Pass the Command
65
+ def pass!(result:, status: :default, message: nil)
66
+ @result = Result.new(result, status, message)
67
+ end
68
+
69
+ # Call the command
70
+ def call
71
+ raise NotImplementedError, 'Commands must define method `#call`.'
72
+ end
73
+ end
74
+ end
@@ -1,21 +1,22 @@
1
1
  module Slayer
2
- class ServiceFailure < StandardError
3
- attr_reader :result
2
+ class CommandFailureError < StandardError
3
+ attr_reader :result
4
4
 
5
- def initialize(result)
6
- @result = result
7
- super
8
- end
5
+ def initialize(result)
6
+ @result = result
7
+ super
9
8
  end
9
+ end
10
10
 
11
- class ServiceNotImplemented < StandardError
12
- def initialize(message = nil)
13
- message ||= %q(
14
- Service implementation must call `fail!` or `pass!`,
15
- or return a <Slayer::Result> object
16
- )
17
-
18
- super message
19
- end
11
+ class CommandNotImplementedError < StandardError
12
+ def initialize(message = nil)
13
+ message ||= 'Command implementation must call `fail!` or `pass!`, or '\
14
+ 'return a <Slayer::Result> object'
15
+ super message
20
16
  end
17
+ end
18
+
19
+ class CommandResultNotHandledError < StandardError; end
20
+ class FormValidationError < StandardError; end
21
+ class ServiceDependencyError < StandardError; end
21
22
  end
@@ -0,0 +1,15 @@
1
+ require 'virtus'
2
+
3
+ module Slayer
4
+ class Form
5
+ include Virtus.model
6
+ include ActiveModel::Validations if defined?(Rails)
7
+
8
+ def validate!
9
+ validatable = respond_to?(:valid?) && respond_to?(:errors)
10
+
11
+ raise NotImplementedError unless validatable
12
+ raise FormValidationError, errors unless valid?
13
+ end
14
+ end
15
+ end
@@ -1,23 +1,24 @@
1
1
  module Slayer
2
- class Result
3
- attr_reader :result, :message
2
+ class Result
3
+ attr_reader :value, :status, :message
4
4
 
5
- def initialize(result, message)
6
- @result = result
7
- @message = message
8
- end
5
+ def initialize(value, status, message)
6
+ @value = value
7
+ @status = status
8
+ @message = message
9
+ end
9
10
 
10
- def success?
11
- !failure?
12
- end
11
+ def success?
12
+ !failure?
13
+ end
13
14
 
14
- def failure?
15
- @failure || false
16
- end
15
+ def failure?
16
+ @failure || false
17
+ end
17
18
 
18
- def fail!
19
- @failure = true
20
- raise ServiceFailure, self
21
- end
19
+ def fail!
20
+ @failure = true
21
+ raise CommandFailureError, self
22
22
  end
23
+ end
23
24
  end
@@ -0,0 +1,217 @@
1
+ module Slayer
2
+ # ResultMatcher is the object passed to the block of a {Command.call}. The ResultMatcher
3
+ # allows the block-author to specify which piece of logic they would like to invoke
4
+ # based on the state of the {Result} object.
5
+ #
6
+ # In the event that multiple blocks match the {Result}, only the most specific
7
+ # matching block will be invoked. Status matches take precedence over default matches.
8
+ # If there are two blocks with a matching status, the pass/fail block takes precedence
9
+ # over the all block.
10
+ #
11
+ # == Matching based on success or failure
12
+ #
13
+ # The ResultMatcher matches calls to {#pass} to a {Result} that returns +true+
14
+ # for {Result#success?}, calls to {#fail} to a {Result} that returns +true+
15
+ # for {Result#failure?}, and calls to {#all} to a {Result} in either state.
16
+ #
17
+ # A matching call to {#pass} or {#fail} takes precedence over matching calls to {#all}
18
+ #
19
+ # == Matching based on status
20
+ #
21
+ # Additionally, the ResultMatcher can also match by the {Result#status}. If a status
22
+ # or statuses is passed to {#pass}, {#fail}, or {#all}, these will only be invoked if the
23
+ # status of the {Result} matches the passed in status.
24
+ #
25
+ # If the default block is the same as the block for one of the statuses the status +:default+
26
+ # can be used to indicate which block should be used as the default. Successful status matches
27
+ # take precedence over default matchers.
28
+ #
29
+ # == Both pass and fail must be handled
30
+ #
31
+ # If the block form of a {Command.call} is invoked, both the block must handle the default
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
34
+ # raised.
35
+ #
36
+ # @example Matcher invokes the matching pass block, with precedence given to {#pass} and {#fail}
37
+ # # Call produces a successful Result
38
+ # SuccessCommand.call do |m|
39
+ # m.pass { puts "Pass!" }
40
+ # m.fail { puts "Fail!" }
41
+ # m.all { puts "All!" } # will never be invoked, due to both a pass and fail response existing
42
+ # end
43
+ # # => prints "Pass!"
44
+ #
45
+ # @example Matcher invokes the matching status of the result object, or the default
46
+ # # Call produces a successful Result with status :ok
47
+ # SuccessCommand.call do |m|
48
+ # m.pass(:ok) { puts "Pass, OK!" }
49
+ # m.pass { puts "Pass, default!" }
50
+ # m.fail { puts "Fail!" }
51
+ # end
52
+ # # => prints "Pass, OK!"
53
+ #
54
+ # # Call produces a successful Result with status :created
55
+ # SuccessCommand.call do |m|
56
+ # m.pass(:ok) { puts "Pass, OK!" }
57
+ # m.pass { puts "Pass, default!" }
58
+ # m.fail { puts "Fail!" }
59
+ # end
60
+ # # => prints "Pass, default!"
61
+ #
62
+ # @example Matcher invokes the explicitly indicated default block
63
+ # # Call produces a successful Result with status :created
64
+ # SuccessCommand.call do |m|
65
+ # m.pass(:ok, :default) { puts "Pass, OK!" }
66
+ # m.pass(:great) { puts "Pass, default!" }
67
+ # m.fail { puts "Fail!" }
68
+ # end
69
+ # # => prints "Pass, OK!"
70
+ #
71
+ # @example Matcher must handle both pass and fail defaults.
72
+ # # Call produces a successful Result with status :ok
73
+ # SuccessCommand.call do |m|
74
+ # m.pass(:ok) { puts "Pass, OK!"}
75
+ # m.fail { puts "Fail!" }
76
+ # end
77
+ # # => raises CommandResultNotHandledError (because no default pass was provided)
78
+ #
79
+ # # Call produces a successful Result with status :ok
80
+ # SuccessCommand.call do |m|
81
+ # m.pass(:ok, :default) { puts "Pass, OK!"}
82
+ # m.fail { puts "Fail!" }
83
+ # end
84
+ # # => prints "Pass, OK!"
85
+ #
86
+ # # Call produces a successful Result with status :ok
87
+ # SuccessCommand.call do |m|
88
+ # m.pass(:ok) { puts "Pass, OK!"}
89
+ # m.all { puts "All!" }
90
+ # end
91
+ # # => prints "Pass, OK!"
92
+ #
93
+ # # Call produces a successful Result with status :ok
94
+ # SuccessCommand.call do |m|
95
+ # m.pass(:ok, :default) { puts "Pass, OK!"}
96
+ # end
97
+ # # => raises CommandResultNotHandledError (because no default fail was provided)
98
+ class ResultMatcher
99
+ attr_reader :result, :command
100
+
101
+ # @api private
102
+ def initialize(result, command)
103
+ @result = result
104
+ @command = command
105
+
106
+ @status = result.status || :default
107
+
108
+ @handled_default_pass = false
109
+ @handled_default_fail = false
110
+
111
+ # These are set to false if they are never set. If they are set to `nil` that
112
+ # means the block intentionally passed `nil` as the block to be executed.
113
+ @matching_block = false
114
+ @matching_all = false
115
+ @default_block = false
116
+ @default_all = false
117
+ @ensure_block = false
118
+ end
119
+
120
+ # Provide a block that should be invoked if the {Result} is a success.
121
+ #
122
+ # @param statuses [Array<status>] Statuses that should be compared to the {Result}. If
123
+ # any of provided statuses match the {Result} this block will be considered a match.
124
+ # The symbol +:default+ can also be used to indicate that this should match any {Result}
125
+ # not matched by other matchers.
126
+ #
127
+ # If no value is provided for statuses it defaults to +:default+.
128
+ def pass(*statuses, &block)
129
+ statuses << :default if statuses.empty?
130
+ @handled_default_pass ||= statuses.include?(:default)
131
+
132
+ block_is_match = @result.success? && statuses.include?(@status)
133
+ block_is_default = @result.success? && statuses.include?(:default)
134
+
135
+ @matching_block = block if block_is_match
136
+ @default_block = block if block_is_default
137
+ end
138
+
139
+ # Provide a block that should be invoked if the {Result} is a failure.
140
+ #
141
+ # @param statuses [Array<status>] Statuses that should be compared to the {Result}. If
142
+ # any of provided statuses match the {Result} this block will be considered a match.
143
+ # The symbol +:default+ can also be used to indicate that this should match any {Result}
144
+ # not matched by other matchers.
145
+ #
146
+ # If no value is provided for statuses it defaults to +:default+.
147
+ def fail(*statuses, &block)
148
+ statuses << :default if statuses.empty?
149
+ @handled_default_fail ||= statuses.include?(:default)
150
+
151
+ block_is_match = @result.failure? && statuses.include?(@status)
152
+ block_is_default = @result.failure? && statuses.include?(:default)
153
+
154
+ @matching_block = block if block_is_match
155
+ @default_block = block if block_is_default
156
+ end
157
+
158
+ # Provide a block that should be invoked for any {Result}. This has a lower precedence that
159
+ # either {#pass} or {#fail}.
160
+ #
161
+ # @param statuses [Array<status>] Statuses that should be compared to the {Result}. If
162
+ # any of provided statuses match the {Result} this block will be considered a match.
163
+ # The symbol +:default+ can also be used to indicate that this should match any {Result}
164
+ # not matched by other matchers.
165
+ #
166
+ # If no value is provided for statuses it defaults to +:default+.
167
+ def all(*statuses, &block)
168
+ statuses << :default if statuses.empty?
169
+ @handled_default_pass ||= statuses.include?(:default)
170
+ @handled_default_fail ||= statuses.include?(:default)
171
+
172
+ block_is_match = statuses.include?(@status)
173
+ block_is_default = statuses.include?(:default)
174
+
175
+ @matching_all = block if block_is_match
176
+ @default_all = block if block_is_default
177
+ end
178
+
179
+ # Provide a block that should be always be invoked after other blocks have executed. This block
180
+ # will be invoked even if the other block raises an error.
181
+ def ensure(&block)
182
+ @ensure_block = block
183
+ end
184
+
185
+ # @return Whether both the pass and the fail defaults have been handled.
186
+ #
187
+ # @api private
188
+ def handled_defaults?
189
+ return @handled_default_pass && @handled_default_fail
190
+ end
191
+
192
+ # Executes the provided block that best matched the {Result}. If no block matched
193
+ # nothing is executed
194
+ #
195
+ # @api private
196
+ def execute_matching_block
197
+ if @matching_block != false # nil should pass this test
198
+ @matching_block&.call(@result, @command) # explicit nil will not get called with
199
+ # safe navigation (&.)
200
+ elsif @matching_all != false
201
+ @matching_all&.call(@result, @command)
202
+ elsif @default_block != false
203
+ @default_block&.call(@result, @command)
204
+ elsif @default_all
205
+ @default_all&.call(@result, @command)
206
+ end
207
+ end
208
+
209
+ def execute_ensure_block
210
+ # rubocop:disable Style/IfUnlessModifier
211
+ if @ensure_block != false # nil should pass this test
212
+ @ensure_block.call(@result, @command)
213
+ end
214
+ # rubocop:enable Style/IfUnlessModifier
215
+ end
216
+ end
217
+ end
@@ -1,52 +1,182 @@
1
1
  module Slayer
2
- class Service
3
- attr_accessor :result
4
-
5
- # Internal: Service Class Methods
6
- class << self
7
- def call(*args, &block)
8
- # Run the Service and capture the result
9
- result = new.tap { |s|
10
- s.run(*args, &block)
11
- }.result
12
-
13
- # Throw an exception if we don't return a result
14
- raise ServiceNotImplemented unless result.is_a? Result
15
- return result
16
- end
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.
9
+ 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")
17
47
  end
18
48
 
19
- # Run the Service, rescue from Failures
20
- def run(*args, &block)
21
- begin
22
- run!(*args, &block)
23
- rescue ServiceFailure
24
- end
49
+ unless dep < Slayer::Service
50
+ raise(ServiceDependencyError, "The object #{dep} passed to dependencies was not a subclass of #{self}")
25
51
  end
52
+ end
53
+
54
+ unless deps.uniq.length == deps.length
55
+ raise(ServiceDependencyError, "There were duplicate dependencies in #{self}")
56
+ end
57
+
58
+ @deps = deps
59
+
60
+ # Calculate the transitive dependencies and raise an error if there are circular dependencies
61
+ transitive_dependencies
62
+ end
63
+
64
+ class << self
65
+
66
+ attr_reader :deps
67
+
68
+ def transitive_dependencies(dependency_hash = {}, visited = [])
69
+ return @transitive_dependencies if @transitive_dependencies
26
70
 
27
- # Run the Service
28
- def run!(*args, &block)
29
- call(*args, &block)
71
+ @deps ||= []
72
+
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)
76
+
77
+ visited << self
78
+ dependency_hash[self] ||= []
79
+
80
+ # Add each of our dependencies (and it's transitive dependency chain) to our
81
+ # own dependencies.
82
+
83
+ @deps.each do |dep|
84
+ dependency_hash[self] << dep
85
+
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
90
+
91
+ dependency_hash[self].uniq
30
92
  end
31
93
 
32
- # Fail the Service
33
- def fail! result:, message:
34
- @result = Result.new(result, message)
35
- @result.fail!
94
+ # NO CIRCULAR DEPENDENCIES!
95
+ if dependency_hash[self].include? self
96
+ raise(ServiceDependencyError, "#{self} had a circular dependency")
36
97
  end
37
98
 
38
- # Pass the Service
39
- def pass! result:, message:
40
- @result = Result.new(result, message)
99
+ # Store these now, so next time we can short-circuit.
100
+ @transitive_dependencies = dependency_hash[self]
101
+
102
+ return @transitive_dependencies
103
+ end
104
+
105
+ def before_each_method(*)
106
+ @deps ||= []
107
+ @@allowed_services ||= nil
108
+
109
+ # Confirm that this method call is allowed
110
+ raise_if_not_allowed
111
+
112
+ @@allowed_services ||= []
113
+ @@allowed_services << @deps
114
+ end
115
+
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
41
123
  end
124
+ end
125
+
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"
42
137
 
43
- # Call the service
44
- def call
45
- raise NotImplementedError, "Services must define method `#call`."
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
46
148
  end
47
149
 
48
- # Do nothing
49
- def rollback
150
+ singleton_class.send(:alias_method, without, name)
151
+ singleton_class.send(:alias_method, name, with)
152
+
153
+ @__last_methods_added = nil
154
+ end
155
+
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
50
173
  end
51
- end
52
- end
174
+
175
+ alias_method without, name
176
+ alias_method name, with
177
+
178
+ @__last_methods_added = nil
179
+ end
180
+ end # << self
181
+ end # class Service
182
+ end # module Slayer
@@ -1,3 +1,3 @@
1
1
  module Slayer
2
- VERSION = "0.1.0"
2
+ VERSION = '0.2.0'
3
3
  end
@@ -4,21 +4,29 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
  require 'slayer/version'
5
5
 
6
6
  Gem::Specification.new do |spec|
7
- spec.name = "slayer"
7
+ spec.name = 'slayer'
8
8
  spec.version = Slayer::VERSION
9
- spec.authors = ["Wyatt Kirby"]
10
- spec.email = ["kirby.wa@gmail.com"]
9
+ spec.authors = ['Wyatt Kirby', 'Noah Callaway']
10
+ spec.email = ['wyatt@apsis.io', 'noah@apsis.io']
11
11
 
12
- spec.summary = %q{A service layer}
13
- spec.homepage = "http://www.apsis.io"
14
- spec.license = "MIT"
12
+ spec.summary = %q{A killer service layer}
13
+ spec.homepage = 'http://www.apsis.io'
14
+ spec.license = 'MIT'
15
15
 
16
16
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
- spec.bindir = "exe"
17
+ spec.bindir = 'exe'
18
18
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
- spec.require_paths = ["lib"]
19
+ spec.require_paths = ['lib']
20
20
 
21
- spec.add_development_dependency "bundler", "~> 1.12"
22
- spec.add_development_dependency "rake", "~> 10.0"
23
- spec.add_development_dependency "minitest", "~> 5.0"
21
+ spec.add_dependency 'virtus', '~> 1.0'
22
+ spec.add_dependency 'dry-validation', '~> 0.10'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.12'
25
+ spec.add_development_dependency 'rake', '~> 10.0'
26
+ spec.add_development_dependency 'minitest', '~> 5.0'
27
+ spec.add_development_dependency 'minitest-reporters', '~> 1.1'
28
+ spec.add_development_dependency 'mocha', '~> 1.2'
29
+ spec.add_development_dependency 'byebug', '~> 9.0'
30
+ spec.add_development_dependency 'yard', '~> 0.9'
31
+ spec.add_development_dependency 'rubocop', '~> 0.47.1'
24
32
  end
Binary file
metadata CHANGED
@@ -1,15 +1,44 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slayer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wyatt Kirby
8
+ - Noah Callaway
8
9
  autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
11
- date: 2017-02-11 00:00:00.000000000 Z
12
+ date: 2017-02-13 00:00:00.000000000 Z
12
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: virtus
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '1.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '1.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: dry-validation
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '0.10'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '0.10'
13
42
  - !ruby/object:Gem::Dependency
14
43
  name: bundler
15
44
  requirement: !ruby/object:Gem::Requirement
@@ -52,27 +81,105 @@ dependencies:
52
81
  - - "~>"
53
82
  - !ruby/object:Gem::Version
54
83
  version: '5.0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: minitest-reporters
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '1.1'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '1.1'
98
+ - !ruby/object:Gem::Dependency
99
+ name: mocha
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '1.2'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '1.2'
112
+ - !ruby/object:Gem::Dependency
113
+ name: byebug
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - "~>"
117
+ - !ruby/object:Gem::Version
118
+ version: '9.0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '9.0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: yard
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - "~>"
131
+ - !ruby/object:Gem::Version
132
+ version: '0.9'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - "~>"
138
+ - !ruby/object:Gem::Version
139
+ version: '0.9'
140
+ - !ruby/object:Gem::Dependency
141
+ name: rubocop
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - "~>"
145
+ - !ruby/object:Gem::Version
146
+ version: 0.47.1
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - "~>"
152
+ - !ruby/object:Gem::Version
153
+ version: 0.47.1
55
154
  description:
56
155
  email:
57
- - kirby.wa@gmail.com
156
+ - wyatt@apsis.io
157
+ - noah@apsis.io
58
158
  executables: []
59
159
  extensions: []
60
160
  extra_rdoc_files: []
61
161
  files:
162
+ - ".githooks/pre-push"
62
163
  - ".gitignore"
164
+ - ".hound.yml"
165
+ - ".rubocop.yml"
166
+ - ".rubocop_todo.yml"
63
167
  - ".travis.yml"
64
168
  - Gemfile
65
169
  - LICENSE.txt
66
170
  - README.md
67
171
  - Rakefile
68
172
  - bin/console
173
+ - bin/install-githooks
69
174
  - bin/setup
175
+ - lib/ext/string_ext.rb
70
176
  - lib/slayer.rb
71
- - lib/slayer/composer.rb
177
+ - lib/slayer/command.rb
72
178
  - lib/slayer/errors.rb
179
+ - lib/slayer/form.rb
73
180
  - lib/slayer/result.rb
181
+ - lib/slayer/result_matcher.rb
74
182
  - lib/slayer/service.rb
75
- - lib/slayer/string_ext.rb
76
183
  - lib/slayer/version.rb
77
184
  - slayer.gemspec
78
185
  - slayer_logo.png
@@ -99,5 +206,5 @@ rubyforge_project:
99
206
  rubygems_version: 2.6.8
100
207
  signing_key:
101
208
  specification_version: 4
102
- summary: A service layer
209
+ summary: A killer service layer
103
210
  test_files: []
@@ -1,80 +0,0 @@
1
- module Slayer
2
- class Composer < Service
3
- attr_accessor :called
4
- attr_accessor :results
5
- attr_accessor :called_services
6
- attr_accessor :composer_params
7
-
8
- class << self
9
- def compose(*services)
10
- @services = services.flatten
11
- end
12
-
13
- def services
14
- @services ||= []
15
- end
16
- end
17
-
18
- # Locate Results from Magic Methods
19
- def method_missing(method_sym, *arguments, &block)
20
- if method_sym.to_s =~ /^(.*)_results$/
21
- results = @results[$1.to_sym]
22
- return results unless results.nil?
23
- end
24
-
25
- super
26
- end
27
-
28
- def run!(**args, &block)
29
- @composer_params = args
30
- @called_services = []
31
- @results = {}
32
-
33
- # Attempt to run each Service, if any fail,
34
- # call rollback on all those already run in
35
- # reverse order.
36
- begin
37
- self.class.services.each do |service|
38
- service_sym = service_to_sym(service)
39
- service_args = service_to_args(service)
40
-
41
- # Run the service then add it to called_services
42
- @results[service_sym] = service.new.run!(service_args)
43
- @called_services << service
44
- end
45
- rescue ServiceFailure
46
- @called_services.reverse_each do |service|
47
- service_args = service_to_args(service)
48
- service_result = service_results(service)
49
-
50
- # Pass the original args and result object
51
- service.rollback(service_args, service_result)
52
- end
53
-
54
- raise
55
- end
56
-
57
- call
58
- @called = true
59
- end
60
-
61
- private
62
-
63
- # Convert a Service to an underscored symbol
64
- def service_to_sym(service)
65
- service.name.underscore.to_sym
66
- end
67
-
68
- # Convert a Service to a call to an args method
69
- def service_to_args(service)
70
- service_sym = service_to_sym(service)
71
- self.method("#{service_sym}_args").call
72
- end
73
-
74
- # Convert a Service to its result object
75
- def service_results(service)
76
- service_sym = service_to_sym(service)
77
- return @results[service_sym]
78
- end
79
- end
80
- end
@@ -1,11 +0,0 @@
1
- class String
2
- def underscore
3
- word = self.dup
4
- word.gsub!(/::/, '/')
5
- word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
6
- word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
7
- word.tr!("-", "_")
8
- word.downcase!
9
- word
10
- end
11
- end