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