teckel 0.3.0 → 0.4.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 +53 -0
- data/LICENSE_LOGO +4 -0
- data/README.md +4 -4
- data/lib/teckel.rb +9 -3
- data/lib/teckel/chain.rb +99 -271
- data/lib/teckel/chain/result.rb +38 -0
- data/lib/teckel/chain/runner.rb +51 -0
- data/lib/teckel/chain/step.rb +18 -0
- data/lib/teckel/config.rb +1 -23
- data/lib/teckel/contracts.rb +19 -0
- data/lib/teckel/operation.rb +309 -215
- data/lib/teckel/operation/result.rb +92 -0
- data/lib/teckel/operation/runner.rb +70 -0
- data/lib/teckel/result.rb +52 -53
- data/lib/teckel/version.rb +1 -1
- data/spec/chain/inheritance_spec.rb +116 -0
- data/spec/chain/results_spec.rb +53 -0
- data/spec/chain_around_hook_spec.rb +100 -0
- data/spec/chain_spec.rb +180 -0
- data/spec/config_spec.rb +26 -0
- data/spec/doctest_helper.rb +7 -0
- data/spec/operation/inheritance_spec.rb +94 -0
- data/spec/operation/result_spec.rb +34 -0
- data/spec/operation/results_spec.rb +117 -0
- data/spec/operation_spec.rb +485 -0
- data/spec/rb27/pattern_matching_spec.rb +193 -0
- data/spec/result_spec.rb +20 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/support/dry_base.rb +8 -0
- data/spec/support/fake_db.rb +12 -0
- data/spec/support/fake_models.rb +20 -0
- data/spec/teckel_spec.rb +7 -0
- metadata +52 -25
- data/.codeclimate.yml +0 -3
- data/.github/workflows/ci.yml +0 -92
- data/.github/workflows/pages.yml +0 -50
- data/.gitignore +0 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -12
- data/.ruby-version +0 -1
- data/DEVELOPMENT.md +0 -32
- data/Gemfile +0 -16
- data/Rakefile +0 -35
- data/bin/console +0 -15
- data/bin/rake +0 -29
- data/bin/rspec +0 -29
- data/bin/rubocop +0 -18
- data/bin/setup +0 -8
- data/lib/teckel/none.rb +0 -18
- data/lib/teckel/operation/results.rb +0 -72
- data/teckel.gemspec +0 -32
@@ -0,0 +1,485 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'support/dry_base'
|
4
|
+
require 'support/fake_models'
|
5
|
+
|
6
|
+
RSpec.describe Teckel::Operation do
|
7
|
+
context "predefined classes" do
|
8
|
+
module TeckelOperationPredefinedClassesTest
|
9
|
+
class CreateUserInput < Dry::Struct
|
10
|
+
attribute :name, Types::String
|
11
|
+
attribute :age, Types::Coercible::Integer
|
12
|
+
end
|
13
|
+
|
14
|
+
CreateUserOutput = Types.Instance(User)
|
15
|
+
|
16
|
+
class CreateUserError < Dry::Struct
|
17
|
+
attribute :message, Types::String
|
18
|
+
attribute :status_code, Types::Integer
|
19
|
+
attribute :meta, Types::Hash.optional
|
20
|
+
end
|
21
|
+
|
22
|
+
class CreateUser
|
23
|
+
include Teckel::Operation
|
24
|
+
|
25
|
+
input CreateUserInput
|
26
|
+
output CreateUserOutput
|
27
|
+
error CreateUserError
|
28
|
+
|
29
|
+
def call(input)
|
30
|
+
user = User.new(**input.attributes)
|
31
|
+
if user.save
|
32
|
+
success!(user)
|
33
|
+
else
|
34
|
+
fail!(
|
35
|
+
message: "Could not create User",
|
36
|
+
status_code: 400,
|
37
|
+
meta: { validation: user.errors }
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
specify "Input" do
|
45
|
+
expect(TeckelOperationPredefinedClassesTest::CreateUser.input).to eq(TeckelOperationPredefinedClassesTest::CreateUserInput)
|
46
|
+
end
|
47
|
+
|
48
|
+
specify "Output" do
|
49
|
+
expect(TeckelOperationPredefinedClassesTest::CreateUser.output).to eq(TeckelOperationPredefinedClassesTest::CreateUserOutput)
|
50
|
+
end
|
51
|
+
|
52
|
+
specify "Error" do
|
53
|
+
expect(TeckelOperationPredefinedClassesTest::CreateUser.error).to eq(TeckelOperationPredefinedClassesTest::CreateUserError)
|
54
|
+
end
|
55
|
+
|
56
|
+
context "success" do
|
57
|
+
specify do
|
58
|
+
result = TeckelOperationPredefinedClassesTest::CreateUser.call(name: "Bob", age: 23)
|
59
|
+
expect(result).to be_a(User)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context "error" do
|
64
|
+
specify do
|
65
|
+
result = TeckelOperationPredefinedClassesTest::CreateUser.call(name: "Bob", age: 7)
|
66
|
+
expect(result).to be_a(TeckelOperationPredefinedClassesTest::CreateUserError)
|
67
|
+
expect(result).to have_attributes(
|
68
|
+
message: "Could not create User",
|
69
|
+
status_code: 400,
|
70
|
+
meta: { validation: [{ age: "underage" }] }
|
71
|
+
)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context "inline classes" do
|
77
|
+
module TeckelOperationInlineClassesTest
|
78
|
+
class CreateUser
|
79
|
+
include Teckel::Operation
|
80
|
+
|
81
|
+
class Input < Dry::Struct
|
82
|
+
attribute :name, Types::String
|
83
|
+
attribute :age, Types::Coercible::Integer
|
84
|
+
end
|
85
|
+
|
86
|
+
Output = Types.Instance(User)
|
87
|
+
|
88
|
+
class Error < Dry::Struct
|
89
|
+
attribute :message, Types::String
|
90
|
+
attribute :status_code, Types::Integer
|
91
|
+
attribute :meta, Types::Hash.optional
|
92
|
+
end
|
93
|
+
|
94
|
+
def call(input)
|
95
|
+
user = User.new(**input.attributes)
|
96
|
+
if user.save
|
97
|
+
user
|
98
|
+
else
|
99
|
+
fail!(
|
100
|
+
message: "Could not create User",
|
101
|
+
status_code: 400,
|
102
|
+
meta: { validation: user.errors }
|
103
|
+
)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
specify "Input" do
|
110
|
+
expect(TeckelOperationInlineClassesTest::CreateUser.input).to be <= Dry::Struct
|
111
|
+
end
|
112
|
+
|
113
|
+
specify "Output" do
|
114
|
+
expect(TeckelOperationInlineClassesTest::CreateUser.output).to be_a Dry::Types::Constrained
|
115
|
+
end
|
116
|
+
|
117
|
+
specify "Error" do
|
118
|
+
expect(TeckelOperationInlineClassesTest::CreateUser.error).to be <= Dry::Struct
|
119
|
+
end
|
120
|
+
|
121
|
+
context "success" do
|
122
|
+
specify do
|
123
|
+
result = TeckelOperationInlineClassesTest::CreateUser.call(name: "Bob", age: 23)
|
124
|
+
expect(result).to be_a(User)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
context "error" do
|
129
|
+
specify do
|
130
|
+
result = TeckelOperationInlineClassesTest::CreateUser.call(name: "Bob", age: 7)
|
131
|
+
expect(result).to have_attributes(
|
132
|
+
message: "Could not create User",
|
133
|
+
status_code: 400,
|
134
|
+
meta: { validation: [{ age: "underage" }] }
|
135
|
+
)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
context "annon classes" do
|
141
|
+
module TeckelOperationAnnonClassesTest
|
142
|
+
class CreateUser
|
143
|
+
include ::Teckel::Operation
|
144
|
+
|
145
|
+
input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
|
146
|
+
output Types.Instance(User)
|
147
|
+
error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
|
148
|
+
|
149
|
+
def call(input)
|
150
|
+
user = User.new(name: input[:name], age: input[:age])
|
151
|
+
if user.save
|
152
|
+
user
|
153
|
+
else
|
154
|
+
fail!(message: "Could not save User", errors: user.errors)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
specify "output" do
|
161
|
+
expect(TeckelOperationAnnonClassesTest::CreateUser.call(name: "Bob", age: 23)).to be_a(User)
|
162
|
+
end
|
163
|
+
|
164
|
+
specify "errors" do
|
165
|
+
expect(TeckelOperationAnnonClassesTest::CreateUser.call(name: "Bob", age: 10)).to eq(message: "Could not save User", errors: [{ age: "underage" }])
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
context "keyword contracts" do
|
170
|
+
class CreateUserKeywordInit
|
171
|
+
include Teckel::Operation
|
172
|
+
|
173
|
+
class Input
|
174
|
+
def initialize(name:, age:)
|
175
|
+
@name, @age = name, age
|
176
|
+
end
|
177
|
+
attr_reader :name, :age
|
178
|
+
end
|
179
|
+
|
180
|
+
input_constructor ->(data) { input.new(**data) }
|
181
|
+
|
182
|
+
Output = ::User
|
183
|
+
|
184
|
+
class Error
|
185
|
+
def initialize(message, errors)
|
186
|
+
@message, @errors = message, errors
|
187
|
+
end
|
188
|
+
attr_reader :message, :errors
|
189
|
+
end
|
190
|
+
error_constructor :new
|
191
|
+
|
192
|
+
def call(input)
|
193
|
+
user = ::User.new(name: input.name, age: input.age)
|
194
|
+
if user.save
|
195
|
+
user
|
196
|
+
else
|
197
|
+
fail!(message: "Could not save User", errors: user.errors)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
specify do
|
203
|
+
expect(CreateUserKeywordInit.call(name: "Bob", age: 23)).to be_a(User)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
context "splat contracts" do
|
208
|
+
class CreateUserSplatInit
|
209
|
+
include Teckel::Operation
|
210
|
+
|
211
|
+
input Struct.new(:name, :age)
|
212
|
+
input_constructor ->(data) { input.new(*data) }
|
213
|
+
|
214
|
+
Output = ::User
|
215
|
+
|
216
|
+
class Error
|
217
|
+
def initialize(message, errors)
|
218
|
+
@message, @errors = message, errors
|
219
|
+
end
|
220
|
+
attr_reader :message, :errors
|
221
|
+
end
|
222
|
+
error_constructor :new
|
223
|
+
|
224
|
+
def call(input)
|
225
|
+
user = ::User.new(name: input.name, age: input.age)
|
226
|
+
if user.save
|
227
|
+
user
|
228
|
+
else
|
229
|
+
fail!(message: "Could not save User", errors: user.errors)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
specify do
|
235
|
+
expect(CreateUserSplatInit.call(["Bob", 23])).to be_a(User)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
context "generated output" do
|
240
|
+
module TeckelOperationGeneratedOutputTest
|
241
|
+
class MyOperation
|
242
|
+
include ::Teckel::Operation
|
243
|
+
|
244
|
+
input none
|
245
|
+
output Struct.new(:some_key)
|
246
|
+
output_constructor ->(data) { output.new(*data.values_at(*output.members)) } # ruby 2.4 way for `keyword_init: true`
|
247
|
+
error none
|
248
|
+
|
249
|
+
def call(_input)
|
250
|
+
{ some_key: "some_value" }
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
specify "result" do
|
256
|
+
result = TeckelOperationGeneratedOutputTest::MyOperation.call
|
257
|
+
expect(result).to be_a(Struct)
|
258
|
+
expect(result.some_key).to eq("some_value")
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
context "inject settings" do
|
263
|
+
module TeckelOperationInjectSettingsTest
|
264
|
+
class MyOperation
|
265
|
+
include ::Teckel::Operation
|
266
|
+
|
267
|
+
settings Struct.new(:injected)
|
268
|
+
settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
|
269
|
+
|
270
|
+
input none
|
271
|
+
output Array
|
272
|
+
error none
|
273
|
+
|
274
|
+
def call(_input)
|
275
|
+
(settings&.injected || []) << :operation_data
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
it "settings in operation instances are nil by default" do
|
281
|
+
op = TeckelOperationInjectSettingsTest::MyOperation.new
|
282
|
+
expect(op.settings).to be_nil
|
283
|
+
end
|
284
|
+
|
285
|
+
it "uses injected data" do
|
286
|
+
result =
|
287
|
+
TeckelOperationInjectSettingsTest::MyOperation.
|
288
|
+
with(injected: [:stuff]).
|
289
|
+
call
|
290
|
+
|
291
|
+
expect(result).to eq([:stuff, :operation_data])
|
292
|
+
|
293
|
+
expect(TeckelOperationInjectSettingsTest::MyOperation.call).to eq([:operation_data])
|
294
|
+
end
|
295
|
+
|
296
|
+
specify "calling `with` multiple times raises an error" do
|
297
|
+
op = TeckelOperationInjectSettingsTest::MyOperation.with(injected: :stuff_1)
|
298
|
+
|
299
|
+
expect {
|
300
|
+
op.with(more: :stuff_2)
|
301
|
+
}.to raise_error(Teckel::Error)
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
context "operation with no settings" do
|
306
|
+
module TeckelOperationNoSettingsTest
|
307
|
+
class MyOperation
|
308
|
+
include ::Teckel::Operation
|
309
|
+
|
310
|
+
input none
|
311
|
+
output none
|
312
|
+
error none
|
313
|
+
|
314
|
+
def call(_input); end
|
315
|
+
end
|
316
|
+
MyOperation.finalize!
|
317
|
+
end
|
318
|
+
|
319
|
+
it "uses None as default settings class" do
|
320
|
+
expect(TeckelOperationNoSettingsTest::MyOperation.settings).to eq(Teckel::Contracts::None)
|
321
|
+
expect(TeckelOperationNoSettingsTest::MyOperation.new.settings).to be_nil
|
322
|
+
end
|
323
|
+
|
324
|
+
it "raises error when trying to set settings" do
|
325
|
+
expect {
|
326
|
+
TeckelOperationNoSettingsTest::MyOperation.with(any: :thing)
|
327
|
+
}.to raise_error(ArgumentError, "None called with arguments")
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
context "None in, out, err" do
|
332
|
+
module TeckelOperationNoneDataTest
|
333
|
+
class MyOperation
|
334
|
+
include ::Teckel::Operation
|
335
|
+
|
336
|
+
settings Struct.new(:fail_it, :fail_data, :success_it, :success_data)
|
337
|
+
settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
|
338
|
+
|
339
|
+
input none
|
340
|
+
output none
|
341
|
+
error none
|
342
|
+
|
343
|
+
def call(_input)
|
344
|
+
if settings&.fail_it
|
345
|
+
if settings&.fail_data
|
346
|
+
fail!(settings.fail_data)
|
347
|
+
else
|
348
|
+
fail!
|
349
|
+
end
|
350
|
+
elsif settings&.success_it
|
351
|
+
if settings&.success_data
|
352
|
+
success!(settings.success_data)
|
353
|
+
else
|
354
|
+
success!
|
355
|
+
end
|
356
|
+
else
|
357
|
+
settings&.success_data
|
358
|
+
end
|
359
|
+
end
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
let(:operation) { TeckelOperationNoneDataTest::MyOperation }
|
364
|
+
|
365
|
+
it "raises error when called with input data" do
|
366
|
+
expect { operation.call("stuff") }.to raise_error(ArgumentError)
|
367
|
+
end
|
368
|
+
|
369
|
+
it "raises error when fail! with data" do
|
370
|
+
expect {
|
371
|
+
operation.with(fail_it: true, fail_data: "stuff").call
|
372
|
+
}.to raise_error(ArgumentError)
|
373
|
+
end
|
374
|
+
|
375
|
+
it "returns nil as failure result when fail! without arguments" do
|
376
|
+
expect(operation.with(fail_it: true).call).to be_nil
|
377
|
+
end
|
378
|
+
|
379
|
+
it "raises error when success! with data" do
|
380
|
+
expect {
|
381
|
+
operation.with(success_it: true, success_data: "stuff").call
|
382
|
+
}.to raise_error(ArgumentError)
|
383
|
+
end
|
384
|
+
|
385
|
+
it "returns nil as success result when success! without arguments" do
|
386
|
+
expect(operation.with(success_it: true).call).to be_nil
|
387
|
+
end
|
388
|
+
|
389
|
+
it "raises error when returning data" do
|
390
|
+
expect {
|
391
|
+
operation.with(success_it: false, success_data: "stuff").call
|
392
|
+
}.to raise_error(ArgumentError)
|
393
|
+
end
|
394
|
+
|
395
|
+
it "returns nil as success result when returning nil" do
|
396
|
+
expect(operation.call).to be_nil
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
describe "#finalize!" do
|
401
|
+
let(:frozen_error) do
|
402
|
+
# different ruby versions raise different errors
|
403
|
+
defined?(FrozenError) ? FrozenError : RuntimeError
|
404
|
+
end
|
405
|
+
|
406
|
+
subject do
|
407
|
+
Class.new do
|
408
|
+
include ::Teckel::Operation
|
409
|
+
|
410
|
+
input Struct.new(:input_data)
|
411
|
+
output Struct.new(:output_data)
|
412
|
+
|
413
|
+
def call(input)
|
414
|
+
success!(input.input_data * 2)
|
415
|
+
end
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
it "fails b/c error config is missing" do
|
420
|
+
expect {
|
421
|
+
subject.finalize!
|
422
|
+
}.to raise_error(Teckel::MissingConfigError, "Missing error config for #{subject}")
|
423
|
+
end
|
424
|
+
|
425
|
+
specify "#dup" do
|
426
|
+
new_operation = subject.dup
|
427
|
+
new_operation.error Struct.new(:error)
|
428
|
+
expect { new_operation.finalize! }.to_not raise_error
|
429
|
+
|
430
|
+
expect {
|
431
|
+
subject.finalize!
|
432
|
+
}.to raise_error(Teckel::MissingConfigError, "Missing error config for #{subject}")
|
433
|
+
end
|
434
|
+
|
435
|
+
specify "#clone" do
|
436
|
+
new_operation = subject.clone
|
437
|
+
new_operation.error Struct.new(:error)
|
438
|
+
expect { new_operation.finalize! }.to_not raise_error
|
439
|
+
|
440
|
+
expect {
|
441
|
+
subject.finalize!
|
442
|
+
}.to raise_error(Teckel::MissingConfigError, "Missing error config for #{subject}")
|
443
|
+
end
|
444
|
+
|
445
|
+
it "rejects any config changes" do
|
446
|
+
subject.error Struct.new(:error)
|
447
|
+
expect { subject.finalize! }.to_not raise_error
|
448
|
+
|
449
|
+
# no more after finalize!
|
450
|
+
subject.finalize!
|
451
|
+
|
452
|
+
expect {
|
453
|
+
subject.error Struct.new(:other_error)
|
454
|
+
}.to raise_error(Teckel::FrozenConfigError, "Configuration error is already set")
|
455
|
+
end
|
456
|
+
|
457
|
+
it "runs" do
|
458
|
+
subject.error Struct.new(:error)
|
459
|
+
subject.finalize!
|
460
|
+
|
461
|
+
result = subject.call("test")
|
462
|
+
expect(result.output_data).to eq("testtest")
|
463
|
+
end
|
464
|
+
|
465
|
+
it "accepts mocks" do
|
466
|
+
subject.error Struct.new(:error)
|
467
|
+
subject.finalize!
|
468
|
+
|
469
|
+
allow(subject).to receive(:call) { :mocked }
|
470
|
+
expect(subject.call).to eq(:mocked)
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
describe "overwriting configs is not allowed" do
|
475
|
+
it "raises" do
|
476
|
+
expect {
|
477
|
+
Class.new do
|
478
|
+
include ::Teckel::Operation
|
479
|
+
input none
|
480
|
+
input Struct.new(:name)
|
481
|
+
end
|
482
|
+
}.to raise_error Teckel::FrozenConfigError, "Configuration input is already set"
|
483
|
+
end
|
484
|
+
end
|
485
|
+
end
|