teckel 0.3.0 → 0.4.0

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