teckel 0.0.1 → 0.1.0

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