teckel 0.1.0 → 0.2.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/.codeclimate.yml +3 -0
- data/.github/workflows/ci.yml +25 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +12 -0
- data/DEVELOPMENT.md +8 -4
- data/Gemfile +8 -0
- data/README.md +22 -14
- data/Rakefile +5 -0
- data/lib/teckel.rb +3 -0
- data/lib/teckel/chain.rb +230 -25
- data/lib/teckel/config.rb +40 -48
- data/lib/teckel/none.rb +18 -0
- data/lib/teckel/operation.rb +183 -110
- data/lib/teckel/operation/results.rb +12 -11
- data/lib/teckel/result.rb +2 -2
- data/lib/teckel/version.rb +1 -1
- data/teckel.gemspec +0 -1
- metadata +5 -24
- data/Gemfile.lock +0 -71
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a9fbb7828be6e84c5d609241e778e39e8509a4cede9f8960f097b31ef0d2473b
|
4
|
+
data.tar.gz: b646f5954b1fb331186f52cf2a6ccd8521004bb26e59a09ec6b6d4d8575affa2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 454b1869e2b2a8bf540f203fccf9962d628c236c2ced1411ded69ca2ccb1e0c361965c74ad73dea6c709b97b7757016c24487f983924111b03f4f0c1c2d0287e
|
7
|
+
data.tar.gz: fd5f0e9e4e147238423454ef7a801139fd6f0ef3b1a7c2198285b7c471f1a26fb64c879647a9c041edb8129e4279a00e52572038959e9b8bd473668772dd78e2
|
data/.codeclimate.yml
ADDED
data/.github/workflows/ci.yml
CHANGED
@@ -50,6 +50,9 @@ jobs:
|
|
50
50
|
fail-fast: false
|
51
51
|
matrix:
|
52
52
|
image: ["jruby:9.2.9", "ruby:2.7"]
|
53
|
+
include:
|
54
|
+
- image: "ruby:2.7"
|
55
|
+
coverage: "true"
|
53
56
|
container:
|
54
57
|
image: ${{matrix.image}}
|
55
58
|
|
@@ -59,9 +62,31 @@ jobs:
|
|
59
62
|
run: |
|
60
63
|
apt-get update
|
61
64
|
apt-get install -y --no-install-recommends git
|
65
|
+
- name: Download test reporter
|
66
|
+
if: "matrix.coverage == 'true'"
|
67
|
+
run: |
|
68
|
+
mkdir -p tmp/
|
69
|
+
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./tmp/cc-test-reporter
|
70
|
+
chmod +x ./tmp/cc-test-reporter
|
71
|
+
./tmp/cc-test-reporter before-build
|
62
72
|
- name: Bundle install
|
73
|
+
env:
|
74
|
+
COVERAGE: ${{matrix.coverage}}
|
63
75
|
run: |
|
64
76
|
gem install bundler
|
65
77
|
bundle install --jobs 4 --retry 3 --without tools docs benchmarks
|
66
78
|
- name: Run all tests
|
79
|
+
env:
|
80
|
+
COVERAGE: ${{matrix.coverage}}
|
67
81
|
run: bundle exec rake
|
82
|
+
- name: Send coverage results
|
83
|
+
if: "matrix.coverage == 'true'"
|
84
|
+
env:
|
85
|
+
CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}}
|
86
|
+
GIT_COMMIT_SHA: ${{github.sha}}
|
87
|
+
GIT_BRANCH: ${{github.ref}}
|
88
|
+
GIT_COMMITTED_AT: ${{github.event.head_commit.timestamp}}
|
89
|
+
run: |
|
90
|
+
GIT_BRANCH=`ruby -e "puts ENV['GITHUB_REF'].split('/', 3).last"` \
|
91
|
+
GIT_COMMITTED_AT=`ruby -r time -e "puts Time.iso8601(ENV['GIT_COMMITTED_AT']).to_i"` \
|
92
|
+
./tmp/cc-test-reporter after-build -t simplecov
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
data/DEVELOPMENT.md
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
# Development Guidelines
|
2
2
|
|
3
|
-
- Keep
|
3
|
+
- Keep it simple.
|
4
4
|
- Favor easy debug-ability over clever solutions.
|
5
5
|
- Aim to be a 0-dependency lib (at runtime)
|
6
6
|
|
7
7
|
## Roadmap
|
8
8
|
|
9
|
-
- Add "Settings" for Operations and Chains
|
9
|
+
- Add "Settings"/Dependency injection for Operations and Chains
|
10
10
|
|
11
11
|
```
|
12
12
|
MyOp.with(foo: "bar").call("input")
|
@@ -22,7 +22,11 @@
|
|
22
22
|
|
23
23
|
MyCain.with(:step1) { { foo: "bar" } }.with(:stepX) { { another: :setting} }.call(params)
|
24
24
|
```
|
25
|
-
- Add
|
26
|
-
|
25
|
+
- Add a dry-monads mixin to wrap Operations and Chains result/error into a Result Monad (for example see https://dry-rb.org/gems/dry-types/master/extensions/monads/)
|
26
|
+
```
|
27
|
+
MyOp.call("input").to_monad do
|
28
|
+
end
|
29
|
+
```
|
30
|
+
- Check if/how to deal with inheritance
|
27
31
|
- ...
|
28
32
|
|
data/Gemfile
CHANGED
@@ -6,3 +6,11 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
6
6
|
|
7
7
|
# Specify your gem's dependencies in teckel.gemspec
|
8
8
|
gemspec
|
9
|
+
|
10
|
+
group :development, :test do
|
11
|
+
gem "dry-struct", ">= 1.1.1", "< 2"
|
12
|
+
end
|
13
|
+
|
14
|
+
group :test do
|
15
|
+
gem "simplecov", require: false
|
16
|
+
end
|
data/README.md
CHANGED
@@ -5,7 +5,8 @@ Ruby service classes with enforced<sup name="footnote-1-source">[1](#footnote-1)
|
|
5
5
|
[][gem]
|
6
6
|
[][ci]
|
7
7
|
[](https://codeclimate.com/github/fnordfish/teckel/maintainability)
|
8
|
-
[](https://codeclimate.com/github/fnordfish/teckel/test_coverage)
|
9
|
+
[][inch]
|
9
10
|
|
10
11
|
## Installation
|
11
12
|
|
@@ -33,30 +34,35 @@ Working with [Interactor](https://github.com/collectiveidea/interactor), [Trailb
|
|
33
34
|
|
34
35
|
## Usage
|
35
36
|
|
36
|
-
For a full overview please see the
|
37
|
+
For a full overview please see the Api Docs:
|
38
|
+
|
39
|
+
* [Operations](https://fnordfish.github.io/teckel/doc/Teckel/Operation.html)
|
40
|
+
* [Operations with Result objects](https://fnordfish.github.io/teckel/doc/Teckel/Operation/Results.html)
|
41
|
+
* [Chains](https://fnordfish.github.io/teckel/doc/Teckel/Chain.html)
|
37
42
|
|
38
|
-
This example uses [Dry::Types](https://dry-rb.org/gems/dry-types/) to illustrate the flexibility. There's no dependency on dry-rb, choose what you like.
|
39
43
|
|
40
44
|
```ruby
|
41
45
|
class CreateUser
|
42
46
|
include Teckel::Operation
|
43
|
-
|
47
|
+
|
44
48
|
# DSL style declaration
|
45
|
-
input
|
46
|
-
|
49
|
+
input Struct.new(:name, :age, keyword_init: true)
|
50
|
+
|
47
51
|
# Constant style declaration
|
48
|
-
Output =
|
52
|
+
Output = ::User
|
49
53
|
|
50
54
|
# Well, also Constant style, but using classic `class` notation
|
51
|
-
class Error
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
+
class Error
|
56
|
+
def initialize(message:, status_code:, meta:)
|
57
|
+
@message, @status_code, @meta = message, status_code, meta
|
58
|
+
end
|
59
|
+
attr_reader :message, :status_code, :meta
|
55
60
|
end
|
61
|
+
error_constructor :new
|
56
62
|
|
57
63
|
def call(input)
|
58
|
-
user = User.
|
59
|
-
if user.
|
64
|
+
user = User.new(name: input.name, age: input.age)
|
65
|
+
if user.save
|
60
66
|
success!(user)
|
61
67
|
else
|
62
68
|
fail!(
|
@@ -68,7 +74,9 @@ class CreateUser
|
|
68
74
|
end
|
69
75
|
end
|
70
76
|
|
71
|
-
|
77
|
+
CreateUser.call(name: "Bob", age: 23) #=> #<User @age=23, @name="Bob">
|
78
|
+
|
79
|
+
CreateUser.call(name: "Bob", age: 5) #=> #<CreateUser::Error @message="Could not create User", @meta={:validation=>[{:age=>"underage"}]}, @status_code=400>
|
72
80
|
```
|
73
81
|
|
74
82
|
## Development
|
data/Rakefile
CHANGED
data/lib/teckel.rb
CHANGED
@@ -4,9 +4,12 @@ require "teckel/version"
|
|
4
4
|
|
5
5
|
module Teckel
|
6
6
|
class Error < StandardError; end
|
7
|
+
class FrozenConfigError < Teckel::Error; end
|
8
|
+
class MissingConfigError < Teckel::Error; end
|
7
9
|
end
|
8
10
|
|
9
11
|
require_relative "teckel/config"
|
12
|
+
require_relative "teckel/none"
|
10
13
|
require_relative "teckel/operation"
|
11
14
|
require_relative "teckel/result"
|
12
15
|
require_relative "teckel/operation/results"
|
data/lib/teckel/chain.rb
CHANGED
@@ -8,14 +8,13 @@ module Teckel
|
|
8
8
|
# - Runs multiple Operations (steps) in order.
|
9
9
|
# - The output of an earlier step is passed as input to the next step.
|
10
10
|
# - Any failure will stop the execution chain (none of the later steps is called).
|
11
|
-
# - All Operations (steps) must behave like
|
12
|
-
# return a result
|
13
|
-
#
|
11
|
+
# - All Operations (steps) must behave like
|
12
|
+
# {Teckel::Operation::Results Teckel::Operation::Results} and return a result
|
13
|
+
# object like {Teckel::Result}
|
14
|
+
# - A failure response is wrapped into a {Teckel::Chain::StepFailure} giving
|
14
15
|
# additional information about which step failed
|
15
16
|
#
|
16
17
|
# @see Teckel::Operation::Results
|
17
|
-
# @see Teckel::Result
|
18
|
-
# @see Teckel::Chain::StepFailure
|
19
18
|
#
|
20
19
|
# @example Defining a simple Chain with three steps
|
21
20
|
# class CreateUser
|
@@ -27,10 +26,10 @@ module Teckel
|
|
27
26
|
#
|
28
27
|
# def call(input)
|
29
28
|
# user = User.new(name: input[:name], age: input[:age])
|
30
|
-
# if user.
|
29
|
+
# if user.save
|
31
30
|
# success!(user)
|
32
31
|
# else
|
33
|
-
# fail!(message: "Could not
|
32
|
+
# fail!(message: "Could not save User", errors: user.errors)
|
34
33
|
# end
|
35
34
|
# end
|
36
35
|
# end
|
@@ -92,8 +91,89 @@ module Teckel
|
|
92
91
|
# # otherwise behaves just like a normal +Result+
|
93
92
|
# failure_result.failure? #=> true
|
94
93
|
# failure_result.failure #=> {message: "Did not find a friend."}
|
94
|
+
#
|
95
|
+
# @example DB transaction around hook
|
96
|
+
# class CreateUser
|
97
|
+
# include ::Teckel::Operation::Results
|
98
|
+
#
|
99
|
+
# input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
|
100
|
+
# output Types.Instance(User)
|
101
|
+
# error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
|
102
|
+
#
|
103
|
+
# def call(input)
|
104
|
+
# user = User.new(name: input[:name], age: input[:age])
|
105
|
+
# if user.save
|
106
|
+
# success!(user)
|
107
|
+
# else
|
108
|
+
# fail!(message: "Could not safe User", errors: user.errors)
|
109
|
+
# end
|
110
|
+
# end
|
111
|
+
# end
|
112
|
+
#
|
113
|
+
# class AddFriend
|
114
|
+
# class << self
|
115
|
+
# # Don't actually do this! It's not safe and for generating the failure sample only.
|
116
|
+
# attr_accessor :fail_befriend
|
117
|
+
# end
|
118
|
+
#
|
119
|
+
# include ::Teckel::Operation::Results
|
120
|
+
#
|
121
|
+
# input Types.Instance(User)
|
122
|
+
# output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
|
123
|
+
# error Types::Hash.schema(message: Types::String)
|
124
|
+
#
|
125
|
+
# def call(user)
|
126
|
+
# if self.class.fail_befriend
|
127
|
+
# fail!(message: "Did not find a friend.")
|
128
|
+
# else
|
129
|
+
# { user: user, friend: User.new(name: "A friend", age: 42) }
|
130
|
+
# end
|
131
|
+
# end
|
132
|
+
# end
|
133
|
+
#
|
134
|
+
# LOG = []
|
135
|
+
#
|
136
|
+
# class MyChain
|
137
|
+
# include Teckel::Chain
|
138
|
+
#
|
139
|
+
# around ->(chain, input) {
|
140
|
+
# result = nil
|
141
|
+
# begin
|
142
|
+
# LOG << :before
|
143
|
+
#
|
144
|
+
# FakeDB.transaction do
|
145
|
+
# result = chain.call(input)
|
146
|
+
# raise FakeDB::Rollback if result.failure?
|
147
|
+
# end
|
148
|
+
#
|
149
|
+
# LOG << :after
|
150
|
+
# result
|
151
|
+
# rescue FakeDB::Rollback
|
152
|
+
# LOG << :rollback
|
153
|
+
# result
|
154
|
+
# end
|
155
|
+
# }
|
156
|
+
#
|
157
|
+
# step :create, CreateUser
|
158
|
+
# step :befriend, AddFriend
|
159
|
+
# end
|
160
|
+
#
|
161
|
+
# AddFriend.fail_befriend = true
|
162
|
+
# failure_result = MyChain.call(name: "Bob", age: 23)
|
163
|
+
# failure_result.is_a?(Teckel::Chain::StepFailure) #=> true
|
164
|
+
#
|
165
|
+
# # triggered DB rollback
|
166
|
+
# LOG #=> [:before, :rollback]
|
167
|
+
#
|
168
|
+
# # additional step information
|
169
|
+
# failure_result.step_name #=> :befriend
|
170
|
+
# failure_result.step #=> AddFriend
|
171
|
+
#
|
172
|
+
# # otherwise behaves just like a normal +Result+
|
173
|
+
# failure_result.failure? #=> true
|
174
|
+
# failure_result.failure #=> {message: "Did not find a friend."}
|
95
175
|
module Chain
|
96
|
-
# Like
|
176
|
+
# Like {Teckel::Result Teckel::Result} but for failing Chains
|
97
177
|
#
|
98
178
|
# When a Chain fails, it stores the failed +Operation+ and it's name.
|
99
179
|
class StepFailure
|
@@ -133,15 +213,52 @@ module Teckel
|
|
133
213
|
def_delegators :@result, :value, :successful?, :success, :failure?, :failure
|
134
214
|
end
|
135
215
|
|
216
|
+
# The default implementation for executing a {Chain}
|
217
|
+
#
|
218
|
+
# @!visibility protected
|
219
|
+
class Runner
|
220
|
+
def initialize(steps)
|
221
|
+
@steps = steps
|
222
|
+
end
|
223
|
+
attr_reader :steps
|
224
|
+
|
225
|
+
# Run steps
|
226
|
+
#
|
227
|
+
# @param input Any form of input the first steps +input+ class can handle
|
228
|
+
#
|
229
|
+
# @return [Teckel::Result,Teckel::Chain::StepFailure] The result object wrapping
|
230
|
+
# either the success or failure value. Note that the {StepFailure} behaves
|
231
|
+
# just like a {Teckel::Result} with added information about which step failed.
|
232
|
+
def call(input)
|
233
|
+
last_result = input
|
234
|
+
failed = nil
|
235
|
+
steps.each do |(name, step)|
|
236
|
+
last_result = step.call(last_result)
|
237
|
+
if last_result.failure?
|
238
|
+
failed = StepFailure.new(step, name, last_result)
|
239
|
+
break
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
failed || last_result
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
136
247
|
module ClassMethods
|
248
|
+
# The expected input for this chain
|
249
|
+
# @return [Class] The {Teckel::Operation.input} of the first step
|
137
250
|
def input
|
138
251
|
@steps.first&.last&.input
|
139
252
|
end
|
140
253
|
|
254
|
+
# The expected output for this chain
|
255
|
+
# @return [Class] The {Teckel::Operation.output} of the last step
|
141
256
|
def output
|
142
257
|
@steps.last&.last&.output
|
143
258
|
end
|
144
259
|
|
260
|
+
# List of all possible errors
|
261
|
+
# @return [<Class>] List of all steps {Teckel::Operation.error}s
|
145
262
|
def errors
|
146
263
|
@steps.each_with_object([]) do |e, m|
|
147
264
|
err = e.last&.error
|
@@ -149,37 +266,125 @@ module Teckel
|
|
149
266
|
end
|
150
267
|
end
|
151
268
|
|
269
|
+
# Declare a {Operation} as a named step
|
270
|
+
#
|
271
|
+
# @param name [String,Symbol] The name of the operation.
|
272
|
+
# This name is used in an error case to let you know which step failed.
|
273
|
+
# @param operation [Operation::Results] The operation to call.
|
274
|
+
# Must return a {Teckel::Result} object.
|
275
|
+
def step(name, operation)
|
276
|
+
@steps << [name, operation]
|
277
|
+
end
|
278
|
+
|
279
|
+
# Set or get the optional around hook.
|
280
|
+
# A Hook might be given as a block or anything callable. The execution of
|
281
|
+
# the chain is yielded to this hook. The first argument being the callable
|
282
|
+
# chain ({Runner}) and the second argument the +input+ data. The hook also
|
283
|
+
# needs to return the result.
|
284
|
+
#
|
285
|
+
# @param callable [Proc,{#call}] The hook to pass chain execution control to. (nil)
|
286
|
+
#
|
287
|
+
# @return [Proc,{#call}] The configured hook
|
288
|
+
#
|
289
|
+
# @example Around hook with block
|
290
|
+
# OUTPUTS = []
|
291
|
+
#
|
292
|
+
# class Echo
|
293
|
+
# include ::Teckel::Operation::Results
|
294
|
+
#
|
295
|
+
# input Hash
|
296
|
+
# output input
|
297
|
+
#
|
298
|
+
# def call(hsh)
|
299
|
+
# hsh
|
300
|
+
# end
|
301
|
+
# end
|
302
|
+
#
|
303
|
+
# class MyChain
|
304
|
+
# include Teckel::Chain
|
305
|
+
#
|
306
|
+
# around do |chain, input|
|
307
|
+
# OUTPUTS << "before start"
|
308
|
+
# result = chain.call(input)
|
309
|
+
# OUTPUTS << "after start"
|
310
|
+
# result
|
311
|
+
# end
|
312
|
+
#
|
313
|
+
# step :noop, Echo
|
314
|
+
# end
|
315
|
+
#
|
316
|
+
# result = MyChain.call(some: 'test')
|
317
|
+
# OUTPUTS #=> ["before start", "after start"]
|
318
|
+
# result.success #=> { some: "test" }
|
319
|
+
def around(callable = nil, &block)
|
320
|
+
@config.for(:around, callable || block)
|
321
|
+
end
|
322
|
+
|
323
|
+
# @!attribute [r] runner()
|
324
|
+
# @return [Class] The Runner class
|
325
|
+
# @!visibility protected
|
326
|
+
|
327
|
+
# Overwrite the default runner
|
328
|
+
# @param klass [Class] A class like the {Runner}
|
329
|
+
# @!visibility protected
|
330
|
+
def runner(klass = nil)
|
331
|
+
@config.for(:runner, klass) { Runner }
|
332
|
+
end
|
333
|
+
|
334
|
+
# The primary interface to call the chain with the given input.
|
335
|
+
#
|
336
|
+
# @param input Any form of input the first steps +input+ class can handle
|
337
|
+
#
|
338
|
+
# @return [Teckel::Result,Teckel::Chain::StepFailure] The result object wrapping
|
339
|
+
# either the success or failure value. Note that the {StepFailure} behaves
|
340
|
+
# just like a {Teckel::Result} with added information about which step failed.
|
152
341
|
def call(input)
|
153
|
-
new
|
342
|
+
runner = self.runner.new(@steps)
|
343
|
+
if around
|
344
|
+
around.call(runner, input)
|
345
|
+
else
|
346
|
+
runner.call(input)
|
347
|
+
end
|
154
348
|
end
|
155
349
|
|
156
|
-
|
157
|
-
|
350
|
+
# Disallow any further changes to this Chain.
|
351
|
+
#
|
352
|
+
# @return [self] Frozen self
|
353
|
+
# @!visibility public
|
354
|
+
def finalize!
|
355
|
+
freeze
|
356
|
+
@steps.freeze
|
357
|
+
@config.freeze
|
358
|
+
self
|
158
359
|
end
|
159
|
-
end
|
160
360
|
|
161
|
-
|
162
|
-
def
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
result = step.call(result)
|
167
|
-
if result.failure?
|
168
|
-
failed = StepFailure.new(step, name, result)
|
169
|
-
break
|
170
|
-
end
|
361
|
+
# @!visibility public
|
362
|
+
def dup
|
363
|
+
super.tap do |copy|
|
364
|
+
copy.instance_variable_set(:@steps, @steps.dup)
|
365
|
+
copy.instance_variable_set(:@config, @config.dup)
|
171
366
|
end
|
367
|
+
end
|
172
368
|
|
173
|
-
|
369
|
+
# @!visibility public
|
370
|
+
def clone
|
371
|
+
if frozen?
|
372
|
+
super
|
373
|
+
else
|
374
|
+
super.tap do |copy|
|
375
|
+
copy.instance_variable_set(:@steps, @steps.dup)
|
376
|
+
copy.instance_variable_set(:@config, @config.dup)
|
377
|
+
end
|
378
|
+
end
|
174
379
|
end
|
175
380
|
end
|
176
381
|
|
177
382
|
def self.included(receiver)
|
178
|
-
receiver.extend
|
179
|
-
receiver.send :include, InstanceMethods
|
383
|
+
receiver.extend ClassMethods
|
180
384
|
|
181
385
|
receiver.class_eval do
|
182
386
|
@steps = []
|
387
|
+
@config = Config.new
|
183
388
|
end
|
184
389
|
end
|
185
390
|
end
|