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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+ require 'support/fake_db'
5
+ require 'support/fake_models'
6
+
7
+ RSpec.describe Teckel::Chain do
8
+ module TeckelChainAroundHookTest
9
+ class CreateUser
10
+ include ::Teckel::Operation
11
+
12
+ result!
13
+
14
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
15
+ output Types.Instance(User)
16
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
17
+
18
+ def call(input)
19
+ user = User.new(name: input[:name], age: input[:age])
20
+ if user.save
21
+ success!(user)
22
+ else
23
+ fail!(message: "Could not safe User", errors: user.errors)
24
+ end
25
+ end
26
+ end
27
+
28
+ class AddFriend
29
+ include ::Teckel::Operation
30
+
31
+ result!
32
+
33
+ settings Struct.new(:fail_befriend)
34
+
35
+ input Types.Instance(User)
36
+ output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
37
+ error Types::Hash.schema(message: Types::String)
38
+
39
+ def call(user)
40
+ if settings&.fail_befriend
41
+ fail!(message: "Did not find a friend.")
42
+ else
43
+ { user: user, friend: User.new(name: "A friend", age: 42) }
44
+ end
45
+ end
46
+ end
47
+
48
+ @stack = []
49
+ def self.stack
50
+ @stack
51
+ end
52
+
53
+ class Chain
54
+ include Teckel::Chain
55
+
56
+ around ->(chain, input) {
57
+ result = nil
58
+ begin
59
+ TeckelChainAroundHookTest.stack << :before
60
+
61
+ FakeDB.transaction do
62
+ result = chain.call(input)
63
+ raise FakeDB::Rollback if result.failure?
64
+ end
65
+
66
+ TeckelChainAroundHookTest.stack << :after
67
+ result
68
+ rescue FakeDB::Rollback
69
+ result
70
+ end
71
+ }
72
+
73
+ step :create, CreateUser
74
+ step :befriend, AddFriend
75
+ end
76
+ end
77
+
78
+ before { TeckelChainAroundHookTest.stack.clear }
79
+
80
+ context "success" do
81
+ it "result matches" do
82
+ result = TeckelChainAroundHookTest::Chain.call(name: "Bob", age: 23)
83
+ expect(result.success).to include(user: kind_of(User), friend: kind_of(User))
84
+ end
85
+
86
+ it "runs around hook" do
87
+ TeckelChainAroundHookTest::Chain.call(name: "Bob", age: 23)
88
+ expect(TeckelChainAroundHookTest.stack).to eq([:before, :after])
89
+ end
90
+ end
91
+
92
+ context "failure" do
93
+ it "runs around hook" do
94
+ TeckelChainAroundHookTest::Chain.
95
+ with(befriend: :fail).
96
+ call(name: "Bob", age: 23)
97
+ expect(TeckelChainAroundHookTest.stack).to eq([:before])
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+ require 'support/fake_models'
5
+
6
+ RSpec.describe Teckel::Chain do
7
+ module TeckelChainTest
8
+ class CreateUser
9
+ include ::Teckel::Operation
10
+ result!
11
+
12
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
13
+ output Types.Instance(User)
14
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
15
+
16
+ def call(input)
17
+ user = User.new(name: input[:name], age: input[:age])
18
+ if user.save
19
+ success!(user)
20
+ else
21
+ fail!(message: "Could not save User", errors: user.errors)
22
+ end
23
+ end
24
+ end
25
+
26
+ class LogUser
27
+ include ::Teckel::Operation
28
+
29
+ result!
30
+
31
+ input Types.Instance(User)
32
+ error none
33
+ output input
34
+
35
+ def call(usr)
36
+ Logger.new(File::NULL).info("User #{usr.name} created")
37
+ usr
38
+ end
39
+ end
40
+
41
+ class AddFriend
42
+ include ::Teckel::Operation
43
+
44
+ result!
45
+
46
+ settings Struct.new(:fail_befriend)
47
+
48
+ input Types.Instance(User)
49
+ output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
50
+ error Types::Hash.schema(message: Types::String)
51
+
52
+ def call(user)
53
+ if settings&.fail_befriend
54
+ fail!(message: "Did not find a friend.")
55
+ else
56
+ { user: user, friend: User.new(name: "A friend", age: 42) }
57
+ end
58
+ end
59
+ end
60
+
61
+ class Chain
62
+ include Teckel::Chain
63
+
64
+ step :create, CreateUser
65
+ step :log, LogUser
66
+ step :befriend, AddFriend
67
+ end
68
+ end
69
+
70
+ it 'Chain input points to first step input' do
71
+ expect(TeckelChainTest::Chain.input).to eq(TeckelChainTest::CreateUser.input)
72
+ end
73
+
74
+ it 'Chain output points to last steps output' do
75
+ expect(TeckelChainTest::Chain.output).to eq(TeckelChainTest::AddFriend.output)
76
+ end
77
+
78
+ it 'Chain errors maps all step errors' do
79
+ expect(TeckelChainTest::Chain.errors).to eq([
80
+ TeckelChainTest::CreateUser.error,
81
+ Teckel::Contracts::None,
82
+ TeckelChainTest::AddFriend.error
83
+ ])
84
+ end
85
+
86
+ context "success" do
87
+ it "result matches" do
88
+ result =
89
+ TeckelChainTest::Chain.
90
+ with(befriend: nil).
91
+ call(name: "Bob", age: 23)
92
+
93
+ expect(result.success).to include(user: kind_of(User), friend: kind_of(User))
94
+ end
95
+ end
96
+
97
+ context "failure" do
98
+ it "returns a Result for invalid input" do
99
+ result =
100
+ TeckelChainTest::Chain.
101
+ with(befriend: :fail).
102
+ call(name: "Bob", age: 0)
103
+
104
+ expect(result).to be_a(Teckel::Chain::Result)
105
+ expect(result).to be_failure
106
+ expect(result.step).to eq(:create)
107
+ expect(result.value).to eq(errors: [{ age: "underage" }], message: "Could not save User")
108
+ end
109
+
110
+ it "returns a Result for failed step" do
111
+ result =
112
+ TeckelChainTest::Chain.
113
+ with(befriend: :fail).
114
+ call(name: "Bob", age: 23)
115
+
116
+ expect(result).to be_a(Teckel::Chain::Result)
117
+ expect(result).to be_failure
118
+ expect(result.step).to eq(:befriend)
119
+ expect(result.value).to eq(message: "Did not find a friend.")
120
+ end
121
+ end
122
+
123
+ describe "#finalize!" do
124
+ let(:frozen_error) do
125
+ # different ruby versions raise different errors
126
+ defined?(FrozenError) ? FrozenError : RuntimeError
127
+ end
128
+
129
+ subject { TeckelChainTest::Chain.dup }
130
+
131
+ it "freezes the Chain class and operation classes" do
132
+ subject.finalize!
133
+
134
+ steps = subject.steps
135
+ expect(steps).to be_frozen
136
+ expect(steps).to all be_frozen
137
+ end
138
+
139
+ it "disallows adding new steps" do
140
+ subject.class_eval do
141
+ step :other, TeckelChainTest::AddFriend
142
+ end
143
+
144
+ subject.finalize!
145
+
146
+ expect {
147
+ subject.class_eval do
148
+ step :yet_other, TeckelChainTest::AddFriend
149
+ end
150
+ }.to raise_error(frozen_error)
151
+ end
152
+
153
+ it "disallows changing around hook" do
154
+ subject.class_eval do
155
+ around ->{}
156
+ end
157
+
158
+ chain2 = TeckelChainTest::Chain.dup.finalize!
159
+ expect {
160
+ chain2.class_eval do
161
+ around ->{}
162
+ end
163
+ }.to raise_error(frozen_error)
164
+ end
165
+
166
+ it "runs" do
167
+ subject.finalize!
168
+
169
+ result = subject.call(name: "Bob", age: 23)
170
+ expect(result.success).to include(user: kind_of(User), friend: kind_of(User))
171
+ end
172
+
173
+ it "accepts mocks" do
174
+ subject.finalize!
175
+
176
+ allow(subject).to receive(:call) { :mocked }
177
+ expect(subject.call).to eq(:mocked)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+ require 'support/fake_models'
5
+
6
+ RSpec.describe Teckel::Config do
7
+ let(:sample_config) do
8
+ Teckel::Config.new
9
+ end
10
+
11
+ it "set and retrieve key" do
12
+ sample_config.for(:some_key, "some_value")
13
+ expect(sample_config.for(:some_key)).to eq("some_value")
14
+ end
15
+
16
+ it "allow default value via block" do
17
+ expect(sample_config.for(:some_key) { "default" }).to eq("default")
18
+ # and sets the block value
19
+ expect(sample_config.for(:some_key)).to eq("default")
20
+ end
21
+
22
+ it "raises FrozenConfigError when setting a key twice" do
23
+ sample_config.for(:some_key, "some_value")
24
+ expect { sample_config.for(:some_key, "other_value") }.to raise_error(Teckel::FrozenConfigError)
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'support/dry_base'
4
+ require_relative 'support/fake_db'
5
+ require_relative 'support/fake_models'
6
+
7
+ require "teckel"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Teckel::Operation do
4
+ context "default settings via base class" do
5
+ module TeckelOperationDefaultsViaBaseClass
6
+ DefaultError = Struct.new(:message, :status_code)
7
+ Settings = Struct.new(:fail_it)
8
+
9
+ class ApplicationOperation
10
+ include Teckel::Operation
11
+
12
+ settings Settings
13
+ settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
14
+
15
+ error DefaultError
16
+ error_constructor ->(data) { error.new(*data.values_at(*error.members)) }
17
+
18
+ result!
19
+
20
+ # Freeze the base class to make sure it's inheritable configuration is not altered
21
+ freeze
22
+ end
23
+
24
+ class OperationA < ApplicationOperation
25
+ input Struct.new(:input_data_a)
26
+ output Struct.new(:output_data_a)
27
+
28
+ def call(input)
29
+ if settings&.fail_it
30
+ fail!(message: settings.fail_it, status_code: 400)
31
+ else
32
+ input.input_data_a * 2
33
+ end
34
+ end
35
+
36
+ finalize!
37
+ end
38
+
39
+ class OperationB < ApplicationOperation
40
+ input Struct.new(:input_data_b)
41
+ output Struct.new(:output_data_b)
42
+
43
+ def call(input)
44
+ if settings&.fail_it
45
+ fail!(message: settings.fail_it, status_code: 500)
46
+ else
47
+ input.input_data_b * 4
48
+ end
49
+ end
50
+
51
+ finalize!
52
+ end
53
+ end
54
+
55
+ let(:operation_a) { TeckelOperationDefaultsViaBaseClass::OperationA }
56
+ let(:operation_b) { TeckelOperationDefaultsViaBaseClass::OperationB }
57
+
58
+ it "inherits config" do
59
+ expect(operation_a.result).to eq(Teckel::Operation::Result)
60
+ expect(operation_a.settings).to eq(TeckelOperationDefaultsViaBaseClass::Settings)
61
+
62
+ expect(operation_b.result).to eq(Teckel::Operation::Result)
63
+ expect(operation_b.settings).to eq(TeckelOperationDefaultsViaBaseClass::Settings)
64
+ end
65
+
66
+ context "operation_a" do
67
+ it "can run" do
68
+ result = operation_a.call(10)
69
+ expect(result.success.to_h).to eq(output_data_a: 20)
70
+ end
71
+
72
+ it "can fail" do
73
+ result = operation_a.with(fail_it: "D'oh!").call(10)
74
+ expect(result.failure.to_h).to eq(
75
+ message: "D'oh!", status_code: 400
76
+ )
77
+ end
78
+ end
79
+
80
+ context "operation_b" do
81
+ it "can run" do
82
+ result = operation_b.call(10)
83
+ expect(result.success.to_h).to eq(output_data_b: 40)
84
+ end
85
+
86
+ it "can fail" do
87
+ result = operation_b.with(fail_it: "D'oh!").call(10)
88
+ expect(result.failure.to_h).to eq(
89
+ message: "D'oh!", status_code: 500
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Teckel::Operation::Result do
4
+ let(:failure_value) { "some error" }
5
+ let(:failed_result) { Teckel::Operation::Result.new(failure_value, false) }
6
+
7
+ let(:success_value) { "some error" }
8
+ let(:successful_result) { Teckel::Operation::Result.new(failure_value, true) }
9
+
10
+ it { expect(successful_result.successful?).to be(true) }
11
+ it { expect(failed_result.successful?).to be(false) }
12
+
13
+ it { expect(successful_result.failure?).to be(false) }
14
+ it { expect(failed_result.failure?).to be(true) }
15
+
16
+ it { expect(successful_result.value).to eq(success_value) }
17
+ it { expect(failed_result.value).to eq(failure_value) }
18
+
19
+ describe "#success" do
20
+ it { expect(successful_result.success).to eq(success_value) }
21
+
22
+ it { expect(failed_result.success).to eq(nil) }
23
+ it { expect(failed_result.success("other")).to eq("other") }
24
+ it { expect(failed_result.success { |value| "Failed: #{value}" } ).to eq("Failed: some error") }
25
+ end
26
+
27
+ describe "#failure" do
28
+ it { expect(failed_result.failure).to eq(failure_value) }
29
+
30
+ it { expect(successful_result.failure).to eq(nil) }
31
+ it { expect(successful_result.failure("other")).to eq("other") }
32
+ it { expect(successful_result.failure { |value| "Failed: #{value}" } ).to eq("Failed: some error") }
33
+ end
34
+ end
@@ -0,0 +1,117 @@
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 "with build in result object" do
8
+ class CreateUserWithResult
9
+ include Teckel::Operation
10
+
11
+ result!
12
+
13
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
14
+ output Types.Instance(User)
15
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
16
+
17
+ def call(input)
18
+ user = User.new(name: input[:name], age: input[:age])
19
+ if user.save
20
+ user
21
+ else
22
+ fail!(message: "Could not save User", errors: user.errors)
23
+ end
24
+ end
25
+ end
26
+
27
+ specify "output" do
28
+ result = CreateUserWithResult.call(name: "Bob", age: 23)
29
+ expect(result).to be_a(Teckel::Result)
30
+ expect(result).to be_successful
31
+ expect(result.success).to be_a(User)
32
+ end
33
+
34
+ specify "errors" do
35
+ result = CreateUserWithResult.call(name: "Bob", age: 10)
36
+ expect(result).to be_a(Teckel::Result)
37
+ expect(result).to be_failure
38
+ expect(result.failure).to eq(message: "Could not save User", errors: [{ age: "underage" }])
39
+ end
40
+ end
41
+
42
+ context "using custom result" do
43
+ class CreateUserCustomResult
44
+ include Teckel::Operation
45
+
46
+ class MyResult
47
+ include Teckel::Result # makes sure this can be used in a Chain
48
+
49
+ def initialize(value, success, opts = {})
50
+ @value, @success, @opts = value, success, opts
51
+ end
52
+
53
+ # implementing Teckel::Result
54
+ def successful?
55
+ @success
56
+ end
57
+
58
+ # implementing Teckel::Result
59
+ attr_reader :value
60
+
61
+ attr_reader :opts
62
+ end
63
+
64
+ result MyResult
65
+ result_constructor ->(value, success) { result.new(value, success, time: Time.now.to_i) }
66
+
67
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
68
+ output Types.Instance(User)
69
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
70
+
71
+ def call(input)
72
+ user = User.new(name: input[:name], age: input[:age])
73
+ if user.save
74
+ user
75
+ else
76
+ fail!(message: "Could not save User", errors: user.errors)
77
+ end
78
+ end
79
+ end
80
+
81
+ specify "output" do
82
+ result = CreateUserCustomResult.call(name: "Bob", age: 23)
83
+ expect(result).to be_a(CreateUserCustomResult::MyResult)
84
+ expect(result).to be_successful
85
+ expect(result.value).to be_a(User)
86
+
87
+ expect(result.opts).to include(time: kind_of(Integer))
88
+ end
89
+
90
+ specify "errors" do
91
+ result = CreateUserCustomResult.call(name: "Bob", age: 10)
92
+ expect(result).to be_a(CreateUserCustomResult::MyResult)
93
+ expect(result).to be_failure
94
+ expect(result.value).to eq(message: "Could not save User", errors: [{ age: "underage" }])
95
+
96
+ expect(result.opts).to include(time: kind_of(Integer))
97
+ end
98
+ end
99
+
100
+ context "overwriting Result" do
101
+ class CreateUserOverwritingResult
102
+ include Teckel::Operation
103
+
104
+ class Result
105
+ include Teckel::Result # makes sure this can be used in a Chain
106
+
107
+ def initialize(value, success); end
108
+ end
109
+ end
110
+
111
+ it "uses the class definition" do
112
+ expect(CreateUserOverwritingResult.result).to_not eq(Teckel::Operation::Result)
113
+ expect(CreateUserOverwritingResult.result).to eq(CreateUserOverwritingResult::Result)
114
+ expect(CreateUserOverwritingResult.result_constructor).to eq(CreateUserOverwritingResult::Result.method(:[]))
115
+ end
116
+ end
117
+ end