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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f8117e6d304e571ccfd26db58af5122330494e3df8b2597cfa5f8e11ad19b12
4
- data.tar.gz: 9aeecbf92e87d3fa0610defed63f0e3f1048925348c43af00d83aab68ffb1265
3
+ metadata.gz: a9fbb7828be6e84c5d609241e778e39e8509a4cede9f8960f097b31ef0d2473b
4
+ data.tar.gz: b646f5954b1fb331186f52cf2a6ccd8521004bb26e59a09ec6b6d4d8575affa2
5
5
  SHA512:
6
- metadata.gz: 7b6eef841002932cad55e8bd4a7121d173624633962de203e0eda72602bbf36afa947126100bd7795ab078540b4b7210a32fc0d51eea117c558943d418026526
7
- data.tar.gz: 60c090f9fd1ec11deb535dcd0e9838146401f361754f15469a80104b69ec58d68a64c5d32399b29d1fb0264538e418e3cf1c3188df6f3fac1af6c87f2fca631b
6
+ metadata.gz: 454b1869e2b2a8bf540f203fccf9962d628c236c2ced1411ded69ca2ccb1e0c361965c74ad73dea6c709b97b7757016c24487f983924111b03f4f0c1c2d0287e
7
+ data.tar.gz: fd5f0e9e4e147238423454ef7a801139fd6f0ef3b1a7c2198285b7c471f1a26fb64c879647a9c041edb8129e4279a00e52572038959e9b8bd473668772dd78e2
data/.codeclimate.yml ADDED
@@ -0,0 +1,3 @@
1
+ plugins:
2
+ rubocop:
3
+ enabled: true
@@ -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
@@ -11,3 +11,5 @@
11
11
  .rspec_status
12
12
 
13
13
  .rubocop-http*
14
+
15
+ /Gemfile.lock
data/CHANGELOG.md CHANGED
@@ -0,0 +1,12 @@
1
+ # Changes
2
+
3
+ ## 0.2.0
4
+
5
+ - Around Hooks for Chains
6
+ - `finalize!`
7
+ - freezing Chains and Operations, to prevent further changes
8
+ - Operations check their config and raise if any is missing
9
+
10
+ ## 0.1.0
11
+
12
+ - Initial release
data/DEVELOPMENT.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Development Guidelines
2
2
 
3
- - Keep is simple.
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 support for around hooks in Chains (for db transactions etc.)
26
- - Add a dry-monads mixin to wrap Operations and Chains result/error into a Result Monad
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 Version](https://img.shields.io/gem/v/teckel.svg)][gem]
6
6
  [![Build Status](https://github.com/dry-rb/dry-configurable/workflows/ci/badge.svg)][ci]
7
7
  [![Maintainability](https://api.codeclimate.com/v1/badges/b3939aaec6271a567a57/maintainability)](https://codeclimate.com/github/fnordfish/teckel/maintainability)
8
- [![API Documentation Coverage](http://inch-ci.org/github/dry-rb/dry-configurable.svg)][inch]
8
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/b3939aaec6271a567a57/test_coverage)](https://codeclimate.com/github/fnordfish/teckel/test_coverage)
9
+ [![API Documentation Coverage](https://inch-ci.org/github/fnordfish/teckel.svg?branch=master)][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 [Api Docs](https://fnordfish.github.io/teckel/doc/)
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 Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
46
-
49
+ input Struct.new(:name, :age, keyword_init: true)
50
+
47
51
  # Constant style declaration
48
- Output = Types.Instance(User)
52
+ Output = ::User
49
53
 
50
54
  # Well, also Constant style, but using classic `class` notation
51
- class Error < Dry::Struct
52
- attribute :message, Types::String
53
- attribute :status_code, Types::Integer
54
- attribute :meta, Types::Hash.optional
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.create(input)
59
- if user.safe
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
- result = CreateUser.call(name: "Bob", age: 23)
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
@@ -7,6 +7,11 @@ require "yard/doctest/rake"
7
7
 
8
8
  RSpec::Core::RakeTask.new(:spec)
9
9
 
10
+ task :docs do
11
+ Rake::Task["docs:yard"].invoke
12
+ Rake::Task["docs:yard:doctest"].invoke
13
+ end
14
+
10
15
  namespace :docs do
11
16
  YARD::Rake::YardocTask.new do |t|
12
17
  t.files = ['lib/**/*.rb']
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 +Teckel::Operation::Results+ and
12
- # return a result object like +Teckel::Result+
13
- # - A failure response is wrapped into a +Teckel::Chain::StepFailure+ giving
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.safe
29
+ # if user.save
31
30
  # success!(user)
32
31
  # else
33
- # fail!(message: "Could not safe User", errors: user.errors)
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 +Teckel::Result+ but for failing Chains
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.call!(@steps, input)
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
- def step(name, operation)
157
- @steps << [name, operation]
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
- module InstanceMethods
162
- def call!(steps, input)
163
- result = input
164
- failed = nil
165
- steps.each do |(name, step)|
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
- failed || result
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 ClassMethods
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