slayer 0.1.0 → 0.2.0

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