cuprum 0.9.1 → 0.11.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.
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