action_operation 1.1.0 → 2.0.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: 9472dfbb606def8319b09f68a32eb640f9fc2e1ecee0ef5ce32b19b3468cc7f1
4
- data.tar.gz: fc055466ce4f942268fc94866237ce480576ab8ce040ef776b9812e84bb8e192
3
+ metadata.gz: f97e8fc036a03a7ff4b0fdab9e4316d2312ad17ae5cf1a1844dc1ceb7cf84ad1
4
+ data.tar.gz: e2e2b98a9c87a50b42acbe2b2d1cfa507a4972d2871f6e26ad5a938d5a28c464
5
5
  SHA512:
6
- metadata.gz: 2437722d056461d7b88808c3f11dbf17551189e41f0dd8b03465aa0d95d4d27ba06c3aded9c1ca933916d8644ca01e3f086c1a47d9461ebbc02578d70f547143
7
- data.tar.gz: 7ddfe5268976e50691c39929f4f8588eeb6262b19eddb9c1cd50cfa52612d464720cf1c987f7be6dba43cd761edf236f8b83ab724f73ad460e4315116cf46da6
6
+ metadata.gz: b0d55912ff981d7298d3c9ca0c24babeea983427af130714e808d8767913cfde985b9a5fd5cb611d2f87c9c0986f133daa6357dd5645d460d740ea4f29e44e42
7
+ data.tar.gz: 90dca03f785f51506abb76bdcda2a2448e1236de32957ab5e9592e2252c3f63f125eb83c7e4e17477619d534a06fcbeb07788e266182b1e2abadf32bd89a1eb1
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  - [![Version](http://img.shields.io/gem/v/action_operation.svg?style=flat-square)](https://rubygems.org/gems/action_operation)
6
6
 
7
7
 
8
- A simple set of right-to-left operations, similar to many other gems out there.
8
+ A simple set of right-to-left operations, similar to many other gems out there like [trailblazer operations](http://trailblazer.to/gems/operation/2.0/index.html).
9
9
 
10
10
 
11
11
  ## Using
@@ -25,50 +25,55 @@ class AddToCartOperation
25
25
  task :lock
26
26
  task :persist
27
27
  task :publish
28
- error :notify, catch: ProductMissingFromCartItemError
29
- error :reraise
28
+ catch :notify, exception: ProductMissingFromCartItemError
29
+ catch :reraise
30
30
 
31
- state :check_for_missing_product do
31
+ schema :check_for_missing_product do
32
32
  field :cart_item, type: Types.Instance(CartItem)
33
33
  end
34
- step :check_for_missing_product do |state|
34
+ def check_for_missing_product(state:)
35
35
  raise ProductMissingFromCartItemError if state.cart_item.product.nil?
36
36
  end
37
37
 
38
- state :carbon_copy_cart_item do
38
+ schema :carbon_copy_cart_item do
39
39
  field :cart_item, type: Types.Instance(CartItem)
40
40
  end
41
- step :carbon_copy_cart_item do |state|
41
+ def carbon_copy_cart_item(state:)
42
42
  state.cart_item.carbon_copy
43
43
  end
44
44
 
45
- state :lock do
45
+ schema :lock do
46
46
  field :cart_item, type: Types.Instance(CartItem)
47
47
  end
48
- step :lock do |state|
49
- GlobalLock.(state.cart_item.owner, state.cart_item, expires_in: 15.minutes)
48
+ def lock(state:)
49
+ GlobalLock.(resource: state.cart_item, expires_in: 15.minutes)
50
50
  end
51
51
 
52
- state :persist do
52
+ schema :persist do
53
53
  field :cart_item, type: Types.Instance(CartItem)
54
54
  end
55
- step :persist do |state|
55
+ def persist(state:)
56
56
  CartItem.transaction do
57
57
  state.cart_item.save!
58
58
  end
59
59
 
60
- fresh(current_account: state.cart_item.owner, cart_item: state.cart_item)
60
+ fresh(state: {current_account: state.cart_item.owner, cart_item: state.cart_item})
61
61
  end
62
62
 
63
- state :publish do
63
+ schema :publish do
64
64
  field :cart_item, type: Types.Instance(CartItem)
65
65
  field :current_account, type: Types.Instance(Account)
66
66
  end
67
- step :publish do |state|
68
- CartItemPickedMessage.(subject: state.cart_item, to: state.current_account).via_pubsub.deliver_later!
67
+ def publish(state:)
68
+ CartItemPickedMessage.(
69
+ to: state.current_account,
70
+ subject: state.cart_item,
71
+ via: :pubsub,
72
+ deliver: :later
73
+ )
69
74
  end
70
75
 
71
- step :notify do |exception|
76
+ def notify(exception:, **)
72
77
  Bugsnag.notify(exception)
73
78
  end
74
79
  end
@@ -85,16 +90,16 @@ class AddToCartOperation
85
90
  task :lock
86
91
  task :persist
87
92
  task :publish
88
- error :notify, catch: ProductMissingFromCartItemError
89
- error :reraise
93
+ catch :notify, exception: ProductMissingFromCartItemError
94
+ catch :reraise
90
95
 
91
96
  # ...
92
97
  end
93
98
  ```
94
99
 
95
- These are the steps our process will take. Each `task` call is *in the order it is listed*, which means that `check_for_missing_product` will happen before `carbon_copy_cart_item`. Each `error` is also *in the order it is listed*, but they only trigger when one of the `task` raises an exception. In this case, we only want to `notify` when there's something seriously wrong!
100
+ These are the steps our process will take. Each `task` call is *in the order it is listed*, which means that `check_for_missing_product` will happen before `carbon_copy_cart_item`. Each `catch` is also *in the order it is listed*, but they only trigger when one of the `task` raises an exception. In this case, we only want to `notify` when there's something seriously wrong!
96
101
 
97
- Finally, before we leave, notice the `reraise` error step. This is built in to the operation layer so that you can easily pass the buck to whomever owns the action currently.
102
+ Finally, before we leave notice the `reraise` error step. This is built in to the operation layer so that you can easily pass the buck to whomever owns the action currently.
98
103
 
99
104
  Okay, so on to our first step:
100
105
 
@@ -102,10 +107,10 @@ Okay, so on to our first step:
102
107
  class AddToCartOperation
103
108
  # ...
104
109
 
105
- state :check_for_missing_product do
110
+ schema :check_for_missing_product do
106
111
  field :cart_item, type: Types.Instance(CartItem)
107
112
  end
108
- step :check_for_missing_product do |state|
113
+ def check_for_missing_product(state:)
109
114
  raise ProductMissingFromCartItemError if state.cart_item.product.nil?
110
115
  end
111
116
 
@@ -113,66 +118,40 @@ class AddToCartOperation
113
118
  end
114
119
  ```
115
120
 
116
- There's two things we want to talk about there and the first is `state`. It defines the shape of the *immutable* state that the step will receive. We use [smart_params](https://github.com/krainboltgreene/smart_params.rb) which means each field is typed with [dry-types](https://github.com/dry-rb/dry-types). Read up on both of those libraries for more fine grained control over your data.
117
-
118
- Second is the `step` definition itself which provides a `state` object that is based on the schema defined above. You have four choices on what you can do in a `step`. You can:
121
+ There's two things we want to talk about there and the first is `schema`. It defines the shape of the *immutable* state that the step will receive. We use [smart_params](https://github.com/krainboltgreene/smart_params.rb) which means each field is typed with [dry-types](https://github.com/dry-rb/dry-types). Read up on both of those libraries for more fine grained control over your data. Second is the step definition itself which provides a `state` object that is based on the schema by the same name. You have four choices on what you can do in a step. You can:
119
122
 
120
123
  - Return any value, which will simply proceed to the next step.
121
- - Raise an exception, which will move you into the left track (that uses `error` steps)
124
+ - Raise an exception, which will move you into the left track (that uses `catch` steps)
122
125
  - Return a fresh state, which will be described below
123
126
  - Return a drift instruction, which will be described below
124
127
 
125
128
  ### Fresh State
126
129
 
127
- Sometimes you want to change the data that is passed around after a step is completed. To achieve this functionality we provide the `fresh()` function:
130
+ Sometimes you want to pass different state to all steps after. We provide the `fresh()` function for this very purpose:
128
131
 
129
132
  ``` ruby
130
133
  class AddToCartOperation
131
134
  # ...
132
135
 
133
- step :persist do |state|
136
+ schema :persist do
137
+ field :cart_item, type: Types.Instance(CartItem)
138
+ end
139
+ def persist(state:)
134
140
  CartItem.transaction do
135
141
  state.cart_item.save!
136
142
  end
137
143
 
138
- fresh(current_account: state.cart_item.owner, cart_item: state.cart_item)
144
+ fresh(state: {current_account: state.cart_item.owner, cart_item: state.cart_item})
139
145
  end
140
146
 
141
147
  # ...
142
148
  end
143
149
  ```
144
150
 
145
- This is the only way to "change" the shape of the state.
146
-
147
-
148
- ### Receivers
149
-
150
- Sometimes you need to share functionality across multiple operations. You can do this via modules and inheritance like normal or you can use our specialized interface:
151
-
152
- ``` ruby
153
- class DocumentUploadOperation
154
- include ActionOperation
155
-
156
- task :upload_to_s3, receiver: S3UploadOperation
157
- end
158
- ```
159
-
160
- This will give the `DocumentUploadOperation` a task that is on another operation! Sometimes that other task has a different name, so we also provide aliasing:
161
-
162
- ``` ruby
163
- class DocumentUploadOperation
164
- include ActionOperation
165
-
166
- task :upload_to_s3, receiver: S3UploadOperation, :upload
167
- end
168
- ```
169
-
170
- So when `DocumentUploadOperation` finally gets to the `upload_to_s3` task it's actually calling the `S3UploadOperation` task called `upload`. More on why this is useful in the next section.
171
-
172
151
 
173
152
  ### Drifting
174
153
 
175
- Alright, so lets say you have a business requirement to upload important documents to the cloud. You have multiple providers (S3, Azure, and DigitalOcean Spaces) and you want to make sure it gets pushed to at least one. Here's how you would write this:
154
+ Alright, so lets say you have a business requirement to upload important documents to the cloud. You have multiple providers (S3, Azure, and DigitalOcean Spaces) and you want to make sure it gets pushed to at least one. First we define how to talk to S3:
176
155
 
177
156
  ``` ruby
178
157
  class S3UploadOperation
@@ -180,67 +159,86 @@ class S3UploadOperation
180
159
 
181
160
  task :upload
182
161
 
183
- state :upload do
162
+ schema :upload do
184
163
  field :document, type: Types.Instance(Document)
185
164
  end
186
- step :upload do |state|
187
- fresh(document: state.document, location: S3.push(state.document))
165
+ def upload(state:)
166
+ fresh(state: {document: state.document, location: S3.push(state.document)})
188
167
  rescue StandardError => exception
189
168
  raise FailedUploadError
190
169
  end
191
170
  end
192
-
193
171
  ```
194
172
 
195
- ``` ruby
196
- class DocumentUploadOperation
197
- include ActionOperation
173
+ Now we define the controlling operation:
198
174
 
199
- task :upload_to_s3, receiver: S3UploadOperation, as: :upload
200
- task :upload_to_azure, receiver: AzureUploadOperation, as: :upload, required: false
201
- task :upload_to_spaces, receiver: SpacesUploadOperation, as: :upload, required: false
175
+ ``` ruby
176
+ class DocumentUploadOperation < ApplicationOperation
177
+ task :upload_to_s3
178
+ task :upload_to_azure, required: false
179
+ task :upload_to_spaces, required: false
202
180
  task :publish
203
- error :retry, catch: FailedUploadError
204
- error :reraise
181
+ catch :retry, exception: FailedUploadError
182
+ catch :reraise
205
183
 
206
- step :retry do |exception, _, step|
207
- case step
208
- when :upload_to_s3 then drift(to: :upload_to_azure)
209
- when :upload_to_azure then drift(to: :upload_to_spaces)
210
- end
184
+ schema :upload_to_s3 do
185
+ field :document, type: Types.Instance(Document)
186
+ end
187
+ def upload_to_s3(state:)
188
+ fresh(state: S3UploadOperation.(document: state.document))
211
189
  end
212
190
 
213
- state :publish do
191
+ schema :upload_to_azure do
192
+ field :document, type: Types.Instance(Document)
193
+ end
194
+ def upload_to_azure(state:)
195
+ fresh(state: AzureUploadOperation.(document: state.document))
196
+ end
197
+
198
+ schema :upload_to_spaces do
199
+ field :document, type: Types.Instance(Document)
200
+ end
201
+ def upload_to_spaces(state:)
202
+ fresh(state: SpacesUploadOperation.(document: state.document))
203
+ end
204
+
205
+ schema :publish do
214
206
  field :document, type: Types.Instance(Document)
215
207
  field :location, type: Types::Strict::String
216
208
  end
217
- step :publish do |state|
218
- DocumentSuccessfullyUploadedMessage.(owner: state.document.owner, location: state.location).via_pubsub.deliver_later!
209
+ def publish(state:)
210
+ DocumentSuccessfullyUploadedMessage.(
211
+ to: state.document.owner,
212
+ subject: state.location,
213
+ via: :pubsub,
214
+ deliver: :later
215
+ )
216
+ end
217
+
218
+ def retry(exception:, step:, **)
219
+ case step.name
220
+ when :upload_to_s3 then drift(to: :upload_to_azure)
221
+ when :upload_to_azure then drift(to: :upload_to_spaces)
222
+ end
219
223
  end
220
224
  end
221
225
  ```
222
226
 
223
227
  So here's how this works:
224
228
 
225
- 1. First we call `upload_to_s3`, which actually talks to `S3UploadOperation/upload`, but for some reason this fails and gets caught by `failed_upload`, which bubbles up a specific exception that we catch with `DocumentUploadOperation/retry`
229
+ 1. First we call `upload_to_s3` which talks to `S3UploadOperation`, but for some reason this fails which bubbles up a specific exception that we catch with `DocumentUploadOperation#retry`
226
230
  2. `retry` looks at the last known step and then drifts to `upload_to_azure`, which functions just like above.
227
231
  3. Then somehow we fail to upload to Azure, so we repeat and retry with DigitalOcean Spaces.
228
- 4. We fail to even upload that, which means the next error step gets called (`reraise`) giving control back to the owner of the operation
229
-
230
-
231
- However, if it finishes successfully we get to push a notification to the document owner in `finish`.
232
-
233
-
234
- ### Understanding the design
232
+ 4. We fail to even upload that, which means the next catch step gets called (`reraise()`) giving control back to the owner of the operation
235
233
 
236
- Each task is a map function wrapped in a HOC for handling the return data. The annotation of each task is `state -> mixed | state` and the HOC is `state -> (state -> mixed | state) -> state`. `error` is like a task, but instead: `exception -> mixed` wrapped in a HOC that matches `exception -> (exception -> mixed) -> exception`.
234
+ However, if it finishes successfully we get to push a notification to the document owner in `publish()`.
237
235
 
238
236
 
239
237
  ## Installing
240
238
 
241
239
  Add this line to your application's Gemfile:
242
240
 
243
- gem "action_operation", "1.1.0"
241
+ gem "action_operation", "2.0.0"
244
242
 
245
243
  And then execute:
246
244
 
@@ -1,13 +1,12 @@
1
1
  module ActionOperation
2
2
  class Error
3
3
  class MissingError < Error
4
- def initialize(function)
5
- @function = function
4
+ def initialize(step)
5
+ @step = step
6
6
  end
7
7
 
8
8
  def message
9
- binding.pry
10
- "expected to see #{@function.name} but the receiver (#{@function.receiver.name}) didn't support it"
9
+ "expected to see #{@step.name} but the receiver (#{@step.receiver.name}) didn't support it"
11
10
  end
12
11
  end
13
12
  end
@@ -0,0 +1,13 @@
1
+ module ActionOperation
2
+ class Error
3
+ class MissingSchema < Error
4
+ def initialize(step)
5
+ @step = step
6
+ end
7
+
8
+ def message
9
+ "expected to see #{@step.name} have a schema but the receiver (#{@step.receiver.name}) didn't support it"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,12 +1,12 @@
1
1
  module ActionOperation
2
2
  class Error
3
3
  class MissingTask < Error
4
- def initialize(function)
5
- @function = function
4
+ def initialize(step)
5
+ @step = step
6
6
  end
7
7
 
8
8
  def message
9
- "expected to see #{function.name} but the receiver (#{function.receiver.name}) didn't support it"
9
+ "expected to see #{@step.name} but the receiver (#{@step.receiver.name}) didn't support it"
10
10
  end
11
11
  end
12
12
  end
@@ -1,7 +1,7 @@
1
1
  module ActionOperation
2
2
  class Error < StandardError
3
3
  require_relative "error/missing_error"
4
- require_relative "error/missing_schema_for_task"
4
+ require_relative "error/missing_schema"
5
5
  require_relative "error/missing_task"
6
6
  end
7
7
  end
@@ -1,3 +1,3 @@
1
1
  module ActionOperation
2
- VERSION = "1.1.0"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -6,62 +6,63 @@ module ActionOperation
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  require_relative "action_operation/version"
9
- require_relative "action_operation/types"
10
9
  require_relative "action_operation/error"
10
+ require_relative "action_operation/types"
11
11
 
12
12
  State = Struct.new(:raw)
13
13
  Drift = Struct.new(:to)
14
-
15
- attr_reader :raw
16
- attr_reader :state
17
- attr_reader :step
14
+ Task = Struct.new(:name, :required)
15
+ Catch = Struct.new(:name, :exception)
18
16
 
19
17
  def initialize(raw:)
18
+ raise ArgumentError, "needs to be a Hash" unless raw.kind_of?(Hash)
19
+
20
20
  @raw = raw
21
21
  end
22
22
 
23
- def call(forced: nil)
24
- right.from(forced || 0).reduce(state || raw) do |state, function|
25
- next state unless function.required || (forced && right.at(forced) == function)
26
-
27
- # NOTE: We store this so we can go drift back if an error tells us to
28
- @state = state
23
+ def call(start: nil, raw: @raw)
24
+ right.from(start || 0).reduce(raw) do |state, step|
25
+ next state unless step.required || (start && right.at(start) == step)
29
26
 
30
- # NOTE: We store this so an error step can ask for the last ran step
31
- @step = function.name
27
+ raise Error::MissingTask, step unless respond_to?(step.name)
28
+ raise Error::MissingSchema, step unless self.class.schemas.key?(step.name)
32
29
 
33
- raise Error::MissingTask, function unless function.receiver.steps.key?(function.as)
34
- raise Error::MissingSchemaForTask, function unless function.receiver.schemas.key?(function.as)
30
+ # NOTE: We only care about this so we can refernece it in the rescue
31
+ @latest_step = step
35
32
 
36
- value = instance_exec(function.receiver.schemas.fetch(function.as).new(state), &function.receiver.steps.fetch(function.as))
33
+ value = public_send(step.name, state: self.class.schemas.fetch(step.name).new(state))
37
34
 
38
35
  case value
39
36
  when State then value.raw
40
- when Drift then break call(forced: right.find_index { |step| step.name == value.to })
37
+ when Drift then break call(start: right.find_index { |step| step.name == value.to }, raw: raw)
41
38
  else state
42
39
  end
43
40
  end
44
- rescue *left.select(&:catch).map(&:catch).uniq => handled_exception
41
+ rescue *left.select(&:exception).map(&:exception).uniq => handled_exception
45
42
  left.select do |failure|
46
- failure.catch === handled_exception
47
- end.reduce(handled_exception) do |exception, function|
48
- raise Error::MissingError, function unless function.receiver.steps.key?(function.as)
43
+ failure.exception === handled_exception
44
+ end.reduce(handled_exception) do |exception, step|
45
+ raise Error::MissingError, step unless respond_to?(step.name)
49
46
 
50
- value = instance_exec(exception, @state, @step, &function.receiver.steps.fetch(function.as))
47
+ value = public_send(step.name, exception: exception, state: self.class.schemas.fetch(@latest_step.name).new(raw), step: @latest_step)
51
48
 
52
49
  if value.kind_of?(Drift)
53
- break call(forced: right.find_index { |step| step.name == value.to })
50
+ break call(start: right.find_index { |step| step.name == value.to }, raw: raw)
54
51
  else
55
52
  exception
56
53
  end
57
54
  end
58
55
  end
59
56
 
60
- def fresh(raw)
61
- State.new(raw)
57
+ def fresh(state:)
58
+ raise ArgumentError, "needs to be a Hash" unless state.kind_of?(Hash)
59
+
60
+ State.new(state)
62
61
  end
63
62
 
64
63
  def drift(to:)
64
+ raise ArgumentError, "needs to be a Symbol or String" unless to.kind_of?(Symbol) || to.kind_of?(String)
65
+
65
66
  Drift.new(to)
66
67
  end
67
68
 
@@ -73,22 +74,16 @@ module ActionOperation
73
74
  self.class.right
74
75
  end
75
76
 
76
- included do
77
- step :reraise do |exception|
78
- raise exception
79
- end
77
+ def reraise(exception:, **)
78
+ raise exception
80
79
  end
81
80
 
82
81
  class_methods do
83
- def inherited(klass)
84
- klass.class_eval do
85
- step :reraise do |exception|
86
- raise exception
87
- end
88
- end
82
+ def call(raw = {})
83
+ new(raw: raw).call
89
84
  end
90
85
 
91
- def state(name, &structure)
86
+ def schema(name, &structure)
92
87
  schemas[name] = Class.new do
93
88
  include(SmartParams)
94
89
 
@@ -96,20 +91,12 @@ module ActionOperation
96
91
  end
97
92
  end
98
93
 
99
- def task(name, receiver: self, as: name, required: true)
100
- right.<<(OpenStruct.new({name: name, as: as, receiver: receiver || self, required: required}))
101
- end
102
-
103
- def error(name, receiver: self, catch: StandardError, as: name)
104
- left.<<(OpenStruct.new({name: name, as: as, receiver: receiver || self, catch: catch || StandardError}))
94
+ def task(name, required: true)
95
+ right << Task.new(name, required)
105
96
  end
106
97
 
107
- def step(name, &process)
108
- steps[name] = process
109
- end
110
-
111
- def call(raw = {})
112
- new(raw: raw).call
98
+ def catch(name, exception: StandardError)
99
+ left << Catch.new(name, exception)
113
100
  end
114
101
 
115
102
  def right
@@ -123,9 +110,5 @@ module ActionOperation
123
110
  def schemas
124
111
  @schemas ||= Hash.new
125
112
  end
126
-
127
- def steps
128
- @steps ||= Hash.new
129
- end
130
113
  end
131
114
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_operation
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kurtis Rainbolt-Greene
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-05-01 00:00:00.000000000 Z
11
+ date: 2018-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -138,7 +138,7 @@ files:
138
138
  - lib/action_operation.rb
139
139
  - lib/action_operation/error.rb
140
140
  - lib/action_operation/error/missing_error.rb
141
- - lib/action_operation/error/missing_schema_for_task.rb
141
+ - lib/action_operation/error/missing_schema.rb
142
142
  - lib/action_operation/error/missing_task.rb
143
143
  - lib/action_operation/types.rb
144
144
  - lib/action_operation/version.rb
@@ -1,13 +0,0 @@
1
- module ActionOperation
2
- class Error
3
- class MissingSchemaForTask < Error
4
- def initialize(function)
5
- @function = function
6
- end
7
-
8
- def message
9
- "expected to see #{function.name} have a schema but the receiver (#{function.receiver.name}) didn't support it"
10
- end
11
- end
12
- end
13
- end