teckel 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -0
  3. data/LICENSE_LOGO +4 -0
  4. data/README.md +22 -14
  5. data/lib/teckel.rb +11 -2
  6. data/lib/teckel/chain.rb +47 -152
  7. data/lib/teckel/chain/config.rb +246 -0
  8. data/lib/teckel/chain/result.rb +38 -0
  9. data/lib/teckel/chain/runner.rb +62 -0
  10. data/lib/teckel/chain/step.rb +18 -0
  11. data/lib/teckel/config.rb +41 -52
  12. data/lib/teckel/contracts.rb +19 -0
  13. data/lib/teckel/operation.rb +108 -253
  14. data/lib/teckel/operation/config.rb +396 -0
  15. data/lib/teckel/operation/result.rb +92 -0
  16. data/lib/teckel/operation/runner.rb +75 -0
  17. data/lib/teckel/result.rb +52 -53
  18. data/lib/teckel/version.rb +1 -1
  19. data/spec/chain/default_settings_spec.rb +39 -0
  20. data/spec/chain/inheritance_spec.rb +116 -0
  21. data/spec/chain/none_input_spec.rb +36 -0
  22. data/spec/chain/results_spec.rb +53 -0
  23. data/spec/chain_around_hook_spec.rb +100 -0
  24. data/spec/chain_spec.rb +180 -0
  25. data/spec/config_spec.rb +26 -0
  26. data/spec/doctest_helper.rb +7 -0
  27. data/spec/operation/contract_trace_spec.rb +116 -0
  28. data/spec/operation/default_settings_spec.rb +94 -0
  29. data/spec/operation/inheritance_spec.rb +94 -0
  30. data/spec/operation/result_spec.rb +34 -0
  31. data/spec/operation/results_spec.rb +117 -0
  32. data/spec/operation_spec.rb +483 -0
  33. data/spec/rb27/pattern_matching_spec.rb +193 -0
  34. data/spec/result_spec.rb +22 -0
  35. data/spec/spec_helper.rb +25 -0
  36. data/spec/support/dry_base.rb +8 -0
  37. data/spec/support/fake_db.rb +12 -0
  38. data/spec/support/fake_models.rb +20 -0
  39. data/spec/teckel_spec.rb +7 -0
  40. metadata +64 -46
  41. data/.github/workflows/ci.yml +0 -67
  42. data/.github/workflows/pages.yml +0 -50
  43. data/.gitignore +0 -13
  44. data/.rspec +0 -3
  45. data/.rubocop.yml +0 -12
  46. data/.ruby-version +0 -1
  47. data/DEVELOPMENT.md +0 -28
  48. data/Gemfile +0 -8
  49. data/Gemfile.lock +0 -71
  50. data/Rakefile +0 -30
  51. data/bin/console +0 -15
  52. data/bin/rake +0 -29
  53. data/bin/rspec +0 -29
  54. data/bin/rubocop +0 -18
  55. data/bin/setup +0 -8
  56. data/lib/teckel/operation/results.rb +0 -71
  57. data/teckel.gemspec +0 -33
@@ -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
+ module TeckelChainAroundHookTest
8
+ class CreateUser
9
+ include ::Teckel::Operation
10
+
11
+ result!
12
+
13
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
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
+ success!(user)
21
+ else
22
+ fail!(message: "Could not safe User", errors: user.errors)
23
+ end
24
+ end
25
+ end
26
+
27
+ class AddFriend
28
+ include ::Teckel::Operation
29
+
30
+ result!
31
+
32
+ settings Struct.new(:fail_befriend)
33
+
34
+ input Types.Instance(User)
35
+ output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
36
+ error Types::Hash.schema(message: Types::String)
37
+
38
+ def call(user)
39
+ if settings&.fail_befriend
40
+ fail!(message: "Did not find a friend.")
41
+ else
42
+ success! user: user, friend: User.new(name: "A friend", age: 42)
43
+ end
44
+ end
45
+ end
46
+
47
+ @stack = []
48
+ def self.stack
49
+ @stack
50
+ end
51
+
52
+ class Chain
53
+ include Teckel::Chain
54
+
55
+ around ->(chain, input) {
56
+ result = nil
57
+ begin
58
+ TeckelChainAroundHookTest.stack << :before
59
+
60
+ FakeDB.transaction do
61
+ result = chain.call(input)
62
+ raise FakeDB::Rollback if result.failure?
63
+ end
64
+
65
+ TeckelChainAroundHookTest.stack << :after
66
+ result
67
+ rescue FakeDB::Rollback
68
+ result
69
+ end
70
+ }
71
+
72
+ step :create, CreateUser
73
+ step :befriend, AddFriend
74
+ end
75
+ end
76
+
77
+ RSpec.describe Teckel::Chain do
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
+ module TeckelChainTest
7
+ class CreateUser
8
+ include ::Teckel::Operation
9
+ result!
10
+
11
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
12
+ output Types.Instance(User)
13
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
14
+
15
+ def call(input)
16
+ user = User.new(name: input[:name], age: input[:age])
17
+ if user.save
18
+ success!(user)
19
+ else
20
+ fail!(message: "Could not save User", errors: user.errors)
21
+ end
22
+ end
23
+ end
24
+
25
+ class LogUser
26
+ include ::Teckel::Operation
27
+
28
+ result!
29
+
30
+ input Types.Instance(User)
31
+ error none
32
+ output input
33
+
34
+ def call(usr)
35
+ Logger.new(File::NULL).info("User #{usr.name} created")
36
+ success! usr
37
+ end
38
+ end
39
+
40
+ class AddFriend
41
+ include ::Teckel::Operation
42
+
43
+ result!
44
+
45
+ settings Struct.new(:fail_befriend)
46
+
47
+ input Types.Instance(User)
48
+ output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
49
+ error Types::Hash.schema(message: Types::String)
50
+
51
+ def call(user)
52
+ if settings&.fail_befriend
53
+ fail!(message: "Did not find a friend.")
54
+ else
55
+ success! user: user, friend: User.new(name: "A friend", age: 42)
56
+ end
57
+ end
58
+ end
59
+
60
+ class Chain
61
+ include Teckel::Chain
62
+
63
+ step :create, CreateUser
64
+ step :log, LogUser
65
+ step :befriend, AddFriend
66
+ end
67
+ end
68
+
69
+ RSpec.describe Teckel::Chain do
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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+
5
+ module TeckelOperationContractTrace
6
+ DefaultError = Struct.new(:message, :status_code)
7
+ Settings = Struct.new(:fail_it)
8
+
9
+ class ApplicationOperation
10
+ include Teckel::Operation
11
+
12
+ class Input < Dry::Struct
13
+ attribute :input_data, Types::String
14
+ end
15
+
16
+ class Output < Dry::Struct
17
+ attribute :output_data, Types::String
18
+ end
19
+
20
+ class Error < Dry::Struct
21
+ attribute :error_data, Types::String
22
+ end
23
+
24
+ # Freeze the base class to make sure it's inheritable configuration is not altered
25
+ freeze
26
+ end
27
+ end
28
+
29
+ # Hack to get reliable stack traces
30
+ eval <<~RUBY, binding, "operation_success_error.rb"
31
+ module TeckelOperationContractTrace
32
+ class OperationSuccessError < ApplicationOperation
33
+ # Includes a deliberate bug while crating a success output
34
+ def call(input)
35
+ success!(incorrect_key: 1)
36
+ end
37
+ end
38
+ end
39
+ RUBY
40
+
41
+ eval <<~RUBY, binding, "operation_simple_success_error.rb"
42
+ module TeckelOperationContractTrace
43
+ class OperationSimpleSuccessNil < ApplicationOperation
44
+ # Includes a deliberate bug while crating a success output
45
+ def call(input)
46
+ return { incorrect_key: 1 }
47
+ end
48
+ end
49
+ end
50
+ RUBY
51
+
52
+ eval <<~RUBY, binding, "operation_failure_error.rb"
53
+ module TeckelOperationContractTrace
54
+ class OperationFailureError < ApplicationOperation
55
+ # Includes a deliberate bug while crating an error output
56
+ def call(input)
57
+ fail!(incorrect_key: 1)
58
+ end
59
+ end
60
+ end
61
+ RUBY
62
+
63
+ eval <<~RUBY, binding, "operation_ok.rb"
64
+ module TeckelOperationContractTrace
65
+ class OperationOk < ApplicationOperation
66
+ def call(input)
67
+ success!(output_data: "all fine")
68
+ end
69
+ end
70
+ end
71
+ RUBY
72
+
73
+ eval <<~RUBY, binding, "operation_input_error.rb"
74
+ module TeckelOperationContractTrace
75
+ def self.run_operation(operation)
76
+ operation.call(error_input_data: "failure")
77
+ end
78
+ end
79
+ RUBY
80
+
81
+ RSpec.describe Teckel::Operation do
82
+ context "contract errors include meaningful trace" do
83
+ specify "incorrect success" do
84
+ expect {
85
+ TeckelOperationContractTrace::OperationSuccessError.call(input_data: "ok")
86
+ }.to raise_error(Dry::Struct::Error) { |error|
87
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_success_error.rb:5:in `call'")}$/
88
+ }
89
+ end
90
+
91
+ specify "incorrect success via simple return results in +nil+, but no meaningful trace" do
92
+ expect(
93
+ TeckelOperationContractTrace::OperationSimpleSuccessNil.call(input_data: "ok")
94
+ ).to be_nil
95
+ end
96
+
97
+ specify "incorrect fail" do
98
+ expect {
99
+ TeckelOperationContractTrace::OperationFailureError.call(input_data: "ok")
100
+ }.to raise_error(Dry::Struct::Error) { |error|
101
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_failure_error.rb:5:in `call'")}$/
102
+ }
103
+ end
104
+
105
+ specify "incorrect input" do
106
+ operation = TeckelOperationContractTrace::OperationOk
107
+
108
+ expect(operation.call(input_data: "ok")).to eq(operation.output[output_data: "all fine"])
109
+ expect {
110
+ TeckelOperationContractTrace.run_operation(operation)
111
+ }.to raise_error(Dry::Struct::Error) { |error|
112
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_input_error.rb:3:in `run_operation'")}$/
113
+ }
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TeckelOperationDefaultSettings
4
+ class BaseOperation
5
+ include ::Teckel::Operation
6
+
7
+ input none
8
+ output Symbol
9
+ error none
10
+
11
+ def call(_input)
12
+ success! settings.injected
13
+ end
14
+ end
15
+ end
16
+
17
+ RSpec.describe Teckel::Operation do
18
+ context "default settings" do
19
+ shared_examples "operation with default settings" do |operation|
20
+ subject { operation }
21
+
22
+ it "with no settings" do
23
+ expect(subject.call).to eq(:default_value)
24
+ end
25
+
26
+ it "with settings" do
27
+ expect(subject.with(:injected_value).call).to eq(:injected_value)
28
+ end
29
+ end
30
+
31
+ describe "with default constructor and clever Settings class" do
32
+ it_behaves_like(
33
+ "operation with default settings",
34
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
35
+ settings(Class.new do
36
+ def initialize(injected = nil)
37
+ @injected = injected
38
+ end
39
+
40
+ def injected
41
+ @injected || :default_value
42
+ end
43
+
44
+ class << self
45
+ alias :[] :new # make us respond to the default constructor
46
+ end
47
+ end)
48
+
49
+ default_settings!
50
+ end
51
+ )
52
+ end
53
+
54
+ describe "with custom constructor and clever Settings class" do
55
+ it_behaves_like(
56
+ "operation with default settings",
57
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
58
+ settings(Class.new do
59
+ def initialize(injected = nil)
60
+ @injected = injected
61
+ end
62
+
63
+ def injected
64
+ @injected || :default_value
65
+ end
66
+ end)
67
+
68
+ settings_constructor :new
69
+ default_settings!
70
+ end
71
+ )
72
+ end
73
+
74
+ describe "with default constructor and simple Settings class" do
75
+ it_behaves_like(
76
+ "operation with default settings",
77
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
78
+ settings Struct.new(:injected)
79
+
80
+ default_settings! -> { settings.new(:default_value) }
81
+ end
82
+ )
83
+
84
+ it_behaves_like(
85
+ "operation with default settings",
86
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
87
+ settings Struct.new(:injected)
88
+
89
+ default_settings!(:default_value)
90
+ end
91
+ )
92
+ end
93
+ end
94
+ end