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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -0
- data/LICENSE_LOGO +4 -0
- data/README.md +22 -14
- data/lib/teckel.rb +11 -2
- data/lib/teckel/chain.rb +47 -152
- data/lib/teckel/chain/config.rb +246 -0
- data/lib/teckel/chain/result.rb +38 -0
- data/lib/teckel/chain/runner.rb +62 -0
- data/lib/teckel/chain/step.rb +18 -0
- data/lib/teckel/config.rb +41 -52
- data/lib/teckel/contracts.rb +19 -0
- data/lib/teckel/operation.rb +108 -253
- data/lib/teckel/operation/config.rb +396 -0
- data/lib/teckel/operation/result.rb +92 -0
- data/lib/teckel/operation/runner.rb +75 -0
- data/lib/teckel/result.rb +52 -53
- data/lib/teckel/version.rb +1 -1
- data/spec/chain/default_settings_spec.rb +39 -0
- data/spec/chain/inheritance_spec.rb +116 -0
- data/spec/chain/none_input_spec.rb +36 -0
- data/spec/chain/results_spec.rb +53 -0
- data/spec/chain_around_hook_spec.rb +100 -0
- data/spec/chain_spec.rb +180 -0
- data/spec/config_spec.rb +26 -0
- data/spec/doctest_helper.rb +7 -0
- data/spec/operation/contract_trace_spec.rb +116 -0
- data/spec/operation/default_settings_spec.rb +94 -0
- data/spec/operation/inheritance_spec.rb +94 -0
- data/spec/operation/result_spec.rb +34 -0
- data/spec/operation/results_spec.rb +117 -0
- data/spec/operation_spec.rb +483 -0
- data/spec/rb27/pattern_matching_spec.rb +193 -0
- data/spec/result_spec.rb +22 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/support/dry_base.rb +8 -0
- data/spec/support/fake_db.rb +12 -0
- data/spec/support/fake_models.rb +20 -0
- data/spec/teckel_spec.rb +7 -0
- metadata +64 -46
- data/.github/workflows/ci.yml +0 -67
- data/.github/workflows/pages.yml +0 -50
- data/.gitignore +0 -13
- data/.rspec +0 -3
- data/.rubocop.yml +0 -12
- data/.ruby-version +0 -1
- data/DEVELOPMENT.md +0 -28
- data/Gemfile +0 -8
- data/Gemfile.lock +0 -71
- data/Rakefile +0 -30
- data/bin/console +0 -15
- data/bin/rake +0 -29
- data/bin/rspec +0 -29
- data/bin/rubocop +0 -18
- data/bin/setup +0 -8
- data/lib/teckel/operation/results.rb +0 -71
- 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
|
data/spec/chain_spec.rb
ADDED
@@ -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
|
data/spec/config_spec.rb
ADDED
@@ -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,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
|