cuprum 0.9.1 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/DEVELOPMENT.md +42 -53
  4. data/README.md +728 -536
  5. data/lib/cuprum.rb +12 -6
  6. data/lib/cuprum/built_in.rb +3 -1
  7. data/lib/cuprum/built_in/identity_command.rb +6 -4
  8. data/lib/cuprum/built_in/identity_operation.rb +4 -2
  9. data/lib/cuprum/built_in/null_command.rb +5 -3
  10. data/lib/cuprum/built_in/null_operation.rb +4 -2
  11. data/lib/cuprum/command.rb +37 -59
  12. data/lib/cuprum/command_factory.rb +50 -24
  13. data/lib/cuprum/currying.rb +79 -0
  14. data/lib/cuprum/currying/curried_command.rb +116 -0
  15. data/lib/cuprum/error.rb +44 -10
  16. data/lib/cuprum/errors.rb +2 -0
  17. data/lib/cuprum/errors/command_not_implemented.rb +6 -3
  18. data/lib/cuprum/errors/operation_not_called.rb +6 -6
  19. data/lib/cuprum/errors/uncaught_exception.rb +55 -0
  20. data/lib/cuprum/exception_handling.rb +50 -0
  21. data/lib/cuprum/matcher.rb +90 -0
  22. data/lib/cuprum/matcher_list.rb +150 -0
  23. data/lib/cuprum/matching.rb +232 -0
  24. data/lib/cuprum/matching/match_clause.rb +65 -0
  25. data/lib/cuprum/middleware.rb +210 -0
  26. data/lib/cuprum/operation.rb +17 -15
  27. data/lib/cuprum/processing.rb +10 -14
  28. data/lib/cuprum/result.rb +2 -4
  29. data/lib/cuprum/result_helpers.rb +22 -0
  30. data/lib/cuprum/rspec/be_a_result.rb +10 -1
  31. data/lib/cuprum/rspec/be_a_result_matcher.rb +5 -7
  32. data/lib/cuprum/rspec/be_callable.rb +14 -0
  33. data/lib/cuprum/steps.rb +233 -0
  34. data/lib/cuprum/utils.rb +3 -1
  35. data/lib/cuprum/utils/instance_spy.rb +37 -30
  36. data/lib/cuprum/version.rb +13 -10
  37. metadata +34 -19
  38. data/lib/cuprum/chaining.rb +0 -420
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/currying'
4
+
5
+ module Cuprum::Currying
6
+ # A CurriedCommand wraps another command and passes preset args to #call.
7
+ #
8
+ # @example Currying Arguments
9
+ # # Our base command takes two arguments.
10
+ # say_command = Cuprum::Command.new do |greeting, person|
11
+ # "#{greeting}, #{person}!"
12
+ # end
13
+ # say_command.call('Hello', 'world')
14
+ # #=> returns a result with value 'Hello, world!'
15
+ #
16
+ # # Next, we create a curried command. This sets the first argument to
17
+ # # always be 'Greetings', so our curried command only takes one argument,
18
+ # # namely the name of the person being greeted.
19
+ # greet_command =
20
+ # Cuprum::CurriedCommand.new(
21
+ # arguments: ['Greetings'],
22
+ # command: say_command
23
+ # )
24
+ # greet_command.call('programs')
25
+ # #=> returns a result with value 'Greetings, programs!'
26
+ #
27
+ # # Here, we are creating a curried command that passes both arguments.
28
+ # # Therefore, our curried command does not take any arguments.
29
+ # recruit_command =
30
+ # Cuprum::CurriedCommand.new(
31
+ # arguments: ['Greetings', 'starfighter'],
32
+ # command: say_command
33
+ # )
34
+ # recruit_command.call
35
+ # #=> returns a result with value 'Greetings, starfighter!'
36
+ #
37
+ # @example Currying Keywords
38
+ # # Our base command takes two keywords: a math operation and an array of
39
+ # # integers.
40
+ # math_command = Cuprum::Command.new do |operands:, operation:|
41
+ # operations.reduce(&operation)
42
+ # end
43
+ # math_command.call(operands: [2, 2], operation: :+)
44
+ # #=> returns a result with value 4
45
+ #
46
+ # # Our curried command still takes two keywords, but now the operation
47
+ # # keyword is optional. It now defaults to :*, for multiplication.
48
+ # multiply_command =
49
+ # Cuprum::CurriedCommand.new(
50
+ # command: math_command,
51
+ # keywords: { operation: :* }
52
+ # )
53
+ # multiply_command.call(operands: [3, 3])
54
+ # #=> returns a result with value 9
55
+ class CurriedCommand < Cuprum::Command
56
+ # @param arguments [Array] The arguments to pass to the curried command.
57
+ # @param command [Cuprum::Command] The original command to curry.
58
+ # @param keywords [Hash] The keywords to pass to the curried command.
59
+ # @yield A block to pass to the curried command.
60
+ def initialize(command:, arguments: [], block: nil, keywords: {})
61
+ super()
62
+
63
+ @arguments = arguments
64
+ @block = block
65
+ @command = command
66
+ @keywords = keywords
67
+ end
68
+
69
+ # @!method call(*args, **kwargs)
70
+ # Merges the arguments and keywords and calls the wrapped command.
71
+ #
72
+ # First, the arguments array is created starting with the :arguments
73
+ # passed to #initialize. Any positional arguments passed directly to #call
74
+ # are then appended.
75
+ #
76
+ # Second, the keyword arguments are created by merging the keywords passed
77
+ # directly into #call into the keywods passed to #initialize. This means
78
+ # that if a key is passed in both places, the value passed into #call will
79
+ # take precedence.
80
+ #
81
+ # Finally, the merged arguments and keywords are passed into the original
82
+ # command's #call method.
83
+ #
84
+ # @param args [Array] Additional arguments to pass to the curried command.
85
+ # @param kwargs [Hash] Additional keywords to pass to the curried command.
86
+ #
87
+ # @return [Cuprum::Result]
88
+ #
89
+ # @see Cuprum::Processing#call
90
+
91
+ # @return [Array] the arguments to pass to the curried command.
92
+ attr_reader :arguments
93
+
94
+ # @return [Proc, nil] a block to pass to the curried command.
95
+ attr_reader :block
96
+
97
+ # @return [Cuprum::Command] the original command to curry.
98
+ attr_reader :command
99
+
100
+ # @return [Hash] the keywords to pass to the curried command.
101
+ attr_reader :keywords
102
+
103
+ private
104
+
105
+ def process(*args, **kwargs, &override)
106
+ args = [*arguments, *args]
107
+ kwargs = keywords.merge(kwargs)
108
+
109
+ if kwargs.empty?
110
+ command.call(*args, &(override || block))
111
+ else
112
+ command.call(*args, **kwargs, &(override || block))
113
+ end
114
+ end
115
+ end
116
+ end
data/lib/cuprum/error.rb CHANGED
@@ -4,34 +4,68 @@ require 'cuprum'
4
4
 
5
5
  module Cuprum
6
6
  # Wrapper class for encapsulating an error state for a failed Cuprum result.
7
+ #
7
8
  # Additional details can be passed by setting the #message or by using a
8
9
  # subclass of Cuprum::Error.
9
10
  class Error
10
- COMPARABLE_PROPERTIES = %i[message].freeze
11
- private_constant :COMPARABLE_PROPERTIES
11
+ # Short string used to identify the type of error.
12
+ #
13
+ # Primarily used for serialization. This value can be overriden by passing
14
+ # in the :type parameter to the constructor.
15
+ #
16
+ # Subclasses of Cuprum::Error should define their own default TYPE constant.
17
+ TYPE = 'cuprum.error'
12
18
 
13
19
  # @param message [String] Optional message describing the nature of the
14
20
  # error.
15
- def initialize(message: nil)
16
- @message = message
21
+ # @param properties [Hash] Additional properties used to compare errors.
22
+ # @param type [String] Short string used to identify the type of error.
23
+ def initialize(message: nil, type: nil, **properties)
24
+ @message = message
25
+ @type = type || self.class::TYPE
26
+ @comparable_properties = properties.merge(message: message, type: type)
17
27
  end
18
28
 
19
- # @return [String] Optional message describing the nature of the error.
29
+ # @return [String] optional message describing the nature of the error.
20
30
  attr_reader :message
21
31
 
32
+ # @return [String] short string used to identify the type of error.
33
+ attr_reader :type
34
+
22
35
  # @param other [Cuprum::Error] The other object to compare.
23
36
  #
24
- # @return [Boolean] true if the other object has the same class and message;
25
- # otherwise false.
37
+ # @return [Boolean] true if the other object has the same class and
38
+ # properties; otherwise false.
26
39
  def ==(other)
27
40
  other.instance_of?(self.class) &&
28
- comparable_properties.all? { |prop| send(prop) == other.send(prop) }
41
+ other.comparable_properties == comparable_properties
42
+ end
43
+
44
+ # Generates a serializable representation of the error object.
45
+ #
46
+ # By default, contains the #type and #message properties and an empty :data
47
+ # Hash. This can be overriden in subclasses by overriding the private method
48
+ # #as_json_data; this should always return a Hash with String keys and whose
49
+ # values are basic objects or data structures of the same.
50
+ #
51
+ # @return [Hash<String, Object>] a serializable hash representation of the
52
+ # error.
53
+ def as_json
54
+ {
55
+ 'data' => as_json_data,
56
+ 'message' => message,
57
+ 'type' => type
58
+ }
29
59
  end
30
60
 
61
+ protected
62
+
63
+ attr_reader :comparable_properties
64
+
31
65
  private
32
66
 
33
- def comparable_properties
34
- self.class.const_get(:COMPARABLE_PROPERTIES)
67
+ def as_json_data
68
+ {}
35
69
  end
36
70
  end
37
71
  end
data/lib/cuprum/errors.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'cuprum'
2
4
 
3
5
  module Cuprum
@@ -13,6 +13,9 @@ module Cuprum::Errors
13
13
  # Format for generating error message.
14
14
  MESSAGE_FORMAT = 'no implementation defined for %s'
15
15
 
16
+ # Short string used to identify the type of error.
17
+ TYPE = 'cuprum.errors.command_not_implemented'
18
+
16
19
  # @param command [Cuprum::Command] The command called without a definition.
17
20
  def initialize(command:)
18
21
  @command = command
@@ -20,7 +23,7 @@ module Cuprum::Errors
20
23
  class_name = command&.class&.name || 'command'
21
24
  message = MESSAGE_FORMAT % class_name
22
25
 
23
- super(message: message)
26
+ super(command: command, message: message)
24
27
  end
25
28
 
26
29
  # @return [Cuprum::Command] The command called without a definition.
@@ -28,8 +31,8 @@ module Cuprum::Errors
28
31
 
29
32
  private
30
33
 
31
- def comparable_properties
32
- COMPARABLE_PROPERTIES
34
+ def as_json_data
35
+ command ? { 'class_name' => command.class.name } : {}
33
36
  end
34
37
  end
35
38
  end
@@ -7,12 +7,12 @@ module Cuprum::Errors
7
7
  # Error class to be used when trying to access the result of an uncalled
8
8
  # Operation.
9
9
  class OperationNotCalled < Cuprum::Error
10
- COMPARABLE_PROPERTIES = %i[operation].freeze
11
- private_constant :COMPARABLE_PROPERTIES
12
-
13
10
  # Format for generating error message.
14
11
  MESSAGE_FORMAT = '%s was not called and does not have a result'
15
12
 
13
+ # Short string used to identify the type of error.
14
+ TYPE = 'cuprum.errors.operation_not_called'
15
+
16
16
  # @param operation [Cuprum::Operation] The uncalled operation.
17
17
  def initialize(operation:)
18
18
  @operation = operation
@@ -20,7 +20,7 @@ module Cuprum::Errors
20
20
  class_name = operation&.class&.name || 'operation'
21
21
  message = MESSAGE_FORMAT % class_name
22
22
 
23
- super(message: message)
23
+ super(message: message, operation: operation)
24
24
  end
25
25
 
26
26
  # @return [Cuprum::Operation] The uncalled operation.
@@ -28,8 +28,8 @@ module Cuprum::Errors
28
28
 
29
29
  private
30
30
 
31
- def comparable_properties
32
- COMPARABLE_PROPERTIES
31
+ def as_json_data
32
+ operation ? { 'class_name' => operation.class.name } : {}
33
33
  end
34
34
  end
35
35
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/error'
4
+ require 'cuprum/errors'
5
+
6
+ module Cuprum::Errors
7
+ # An error returned when a command encounters an unhandled exception.
8
+ class UncaughtException < Cuprum::Error
9
+ # Short string used to identify the type of error.
10
+ TYPE = 'cuprum.collections.errors.uncaught_exception'
11
+
12
+ # @param exception [StandardError] The exception that was raised.
13
+ # @param message [String] A message to display. Will be annotated with
14
+ # details on the exception and the exception's cause (if any).
15
+ def initialize(exception:, message: 'uncaught exception')
16
+ @exception = exception
17
+ @cause = exception.cause
18
+
19
+ super(message: generate_message(message))
20
+ end
21
+
22
+ # @return [StandardError] the exception that was raised.
23
+ attr_reader :exception
24
+
25
+ private
26
+
27
+ attr_reader :cause
28
+
29
+ def as_json_data # rubocop:disable Metrics/MethodLength
30
+ data = {
31
+ 'exception_backtrace' => exception.backtrace,
32
+ 'exception_class' => exception.class,
33
+ 'exception_message' => exception.message
34
+ }
35
+
36
+ return data unless cause
37
+
38
+ data.update(
39
+ {
40
+ 'cause_backtrace' => cause.backtrace,
41
+ 'cause_class' => cause.class,
42
+ 'cause_message' => cause.message
43
+ }
44
+ )
45
+ end
46
+
47
+ def generate_message(message)
48
+ message = "#{message} #{exception.class}: #{exception.message}"
49
+
50
+ return message unless cause
51
+
52
+ message + " caused by #{cause.class}: #{cause.message}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/errors/uncaught_exception'
4
+
5
+ module Cuprum
6
+ # Utility class for handling uncaught exceptions in commands.
7
+ #
8
+ # @example
9
+ # class UnsafeCommand < Cuprum::Command
10
+ # private
11
+ #
12
+ # def process
13
+ # raise 'Something went wrong.'
14
+ # end
15
+ # end
16
+ #
17
+ # class SafeCommand < UnsafeCommand
18
+ # include Cuprum::ExceptionHandling
19
+ # end
20
+ #
21
+ # UnsafeCommand.new.call
22
+ # #=> raises a StandardError
23
+ #
24
+ # result = SafeCommand.new.call
25
+ # #=> a Cuprum::Result
26
+ # result.error
27
+ # #=> a Cuprum::Errors::UncaughtException error.
28
+ # result.error.message
29
+ # #=> 'uncaught exception in SafeCommand -' \
30
+ # ' StandardError: Something went wrong.'
31
+ module ExceptionHandling
32
+ # Wraps the #call method with a rescue clause matching any StandardError.
33
+ #
34
+ # If a StandardError or subclass thereof is raised and not caught by #call,
35
+ # then ExceptionHandling will rescue the exception and return a failing
36
+ # Cuprum::Result with a Cuprum::Errors::UncaughtException error.
37
+ #
38
+ # @return [Cuprum::Result] the result of calling the superclass method, or
39
+ # a failing result if a StandardError is raised.
40
+ def call(*args, **kwargs, &block)
41
+ super
42
+ rescue StandardError => exception
43
+ error = Cuprum::Errors::UncaughtException.new(
44
+ exception: exception,
45
+ message: "uncaught exception in #{self.class.name} - "
46
+ )
47
+ failure(error)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+ require 'cuprum/matching'
5
+
6
+ module Cuprum
7
+ # Provides result matching based on result status, error, and value.
8
+ #
9
+ # First, define match clauses using the .match DSL. Each match clause has a
10
+ # status and optionally a value class and/or error class. A result will only
11
+ # match the clause if the result status is the same as the clause's status.
12
+ # If the clause sets a value class, then the result value must be an instance
13
+ # of that class (or an instance of a subclass). If the clause sets an error
14
+ # class, then the result error must be an instance of that class (or an
15
+ # instance of a subclass).
16
+ #
17
+ # Once the matcher defines one or more match clauses, call #call with a result
18
+ # to match the result. The matcher will determine the best match with the same
19
+ # status (value and error match the result, only value or error match, or just
20
+ # status matches) and then call the match clause with the result. If no match
21
+ # clauses match the result, the matcher will instead raise a
22
+ # Cuprum::Matching::NoMatchError.
23
+ #
24
+ # @example Matching A Status
25
+ # matcher = Cuprum::Matcher.new do
26
+ # match(:failure) { 'Something went wrong' }
27
+ #
28
+ # match(:success) { 'Ok' }
29
+ # end
30
+ #
31
+ # matcher.call(Cuprum::Result.new(status: :failure))
32
+ # #=> 'Something went wrong'
33
+ #
34
+ # matcher.call(Cuprum::Result.new(status: :success))
35
+ # #=> 'Ok'
36
+ #
37
+ # @example Matching An Error
38
+ # matcher = Cuprum::Matcher.new do
39
+ # match(:failure) { 'Something went wrong' }
40
+ #
41
+ # match(:failure, error: CustomError) { |result| result.error.message }
42
+ #
43
+ # match(:success) { 'Ok' }
44
+ # end
45
+ #
46
+ # matcher.call(Cuprum::Result.new(status: :failure))
47
+ # #=> 'Something went wrong'
48
+ #
49
+ # error = CustomError.new(message: 'The magic smoke is escaping.')
50
+ # matcher.call(Cuprum::Result.new(error: error))
51
+ # #=> 'The magic smoke is escaping.'
52
+ #
53
+ # @example Using A Match Context
54
+ # context = Struct.new(:name).new('programs')
55
+ # matcher = Cuprum::Matcher.new(context) do
56
+ # match(:failure) { 'Something went wrong' }
57
+ #
58
+ # match(:success) { "Greetings, #{name}!" }
59
+ # end
60
+ #
61
+ # matcher.call(Cuprum::Result.new(status: :success)
62
+ # #=> 'Greetings, programs!'
63
+ class Matcher
64
+ include Cuprum::Matching
65
+
66
+ # @param match_context [Object] the execution context for a matching clause.
67
+ #
68
+ # @yield Executes the block in the context of the singleton class. This is
69
+ # used to define match clauses when instantiating a Matcher instance.
70
+ def initialize(match_context = nil, &block)
71
+ @match_context = match_context
72
+
73
+ singleton_class.instance_exec(&block) if block_given?
74
+ end
75
+
76
+ # Returns a copy of the matcher with the given execution context.
77
+ #
78
+ # @param match_context [Object] the execution context for a matching clause.
79
+ #
80
+ # @return [Cuprum::Matcher] the copied matcher.
81
+ def with_context(match_context)
82
+ clone.tap { |copy| copy.match_context = match_context }
83
+ end
84
+ alias_method :using_context, :with_context
85
+
86
+ protected
87
+
88
+ attr_writer :match_context
89
+ end
90
+ end