teckel 0.1.0 → 0.6.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 (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