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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/DEVELOPMENT.md +42 -53
- data/README.md +728 -536
- data/lib/cuprum.rb +12 -6
- data/lib/cuprum/built_in.rb +3 -1
- data/lib/cuprum/built_in/identity_command.rb +6 -4
- data/lib/cuprum/built_in/identity_operation.rb +4 -2
- data/lib/cuprum/built_in/null_command.rb +5 -3
- data/lib/cuprum/built_in/null_operation.rb +4 -2
- data/lib/cuprum/command.rb +37 -59
- data/lib/cuprum/command_factory.rb +50 -24
- data/lib/cuprum/currying.rb +79 -0
- data/lib/cuprum/currying/curried_command.rb +116 -0
- data/lib/cuprum/error.rb +44 -10
- data/lib/cuprum/errors.rb +2 -0
- data/lib/cuprum/errors/command_not_implemented.rb +6 -3
- data/lib/cuprum/errors/operation_not_called.rb +6 -6
- data/lib/cuprum/errors/uncaught_exception.rb +55 -0
- data/lib/cuprum/exception_handling.rb +50 -0
- data/lib/cuprum/matcher.rb +90 -0
- data/lib/cuprum/matcher_list.rb +150 -0
- data/lib/cuprum/matching.rb +232 -0
- data/lib/cuprum/matching/match_clause.rb +65 -0
- data/lib/cuprum/middleware.rb +210 -0
- data/lib/cuprum/operation.rb +17 -15
- data/lib/cuprum/processing.rb +10 -14
- data/lib/cuprum/result.rb +2 -4
- data/lib/cuprum/result_helpers.rb +22 -0
- data/lib/cuprum/rspec/be_a_result.rb +10 -1
- data/lib/cuprum/rspec/be_a_result_matcher.rb +5 -7
- data/lib/cuprum/rspec/be_callable.rb +14 -0
- data/lib/cuprum/steps.rb +233 -0
- data/lib/cuprum/utils.rb +3 -1
- data/lib/cuprum/utils/instance_spy.rb +37 -30
- data/lib/cuprum/version.rb +13 -10
- metadata +34 -19
- 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
|
-
|
11
|
-
|
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
|
-
|
16
|
-
|
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]
|
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
|
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
|
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
|
34
|
-
|
67
|
+
def as_json_data
|
68
|
+
{}
|
35
69
|
end
|
36
70
|
end
|
37
71
|
end
|
data/lib/cuprum/errors.rb
CHANGED
@@ -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
|
32
|
-
|
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
|
32
|
-
|
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
|