teckel 0.0.1 → 0.1.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.
data/bin/rubocop ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/inline'
5
+ require 'bundler'
6
+
7
+ # We need the `Bundler.settings.temporary` for a bundler bug:
8
+ # https://github.com/bundler/bundler/issues/7114
9
+ # Will get fixed in bundler version 2.1.0
10
+ Bundler.settings.temporary(frozen: false) do
11
+ gemfile do
12
+ source 'https://rubygems.org'
13
+ gem 'rubocop', '~> 0.78.0'
14
+ gem 'relaxed-rubocop', '2.4'
15
+ end
16
+ end
17
+
18
+ load Gem.bin_path("rubocop", "rubocop")
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Teckel
6
+ # Railway style execution of multiple Operations.
7
+ #
8
+ # - Runs multiple Operations (steps) in order.
9
+ # - The output of an earlier step is passed as input to the next step.
10
+ # - Any failure will stop the execution chain (none of the later steps is called).
11
+ # - All Operations (steps) must behave like +Teckel::Operation::Results+ and
12
+ # return a result object like +Teckel::Result+
13
+ # - A failure response is wrapped into a +Teckel::Chain::StepFailure+ giving
14
+ # additional information about which step failed
15
+ #
16
+ # @see Teckel::Operation::Results
17
+ # @see Teckel::Result
18
+ # @see Teckel::Chain::StepFailure
19
+ #
20
+ # @example Defining a simple Chain with three steps
21
+ # class CreateUser
22
+ # include ::Teckel::Operation::Results
23
+ #
24
+ # input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
25
+ # output Types.Instance(User)
26
+ # error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
27
+ #
28
+ # def call(input)
29
+ # user = User.new(name: input[:name], age: input[:age])
30
+ # if user.safe
31
+ # success!(user)
32
+ # else
33
+ # fail!(message: "Could not safe User", errors: user.errors)
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # class LogUser
39
+ # include ::Teckel::Operation::Results
40
+ #
41
+ # input Types.Instance(User)
42
+ # output input
43
+ #
44
+ # def call(usr)
45
+ # Logger.new(File::NULL).info("User #{usr.name} created")
46
+ # usr # we need to return the correct output type
47
+ # end
48
+ # end
49
+ #
50
+ # class AddFriend
51
+ # class << self
52
+ # # Don't actually do this! It's not safe and for generating the failure sample only.
53
+ # attr_accessor :fail_befriend
54
+ # end
55
+ #
56
+ # include ::Teckel::Operation::Results
57
+ #
58
+ # input Types.Instance(User)
59
+ # output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
60
+ # error Types::Hash.schema(message: Types::String)
61
+ #
62
+ # def call(user)
63
+ # if self.class.fail_befriend
64
+ # fail!(message: "Did not find a friend.")
65
+ # else
66
+ # { user: user, friend: User.new(name: "A friend", age: 42) }
67
+ # end
68
+ # end
69
+ # end
70
+ #
71
+ # class MyChain
72
+ # include Teckel::Chain
73
+ #
74
+ # step :create, CreateUser
75
+ # step :log, LogUser
76
+ # step :befriend, AddFriend
77
+ # end
78
+ #
79
+ # result = MyChain.call(name: "Bob", age: 23)
80
+ # result.is_a?(Teckel::Result) #=> true
81
+ # result.success[:user].is_a?(User) #=> true
82
+ # result.success[:friend].is_a?(User) #=> true
83
+ #
84
+ # AddFriend.fail_befriend = true
85
+ # failure_result = MyChain.call(name: "Bob", age: 23)
86
+ # failure_result.is_a?(Teckel::Chain::StepFailure) #=> true
87
+ #
88
+ # # additional step information
89
+ # failure_result.step_name #=> :befriend
90
+ # failure_result.step #=> AddFriend
91
+ #
92
+ # # otherwise behaves just like a normal +Result+
93
+ # failure_result.failure? #=> true
94
+ # failure_result.failure #=> {message: "Did not find a friend."}
95
+ module Chain
96
+ # Like +Teckel::Result+ but for failing Chains
97
+ #
98
+ # When a Chain fails, it stores the failed +Operation+ and it's name.
99
+ class StepFailure
100
+ extend Forwardable
101
+
102
+ def initialize(step, step_name, result)
103
+ @step, @step_name, @result = step, step_name, result
104
+ end
105
+
106
+ # @!attribute step [R]
107
+ # @return [Teckel::Operation] the failed Operation
108
+ attr_reader :step
109
+
110
+ # @!attribute step_name [R]
111
+ # @return [String] the step name of the failed Operation
112
+ attr_reader :step_name
113
+
114
+ # @!attribute result [R]
115
+ # @return [Teckel::Result] the failure Result
116
+ attr_reader :result
117
+
118
+ # @!method value
119
+ # Delegates to +result.value+
120
+ # @see Teckel::Result#value
121
+ # @!method successful?
122
+ # Delegates to +result.successful?+
123
+ # @see Teckel::Result#successful?
124
+ # @!method success
125
+ # Delegates to +result.success+
126
+ # @see Teckel::Result#success
127
+ # @!method failure?
128
+ # Delegates to +result.failure?+
129
+ # @see Teckel::Result#failure?
130
+ # @!method failure
131
+ # Delegates to +result.failure+
132
+ # @see Teckel::Result#failure
133
+ def_delegators :@result, :value, :successful?, :success, :failure?, :failure
134
+ end
135
+
136
+ module ClassMethods
137
+ def input
138
+ @steps.first&.last&.input
139
+ end
140
+
141
+ def output
142
+ @steps.last&.last&.output
143
+ end
144
+
145
+ def errors
146
+ @steps.each_with_object([]) do |e, m|
147
+ err = e.last&.error
148
+ m << err if err
149
+ end
150
+ end
151
+
152
+ def call(input)
153
+ new.call!(@steps, input)
154
+ end
155
+
156
+ def step(name, operation)
157
+ @steps << [name, operation]
158
+ end
159
+ end
160
+
161
+ module InstanceMethods
162
+ def call!(steps, input)
163
+ result = input
164
+ failed = nil
165
+ steps.each do |(name, step)|
166
+ result = step.call(result)
167
+ if result.failure?
168
+ failed = StepFailure.new(step, name, result)
169
+ break
170
+ end
171
+ end
172
+
173
+ failed || result
174
+ end
175
+ end
176
+
177
+ def self.included(receiver)
178
+ receiver.extend ClassMethods
179
+ receiver.send :include, InstanceMethods
180
+
181
+ receiver.class_eval do
182
+ @steps = []
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ class Config
5
+ class FrozenConfigError < Teckel::Error; end
6
+
7
+ @default_constructor = :[]
8
+ class << self
9
+ def default_constructor(sym_or_proc = nil)
10
+ return @default_constructor if sym_or_proc.nil?
11
+
12
+ @default_constructor = sym_or_proc
13
+ end
14
+ end
15
+
16
+ def initialize
17
+ @input_class = nil
18
+ @input_constructor = nil
19
+
20
+ @output_class = nil
21
+ @output_constructor = nil
22
+
23
+ @error_class = nil
24
+ @error_constructor = nil
25
+ end
26
+
27
+ def input(klass = nil)
28
+ return @input_class if klass.nil?
29
+ raise FrozenConfigError unless @input_class.nil?
30
+
31
+ @input_class = klass
32
+ end
33
+
34
+ def input_constructor(sym_or_proc = nil)
35
+ return (@input_constructor || self.class.default_constructor) if sym_or_proc.nil?
36
+ raise FrozenConfigError unless @input_constructor.nil?
37
+
38
+ @input_constructor = sym_or_proc
39
+ end
40
+
41
+ def output(klass = nil)
42
+ return @output_class if klass.nil?
43
+ raise FrozenConfigError unless @output_class.nil?
44
+
45
+ @output_class = klass
46
+ end
47
+
48
+ def output_constructor(sym_or_proc = nil)
49
+ return (@output_constructor || self.class.default_constructor) if sym_or_proc.nil?
50
+ raise FrozenConfigError unless @output_constructor.nil?
51
+
52
+ @output_constructor = sym_or_proc
53
+ end
54
+
55
+ def error(klass = nil)
56
+ return @error_class if klass.nil?
57
+ raise FrozenConfigError unless @error_class.nil?
58
+
59
+ @error_class = klass
60
+ end
61
+
62
+ def error_constructor(sym_or_proc = nil)
63
+ return (@error_constructor || self.class.default_constructor) if sym_or_proc.nil?
64
+ raise FrozenConfigError unless @error_constructor.nil?
65
+
66
+ @error_constructor = sym_or_proc
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ module Operation
5
+ # Works just like +Teckel::Operation+, but wraps +output+ and +error+ into a +Teckel::Result+.
6
+ #
7
+ # A +Teckel::Result+ given as +input+ will get unwrapped, so that the original +value+
8
+ # gets passed to your Operation code.
9
+ #
10
+ # @example
11
+ #
12
+ # class CreateUser
13
+ # include Teckel::Operation::Results
14
+ #
15
+ # input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
16
+ # output Types.Instance(User)
17
+ # error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
18
+ #
19
+ # # @param [Hash<name: String, age: Integer>]
20
+ # # @return [User | Hash<message: String, errors: [Hash]>]
21
+ # def call(input)
22
+ # user = User.new(name: input[:name], age: input[:age])
23
+ # if user.safe
24
+ # # exits early with success, prevents any further execution
25
+ # success!(user)
26
+ # else
27
+ # fail!(message: "Could not safe User", errors: user.errors)
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ # # A success call:
33
+ # CreateUser.call(name: "Bob", age: 23).is_a?(Teckel::Result) #=> true
34
+ # CreateUser.call(name: "Bob", age: 23).success.is_a?(User) #=> true
35
+ #
36
+ # # A failure call:
37
+ # CreateUser.call(name: "Bob", age: 10).is_a?(Teckel::Result) #=> true
38
+ # CreateUser.call(name: "Bob", age: 10).failure.is_a?(Hash) #=> true
39
+ #
40
+ # # Unwrapping success input:
41
+ # CreateUser.call(Teckel::Result.new({name: "Bob", age: 23}, true)).success.is_a?(User) #=> true
42
+ #
43
+ # # Unwrapping failure input:
44
+ # CreateUser.call(Teckel::Result.new({name: "Bob", age: 23}, false)).success.is_a?(User) #=> true
45
+ #
46
+ # @api public
47
+ module Results
48
+ module InstanceMethods
49
+ private
50
+
51
+ def build_input(input)
52
+ input = input.value if input.is_a?(Teckel::Result)
53
+ super(input)
54
+ end
55
+
56
+ def build_output(*args)
57
+ Teckel::Result.new(super, true)
58
+ end
59
+
60
+ def build_error(*args)
61
+ Teckel::Result.new(super, false)
62
+ end
63
+ end
64
+
65
+ def self.included(receiver)
66
+ receiver.send :include, Teckel::Operation unless Teckel::Operation >= receiver
67
+ receiver.send :include, InstanceMethods
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ # The main operation Mixin
5
+ #
6
+ # Each operation is expected to declare +input+. +output+ and +error+ classes.
7
+ #
8
+ # There are two ways of declaring those classes. The first way is to define
9
+ # the constants +Input+, +Output+ and +Error+, the second way is to use the
10
+ # +input+. +output+ and +error+ methods to point them to anonymous classes.
11
+ #
12
+ # If you like "traditional" result objects (to ask +successful?+ or +failure?+ on)
13
+ # see +Teckel::Operation::Results+
14
+ #
15
+ # @see Teckel::Operation::Results
16
+ #
17
+ # @example class definitions via constants
18
+ # class CreateUserViaConstants
19
+ # include Teckel::Operation
20
+ #
21
+ # class Input
22
+ # def initialize(name:, age:)
23
+ # @name, @age = name, age
24
+ # end
25
+ # attr_reader :name, :age
26
+ # end
27
+ #
28
+ # Output = ::User
29
+ #
30
+ # class Error
31
+ # def initialize(message, errors)
32
+ # @message, @errors = message, errors
33
+ # end
34
+ # attr_reader :message, :errors
35
+ # end
36
+ #
37
+ # input_constructor :new
38
+ # error_constructor :new
39
+ #
40
+ # # @param [CreateUser::Input]
41
+ # # @return [User | CreateUser::Error]
42
+ # def call(input)
43
+ # user = ::User.new(name: input.name, age: input.age)
44
+ # if user.safe
45
+ # user
46
+ # else
47
+ # fail!(message: "Could not safe User", errors: user.errors)
48
+ # end
49
+ # end
50
+ # end
51
+ #
52
+ # CreateUserViaConstants.call(name: "Bob", age: 23).is_a?(User) #=> true
53
+ #
54
+ # @example class definitions via methods
55
+ # class CreateUserViaMethods
56
+ # include Teckel::Operation
57
+ #
58
+ # input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
59
+ # output Types.Instance(User)
60
+ # error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
61
+ #
62
+ # # @param [Hash<name: String, age: Integer>]
63
+ # # @return [User | Hash<message: String, errors: [Hash]>]
64
+ # def call(input)
65
+ # user = User.new(name: input[:name], age: input[:age])
66
+ # if user.safe
67
+ # # exits early with success, prevents any further execution
68
+ # success!(user)
69
+ # else
70
+ # fail!(message: "Could not safe User", errors: user.errors)
71
+ # end
72
+ # end
73
+ # end
74
+ #
75
+ # # A success call:
76
+ # CreateUserViaMethods.call(name: "Bob", age: 23).is_a?(User) #=> true
77
+ #
78
+ # # A failure call:
79
+ # CreateUserViaMethods.call(name: "Bob", age: 10).eql?(message: "Could not safe User", errors: [{age: "underage"}]) #=> true
80
+ #
81
+ # # Build your Input, Output and Error classes in a way that let you know:
82
+ # begin; CreateUserViaMethods.call(unwanted: "input"); rescue => e; e end.is_a?(::Dry::Types::MissingKeyError) #=> true
83
+ #
84
+ # # Feed an instance of the input class directly to call:
85
+ # CreateUserViaMethods.call(CreateUserViaMethods.input[name: "Bob", age: 23]).is_a?(User) #=> true
86
+ #
87
+ # @api public
88
+ module Operation
89
+ module ClassMethods
90
+ # @!attribute [r] input()
91
+ # Get the configured class wrapping the input data structure.
92
+ # @return [Class] The +input+ class
93
+
94
+ # @!method input(klass)
95
+ # Set the class wrapping the input data structure.
96
+ # @param klass [Class] The +input+ class
97
+ # @return [Class] The +input+ class
98
+ def input(klass = nil)
99
+ return @input_class if @input_class
100
+
101
+ @input_class = @config.input(klass)
102
+ @input_class ||= self::Input if const_defined?(:Input)
103
+ @input_class
104
+ end
105
+
106
+ # @!attribute [r] input_constructor()
107
+ # The callable constructor to build an instance of the +input+ class.
108
+ # @return [Class] The Input class
109
+
110
+ # @!method input_constructor(sym_or_proc)
111
+ # Define how to build the +input+.
112
+ # @param sym_or_proc [Symbol|#call]
113
+ # - Either a +Symbol+ representing the _public_ method to call on the +input+ class.
114
+ # - Or a callable (like a +Proc+).
115
+ # @return [#call] The callable constructor
116
+ #
117
+ # @example simple symbol to method constructor
118
+ # class MyOperation
119
+ # include Teckel::Operation
120
+ #
121
+ # class Input
122
+ # # ...
123
+ # end
124
+ #
125
+ # # If you need more control over how to build a new +Input+ instance
126
+ # # MyOperation.call(name: "Bob", age: 23) # -> Input.new(name: "Bob", age: 23)
127
+ # input_constructor :new
128
+ # end
129
+ #
130
+ # MyOperation.input_constructor.is_a?(Method) #=> true
131
+ #
132
+ # @example Custom Proc constructor
133
+ # class MyOperation
134
+ # include Teckel::Operation
135
+ #
136
+ # class Input
137
+ # # ...
138
+ # end
139
+ #
140
+ # # If you need more control over how to build a new +Input+ instance
141
+ # # MyOperation.call("foo", opt: "bar") # -> Input.new(name: "foo", opt: "bar")
142
+ # input_constructor ->(name, options) { Input.new(name: name, **options) }
143
+ # end
144
+ #
145
+ # MyOperation.input_constructor.is_a?(Proc) #=> true
146
+ def input_constructor(sym_or_proc = nil)
147
+ return @input_constructor if @input_constructor
148
+
149
+ constructor = @config.input_constructor(sym_or_proc)
150
+ @input_constructor =
151
+ if constructor.is_a?(Symbol) && input.respond_to?(constructor)
152
+ input.public_method(constructor)
153
+ elsif sym_or_proc.respond_to?(:call)
154
+ sym_or_proc
155
+ end
156
+ end
157
+
158
+ # @!attribute [r] output()
159
+ # Get the configured class wrapping the output data structure.
160
+ # @return [Class] The +output+ class
161
+
162
+ # @!method output(klass)
163
+ # Set the class wrapping the output data structure.
164
+ # @param klass [Class] The +output+ class
165
+ # @return [Class] The +output+ class
166
+ def output(klass = nil)
167
+ return @output_class if @output_class
168
+
169
+ @output_class = @config.output(klass)
170
+ @output_class ||= self::Output if const_defined?(:Output)
171
+ @output_class
172
+ end
173
+
174
+ # @!attribute [r] output_constructor()
175
+ # The callable constructor to build an instance of the +output+ class.
176
+ # @return [Class] The Output class
177
+
178
+ # @!method output_constructor(sym_or_proc)
179
+ # Define how to build the +output+.
180
+ # @param sym_or_proc [Symbol|#call]
181
+ # - Either a +Symbol+ representing the _public_ method to call on the +output+ class.
182
+ # - Or a callable (like a +Proc+).
183
+ # @return [#call] The callable constructor
184
+ #
185
+ # @example
186
+ # class MyOperation
187
+ # include Teckel::Operation
188
+ #
189
+ # class Output
190
+ # # ....
191
+ # end
192
+ #
193
+ # # MyOperation.call("foo", "bar") # -> Output.new("foo", "bar")
194
+ # output_constructor :new
195
+ #
196
+ # # If you need more control over how to build a new +Output+ instance
197
+ # # MyOperation.call("foo", opt: "bar") # -> Output.new(name: "foo", opt: "bar")
198
+ # output_constructor ->(name, options) { Output.new(name: name, **options) }
199
+ # end
200
+ def output_constructor(sym_or_proc = nil)
201
+ return @output_constructor if @output_constructor
202
+
203
+ constructor = @config.output_constructor(sym_or_proc)
204
+ @output_constructor =
205
+ if constructor.is_a?(Symbol) && output.respond_to?(constructor)
206
+ output.public_method(constructor)
207
+ elsif sym_or_proc.respond_to?(:call)
208
+ sym_or_proc
209
+ end
210
+ end
211
+
212
+ # @!attribute [r] error()
213
+ # Get the configured class wrapping the error data structure.
214
+ # @return [Class] The +error+ class
215
+
216
+ # @!method error(klass)
217
+ # Set the class wrapping the error data structure.
218
+ # @param klass [Class] The +error+ class
219
+ # @return [Class] The +error+ class
220
+ def error(klass = nil)
221
+ return @error_class if @error_class
222
+
223
+ @error_class = @config.error(klass)
224
+ @error_class ||= self::Error if const_defined?(:Error)
225
+ @error_class
226
+ end
227
+
228
+ # @!attribute [r] error_constructor()
229
+ # The callable constructor to build an instance of the +error+ class.
230
+ # @return [Class] The Error class
231
+
232
+ # @!method error_constructor(sym_or_proc)
233
+ # Define how to build the +error+.
234
+ # @param sym_or_proc [Symbol|#call]
235
+ # - Either a +Symbol+ representing the _public_ method to call on the +error+ class.
236
+ # - Or a callable (like a +Proc+).
237
+ # @return [#call] The callable constructor
238
+ #
239
+ # @example
240
+ # class MyOperation
241
+ # include Teckel::Operation
242
+ #
243
+ # class Error
244
+ # # ....
245
+ # end
246
+ #
247
+ # # MyOperation.call("foo", "bar") # -> Error.new("foo", "bar")
248
+ # error_constructor :new
249
+ #
250
+ # # If you need more control over how to build a new +Error+ instance
251
+ # # MyOperation.call("foo", opt: "bar") # -> Error.new(name: "foo", opt: "bar")
252
+ # error_constructor ->(name, options) { Error.new(name: name, **options) }
253
+ # end
254
+ def error_constructor(sym_or_proc = nil)
255
+ return @error_constructor if @error_constructor
256
+
257
+ constructor = @config.error_constructor(sym_or_proc)
258
+ @error_constructor =
259
+ if constructor.is_a?(Symbol) && error.respond_to?(constructor)
260
+ error.public_method(constructor)
261
+ elsif sym_or_proc.respond_to?(:call)
262
+ sym_or_proc
263
+ end
264
+ end
265
+
266
+ # Invoke the Operation
267
+ #
268
+ # @param input Any form of input your +input+ class can handle via the given +input_constructor+
269
+ # @return Either An instance of your defined +error+ class or +output+ class
270
+ def call(input)
271
+ new.call!(input)
272
+ end
273
+ end
274
+
275
+ module InstanceMethods
276
+ # @!visibility protected
277
+ def call!(input)
278
+ catch(:failure) do
279
+ out = catch(:success) do
280
+ simple_ret = call(build_input(input))
281
+ build_output(simple_ret)
282
+ end
283
+ return out
284
+ end
285
+ end
286
+
287
+ # @!visibility protected
288
+ def success!(*args)
289
+ throw :success, build_output(*args)
290
+ end
291
+
292
+ # @!visibility protected
293
+ def fail!(*args)
294
+ throw :failure, build_error(*args)
295
+ end
296
+
297
+ private
298
+
299
+ def build_input(input)
300
+ self.class.input_constructor.call(input)
301
+ end
302
+
303
+ def build_output(*args)
304
+ if args.size == 1 && self.class.output === args.first # rubocop:disable Style/CaseEquality
305
+ args.first
306
+ else
307
+ self.class.output_constructor.call(*args)
308
+ end
309
+ end
310
+
311
+ def build_error(*args)
312
+ if args.size == 1 && self.class.error === args.first # rubocop:disable Style/CaseEquality
313
+ args.first
314
+ else
315
+ self.class.error_constructor.call(*args)
316
+ end
317
+ end
318
+ end
319
+
320
+ def self.included(receiver)
321
+ receiver.extend ClassMethods
322
+ receiver.send :include, InstanceMethods
323
+
324
+ receiver.class_eval do
325
+ @config = Config.new
326
+
327
+ @input_class = nil
328
+ @input_constructor = nil
329
+
330
+ @output_class = nil
331
+ @output_constructor = nil
332
+
333
+ @error_class = nil
334
+ @error_constructor = nil
335
+
336
+ protected :success!, :fail!
337
+ end
338
+ end
339
+ end
340
+ end