teckel 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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