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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE_LOGO +4 -0
  4. data/README.md +4 -4
  5. data/lib/teckel.rb +9 -3
  6. data/lib/teckel/chain.rb +99 -271
  7. data/lib/teckel/chain/result.rb +38 -0
  8. data/lib/teckel/chain/runner.rb +51 -0
  9. data/lib/teckel/chain/step.rb +18 -0
  10. data/lib/teckel/config.rb +1 -23
  11. data/lib/teckel/contracts.rb +19 -0
  12. data/lib/teckel/operation.rb +309 -215
  13. data/lib/teckel/operation/result.rb +92 -0
  14. data/lib/teckel/operation/runner.rb +70 -0
  15. data/lib/teckel/result.rb +52 -53
  16. data/lib/teckel/version.rb +1 -1
  17. data/spec/chain/inheritance_spec.rb +116 -0
  18. data/spec/chain/results_spec.rb +53 -0
  19. data/spec/chain_around_hook_spec.rb +100 -0
  20. data/spec/chain_spec.rb +180 -0
  21. data/spec/config_spec.rb +26 -0
  22. data/spec/doctest_helper.rb +7 -0
  23. data/spec/operation/inheritance_spec.rb +94 -0
  24. data/spec/operation/result_spec.rb +34 -0
  25. data/spec/operation/results_spec.rb +117 -0
  26. data/spec/operation_spec.rb +485 -0
  27. data/spec/rb27/pattern_matching_spec.rb +193 -0
  28. data/spec/result_spec.rb +20 -0
  29. data/spec/spec_helper.rb +25 -0
  30. data/spec/support/dry_base.rb +8 -0
  31. data/spec/support/fake_db.rb +12 -0
  32. data/spec/support/fake_models.rb +20 -0
  33. data/spec/teckel_spec.rb +7 -0
  34. metadata +52 -25
  35. data/.codeclimate.yml +0 -3
  36. data/.github/workflows/ci.yml +0 -92
  37. data/.github/workflows/pages.yml +0 -50
  38. data/.gitignore +0 -15
  39. data/.rspec +0 -3
  40. data/.rubocop.yml +0 -12
  41. data/.ruby-version +0 -1
  42. data/DEVELOPMENT.md +0 -32
  43. data/Gemfile +0 -16
  44. data/Rakefile +0 -35
  45. data/bin/console +0 -15
  46. data/bin/rake +0 -29
  47. data/bin/rspec +0 -29
  48. data/bin/rubocop +0 -18
  49. data/bin/setup +0 -8
  50. data/lib/teckel/none.rb +0 -18
  51. data/lib/teckel/operation/results.rb +0 -72
  52. 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