teckel 0.4.0 → 0.8.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.
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ RSpec.describe Teckel::Operation do
6
+ let(:operation) do
7
+ Class.new do
8
+ include Teckel::Operation
9
+ input none
10
+ output ->(o) { o }
11
+ error none
12
+
13
+ def call(_)
14
+ success! settings
15
+ end
16
+ end
17
+ end
18
+
19
+ let(:blank_operation) do
20
+ Class.new do
21
+ include Teckel::Operation
22
+ end
23
+ end
24
+
25
+ describe ".settings" do
26
+ specify "no settings" do
27
+ expect(operation.settings).to eq(Teckel::Contracts::None)
28
+ expect(operation.settings_constructor).to eq(Teckel::Contracts::None.method(:new))
29
+ end
30
+
31
+ specify "with settings klass" do
32
+ settings_klass = Struct.new(:name)
33
+ operation.settings(settings_klass)
34
+ expect(operation.settings).to eq(settings_klass)
35
+ end
36
+
37
+ specify "without settings class, with settings constructor as proc" do
38
+ settings_const = if RUBY_VERSION < '2.6.0'
39
+ ->(sets) { sets.map { |k, v| [k.to_s, v.to_i] }.to_h }
40
+ else
41
+ ->(sets) { sets.to_h { |k, v| [k.to_s, v.to_i] } }
42
+ end
43
+
44
+ operation.settings_constructor(settings_const)
45
+
46
+ expect(operation.settings).to eq(Teckel::Contracts::None)
47
+ expect(operation.settings_constructor).to eq(settings_const)
48
+
49
+ runner = operation.with(key: "1")
50
+ expect(runner).to be_a(Teckel::Operation::Runner)
51
+ expect(runner.settings).to eq({ "key" => 1 })
52
+ end
53
+
54
+ specify "with settings class, with settings constructor as symbol" do
55
+ settings_klass = Struct.new(:name) do
56
+ def self.make_one(opts)
57
+ new(opts[:name])
58
+ end
59
+ end
60
+
61
+ operation.settings(settings_klass)
62
+ operation.settings_constructor(:make_one)
63
+
64
+ expect(operation.settings).to eq(settings_klass)
65
+ expect(operation.settings_constructor).to eq(settings_klass.method(:make_one))
66
+
67
+ runner = operation.with(name: "value")
68
+ expect(runner).to be_a(Teckel::Operation::Runner)
69
+ expect(runner.settings).to be_a(settings_klass)
70
+ expect(runner.settings.name).to eq("value")
71
+ end
72
+
73
+ specify "with settings class as constant" do
74
+ settings_klass = Struct.new(:name)
75
+ operation.const_set(:Settings, settings_klass)
76
+
77
+ expect(operation.settings).to eq(settings_klass)
78
+ expect(operation.settings_constructor).to eq(settings_klass.method(:[]))
79
+ end
80
+ end
81
+
82
+ describe ".default_settings" do
83
+ specify "no default_settings" do
84
+ expect(operation.default_settings).to be_nil
85
+ expect(operation.runner).to receive(:new).with(operation).and_call_original
86
+
87
+ operation.call
88
+ end
89
+
90
+ specify "default_settings!() with no default_settings" do
91
+ operation.default_settings!
92
+ expect(operation.default_settings).to be_a(Proc)
93
+
94
+ expect(operation.default_settings).to receive(:call).with(no_args).and_wrap_original do |original_method, *args, &block|
95
+ settings = original_method.call(*args, &block)
96
+ expect(settings).to be_nil
97
+ expect(operation.runner).to receive(:new).with(operation, settings).and_call_original
98
+ settings
99
+ end
100
+
101
+ operation.call
102
+ end
103
+
104
+ specify "default_settings!() with default_settings" do
105
+ settings_klass = Struct.new(:name)
106
+
107
+ operation.settings(settings_klass)
108
+ operation.default_settings!
109
+
110
+ expect(operation.default_settings).to be_a(Proc)
111
+
112
+ expect(operation.default_settings).to receive(:call).with(no_args).and_wrap_original do |original_method, *args, &block|
113
+ settings = original_method.call(*args, &block)
114
+ expect(settings).to be_a(settings_klass).and have_attributes(name: nil)
115
+ expect(operation.runner).to receive(:new).with(operation, settings).and_call_original
116
+ settings
117
+ end
118
+
119
+ operation.call
120
+ end
121
+
122
+ specify "default_settings!(arg) with default_settings" do
123
+ settings_klass = Struct.new(:name)
124
+
125
+ operation.settings(settings_klass)
126
+ operation.default_settings!("Bob")
127
+
128
+ expect(operation.default_settings).to be_a(Proc)
129
+
130
+ expect(operation.default_settings).to receive(:call).with(no_args).and_wrap_original do |original_method, *args, &block|
131
+ settings = original_method.call(*args, &block)
132
+ expect(settings).to be_a(settings_klass).and have_attributes(name: "Bob")
133
+ expect(operation.runner).to receive(:new).with(operation, settings).and_call_original
134
+ settings
135
+ end
136
+
137
+ expect(operation.call).to be_a(Struct).and have_attributes(name: "Bob")
138
+ end
139
+ end
140
+
141
+ %i[input output error].each do |meth|
142
+ describe ".#{meth}" do
143
+ specify "missing .#{meth} config raises MissingConfigError" do
144
+ expect {
145
+ blank_operation.public_send(meth)
146
+ }.to raise_error(Teckel::MissingConfigError, "Missing #{meth} config for #{blank_operation}")
147
+ end
148
+ end
149
+
150
+ describe ".#{meth}_constructor" do
151
+ specify "missing .#{meth}_constructor config raises MissingConfigError for missing #{meth}" do
152
+ expect {
153
+ blank_operation.public_send(:"#{meth}_constructor")
154
+ }.to raise_error(Teckel::MissingConfigError, "Missing #{meth} config for #{blank_operation}")
155
+ end
156
+ end
157
+ end
158
+
159
+ specify "default settings config" do
160
+ expect(blank_operation.settings).to eq(Teckel::Contracts::None)
161
+ end
162
+
163
+ specify "default settings_constructor" do
164
+ expect(blank_operation.settings_constructor).to eq(Teckel::Contracts::None.method(:[]))
165
+ end
166
+
167
+ specify "default settings_constructor with settings config set" do
168
+ settings_klass = Struct.new(:name)
169
+ blank_operation.settings(settings_klass)
170
+
171
+ expect(blank_operation.settings_constructor).to eq(settings_klass.method(:[]))
172
+ end
173
+
174
+ specify "unsupported constructor method" do
175
+ blank_operation.settings(Class.new)
176
+ expect {
177
+ blank_operation.settings_constructor(:nope)
178
+ }.to raise_error(Teckel::MissingConfigError, "Missing settings_constructor config for #{blank_operation}")
179
+
180
+ expect {
181
+ blank_operation.settings_constructor
182
+ }.to raise_error(Teckel::MissingConfigError, "Missing settings_constructor config for #{blank_operation}")
183
+ end
184
+
185
+ describe "result" do
186
+ specify "default result config" do
187
+ expect(blank_operation.result).to eq(Teckel::Operation::ValueResult)
188
+ end
189
+
190
+ specify "default result_constructor" do
191
+ expect(blank_operation.result_constructor).to eq(Teckel::Operation::ValueResult.method(:[]))
192
+ end
193
+
194
+ specify "default result_constructor with settings config set" do
195
+ result_klass = OpenStruct.new
196
+ blank_operation.result(result_klass)
197
+
198
+ expect(blank_operation.result_constructor).to eq(result_klass.method(:[]))
199
+ end
200
+
201
+ specify "unsupported constructor method" do
202
+ blank_operation.result(Class.new)
203
+ expect {
204
+ blank_operation.result_constructor(:nope)
205
+ }.to raise_error(Teckel::MissingConfigError, "Missing result_constructor config for #{blank_operation}")
206
+
207
+ expect {
208
+ blank_operation.result_constructor
209
+ }.to raise_error(Teckel::MissingConfigError, "Missing result_constructor config for #{blank_operation}")
210
+ end
211
+
212
+ specify "with result class as constant" do
213
+ result_klass = OpenStruct.new
214
+ blank_operation.const_set(:Result, result_klass)
215
+
216
+ expect(blank_operation.result).to eq(result_klass)
217
+ expect(blank_operation.result_constructor).to eq(result_klass.method(:[]))
218
+ end
219
+ end
220
+
221
+ describe "result!" do
222
+ specify "default result config" do
223
+ blank_operation.result!
224
+ expect(blank_operation.result).to eq(Teckel::Operation::Result)
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,118 @@
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
+ # rubocop:disable Style/EvalWithLocation
30
+ # Hack to get reliable stack traces
31
+ eval <<~RUBY, binding, "operation_success_error.rb"
32
+ module TeckelOperationContractTrace
33
+ class OperationSuccessError < ApplicationOperation
34
+ # Includes a deliberate bug while crating a success output
35
+ def call(input)
36
+ success!(incorrect_key: 1)
37
+ end
38
+ end
39
+ end
40
+ RUBY
41
+
42
+ eval <<~RUBY, binding, "operation_simple_success_error.rb"
43
+ module TeckelOperationContractTrace
44
+ class OperationSimpleSuccessNil < ApplicationOperation
45
+ # Includes a deliberate bug while crating a success output
46
+ def call(input)
47
+ return { incorrect_key: 1 }
48
+ end
49
+ end
50
+ end
51
+ RUBY
52
+
53
+ eval <<~RUBY, binding, "operation_failure_error.rb"
54
+ module TeckelOperationContractTrace
55
+ class OperationFailureError < ApplicationOperation
56
+ # Includes a deliberate bug while crating an error output
57
+ def call(input)
58
+ fail!(incorrect_key: 1)
59
+ end
60
+ end
61
+ end
62
+ RUBY
63
+
64
+ eval <<~RUBY, binding, "operation_ok.rb"
65
+ module TeckelOperationContractTrace
66
+ class OperationOk < ApplicationOperation
67
+ def call(input)
68
+ success!(output_data: "all fine")
69
+ end
70
+ end
71
+ end
72
+ RUBY
73
+
74
+ eval <<~RUBY, binding, "operation_input_error.rb"
75
+ module TeckelOperationContractTrace
76
+ def self.run_operation(operation)
77
+ operation.call(error_input_data: "failure")
78
+ end
79
+ end
80
+ RUBY
81
+ # rubocop:enable Style/EvalWithLocation
82
+
83
+ RSpec.describe Teckel::Operation do
84
+ context "contract errors include meaningful trace" do
85
+ specify "incorrect success" do
86
+ expect {
87
+ TeckelOperationContractTrace::OperationSuccessError.call(input_data: "ok")
88
+ }.to raise_error(Dry::Struct::Error) { |error|
89
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_success_error.rb:5:in `call'")}$/
90
+ }
91
+ end
92
+
93
+ specify "incorrect success via simple return results in +nil+, but no meaningful trace" do
94
+ expect(
95
+ TeckelOperationContractTrace::OperationSimpleSuccessNil.call(input_data: "ok")
96
+ ).to be_nil
97
+ end
98
+
99
+ specify "incorrect fail" do
100
+ expect {
101
+ TeckelOperationContractTrace::OperationFailureError.call(input_data: "ok")
102
+ }.to raise_error(Dry::Struct::Error) { |error|
103
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_failure_error.rb:5:in `call'")}$/
104
+ }
105
+ end
106
+
107
+ specify "incorrect input" do
108
+ operation = TeckelOperationContractTrace::OperationOk
109
+
110
+ expect(operation.call(input_data: "ok")).to eq(operation.output[output_data: "all fine"])
111
+ expect {
112
+ TeckelOperationContractTrace.run_operation(operation)
113
+ }.to raise_error(Dry::Struct::Error) { |error|
114
+ expect(error.backtrace).to include /^#{Regexp.escape("operation_input_error.rb:3:in `run_operation'")}$/
115
+ }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,120 @@
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
+
93
+ it_behaves_like(
94
+ "operation with default settings",
95
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
96
+ settings Struct.new(:injected)
97
+
98
+ default_settings!("default_value")
99
+
100
+ output_constructor ->(out) { out&.to_sym }
101
+ end
102
+ )
103
+ end
104
+
105
+ describe "with default constructor and simple Settings class responding to passed default setting" do
106
+ it_behaves_like(
107
+ "operation with default settings",
108
+ Class.new(TeckelOperationDefaultSettings::BaseOperation) do
109
+ settings(Struct.new(:injected) do
110
+ def self.default
111
+ new(:default_value)
112
+ end
113
+ end)
114
+
115
+ default_settings!(:default)
116
+ end
117
+ )
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require 'support/dry_base'
5
+ require 'support/fake_models'
6
+
7
+ module TeckelOperationFailOnOInput
8
+ class NewUserContract < Dry::Validation::Contract
9
+ schema do
10
+ required(:name).filled(:string)
11
+ required(:age).value(:integer)
12
+ end
13
+ end
14
+
15
+ class CreateUser
16
+ include Teckel::Operation
17
+
18
+ result!
19
+
20
+ input(NewUserContract.new)
21
+ input_constructor(->(input){
22
+ result = self.class.input.call(input)
23
+ if result.success?
24
+ result.to_h
25
+ else
26
+ fail!(message: "Input data validation failed", errors: [result.errors.to_h])
27
+ end
28
+ })
29
+
30
+ output Types.Instance(User)
31
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
32
+
33
+ def call(input)
34
+ user = User.new(name: input[:name], age: input[:age])
35
+ if user.save
36
+ success! user
37
+ else
38
+ fail!(message: "Could not save User", errors: user.errors)
39
+ end
40
+ end
41
+
42
+ finalize!
43
+ end
44
+
45
+ class CreateUserIncorrectFailure
46
+ include Teckel::Operation
47
+
48
+ result!
49
+
50
+ input(->(input) { input }) # NoOp
51
+ input_constructor(->(_input) {
52
+ fail!("Input data validation failed")
53
+ })
54
+
55
+ output none
56
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
57
+
58
+ def call(_); end
59
+ finalize!
60
+ end
61
+ end
62
+
63
+ RSpec.describe Teckel::Operation do
64
+ specify "successs" do
65
+ result = TeckelOperationFailOnOInput::CreateUser.call(name: "Bob", age: 23)
66
+ expect(result).to be_successful
67
+ expect(result.success).to be_a(User)
68
+ end
69
+
70
+ describe "failing in input_constructor" do
71
+ let(:failure_input) do
72
+ { name: "", age: "incorrect type" }
73
+ end
74
+
75
+ it "returns the failure thrown in input_constructor" do
76
+ result = TeckelOperationFailOnOInput::CreateUser.call(failure_input)
77
+ expect(result).to be_a(Teckel::Operation::Result)
78
+ expect(result).to be_failure
79
+ expect(result.failure).to eq(
80
+ message: "Input data validation failed",
81
+ errors: [
82
+ { name: ["must be filled"], age: ["must be an integer"] }
83
+ ]
84
+ )
85
+ end
86
+
87
+ it "does not run .call" do
88
+ expect(TeckelOperationFailOnOInput::CreateUser).to receive(:new).and_wrap_original do |m, *args|
89
+ op_instance = m.call(*args)
90
+ expect(op_instance).to_not receive(:call)
91
+ op_instance
92
+ end
93
+
94
+ TeckelOperationFailOnOInput::CreateUser.call(failure_input)
95
+ end
96
+ end
97
+
98
+ specify "thrown failure needs to conform to :error" do
99
+ expect {
100
+ TeckelOperationFailOnOInput::CreateUserIncorrectFailure.call(name: "Bob", age: 23)
101
+ }.to raise_error(Dry::Types::ConstraintError, /violates constraints/)
102
+ end
103
+ end
@@ -1,57 +1,57 @@
1
1
  # frozen_string_literal: true
2
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
3
+ module TeckelOperationDefaultsViaBaseClass
4
+ DefaultError = Struct.new(:message, :status_code)
5
+ Settings = Struct.new(:fail_it)
11
6
 
12
- settings Settings
13
- settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
7
+ class ApplicationOperation
8
+ include Teckel::Operation
14
9
 
15
- error DefaultError
16
- error_constructor ->(data) { error.new(*data.values_at(*error.members)) }
10
+ settings Settings
11
+ settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
17
12
 
18
- result!
13
+ error DefaultError
14
+ error_constructor ->(data) { error.new(*data.values_at(*error.members)) }
19
15
 
20
- # Freeze the base class to make sure it's inheritable configuration is not altered
21
- freeze
22
- end
16
+ result!
23
17
 
24
- class OperationA < ApplicationOperation
25
- input Struct.new(:input_data_a)
26
- output Struct.new(:output_data_a)
18
+ # Freeze the base class to make sure it's inheritable configuration is not altered
19
+ freeze
20
+ end
27
21
 
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
22
+ class OperationA < ApplicationOperation
23
+ input Struct.new(:input_data_a)
24
+ output Struct.new(:output_data_a)
35
25
 
36
- finalize!
26
+ def call(input)
27
+ if settings&.fail_it
28
+ fail!(message: settings.fail_it, status_code: 400)
29
+ else
30
+ success!(input.input_data_a * 2)
37
31
  end
32
+ end
38
33
 
39
- class OperationB < ApplicationOperation
40
- input Struct.new(:input_data_b)
41
- output Struct.new(:output_data_b)
34
+ finalize!
35
+ end
42
36
 
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
37
+ class OperationB < ApplicationOperation
38
+ input Struct.new(:input_data_b)
39
+ output Struct.new(:output_data_b)
50
40
 
51
- finalize!
41
+ def call(input)
42
+ if settings&.fail_it
43
+ fail!(message: settings.fail_it, status_code: 500)
44
+ else
45
+ success!(input.input_data_b * 4)
52
46
  end
53
47
  end
54
48
 
49
+ finalize!
50
+ end
51
+ end
52
+
53
+ RSpec.describe Teckel::Operation do
54
+ context "default settings via base class" do
55
55
  let(:operation_a) { TeckelOperationDefaultsViaBaseClass::OperationA }
56
56
  let(:operation_b) { TeckelOperationDefaultsViaBaseClass::OperationB }
57
57