action_operation 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ac4f5bc88c4689c01c68d6761631feb15ff1b17e54274be64223b3e9d6190f50
4
+ data.tar.gz: ccced431a6f77242476bc749a94c5d3a742f8b91cb24ced140543c14f9cff55f
5
+ SHA512:
6
+ metadata.gz: 1d93d27a24c101660023688e238d7d84743c5586b901dc73942524422f7317587ac9b38925967dbbefd0ad1193a4dc9b39fd295d4b32a1ac280dfceabcca6507
7
+ data.tar.gz: 5f9f49b17ca20437080e52466d07195f19fe752adb52237a196b3e6011d39d3ca950b4bd749640be5f62c0eb47db32d419d54dfd5b132efb70d872f8ad0f6b9d
data/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # action_operation
2
+
3
+ - [![Build](http://img.shields.io/travis-ci/krainboltgreene/action_operation.rb.svg?style=flat-square)](https://travis-ci.org/krainboltgreene/action_operation.rb)
4
+ - [![Downloads](http://img.shields.io/gem/dtv/action_operation.svg?style=flat-square)](https://rubygems.org/gems/action_operation)
5
+ - [![Version](http://img.shields.io/gem/v/action_operation.svg?style=flat-square)](https://rubygems.org/gems/action_operation)
6
+
7
+
8
+ A simple set of right-to-left operations, similar to many other gems out there.
9
+
10
+
11
+ ## Using
12
+
13
+ Alright, so you have some business logic you'd like to control in your application. You've found that putting in the controllers sucks, because an application is more than it's HTTP requests. You've found that putting in the model sucks, because there's not enough context and does too many things. You've found that "service classes" have no form or shape and get way too out of hand.
14
+
15
+ ActionOperation is here to help! This, like many others before and after, gives a concise way to describe a series of business requirements. It has as much context as you give it and only does the thing you need it to do. It can be used anywhere and everywhere.
16
+
17
+ First let's make our operation:
18
+
19
+ ``` ruby
20
+ class AddToCartOperation
21
+ include ActionOperation
22
+
23
+ task :check_for_missing_product
24
+ task :carbon_copy_cart_item
25
+ task :lock
26
+ task :persist
27
+ task :publish
28
+ error :notify, catch: ProductMissingFromCartItemError
29
+ error :reraise
30
+
31
+ state :check_for_missing_product do
32
+ field :cart_item, type: Types.Instance(CartItem)
33
+ end
34
+ step :check_for_missing_product do |state|
35
+ raise ProductMissingFromCartItemError if state.cart_item.product.nil?
36
+ end
37
+
38
+ state :carbon_copy_cart_item do
39
+ field :cart_item, type: Types.Instance(CartItem)
40
+ end
41
+ step :carbon_copy_cart_item do |state|
42
+ state.cart_item.carbon_copy
43
+ end
44
+
45
+ state :lock do
46
+ field :cart_item, type: Types.Instance(CartItem)
47
+ end
48
+ step :lock do |state|
49
+ GlobalLock.(state.cart_item.owner, state.cart_item, expires_in: 15.minutes)
50
+ end
51
+
52
+ state :persist do
53
+ field :cart_item, type: Types.Instance(CartItem)
54
+ end
55
+ step :persist do |state|
56
+ CartItem.transaction do
57
+ state.cart_item.save!
58
+ end
59
+
60
+ fresh(current_account: state.cart_item.owner, cart_item: state.cart_item)
61
+ end
62
+
63
+ state :publish do
64
+ field :cart_item, type: Types.Instance(CartItem)
65
+ field :current_account, type: Types.Instance(Account)
66
+ end
67
+ step :publish do |state|
68
+ CartItemPickedMessage.(subject: state.cart_item, to: state.current_account).via_pubsub.deliver_later!
69
+ end
70
+
71
+ step :notify do |exception|
72
+ Bugsnag.notify(exception)
73
+ end
74
+ end
75
+ ```
76
+
77
+ There's a lot to take in here, so lets go through each point:
78
+
79
+ ``` ruby
80
+ class AddToCartOperation
81
+ # ...
82
+
83
+ task :check_for_missing_product
84
+ task :carbon_copy_cart_item
85
+ task :lock
86
+ task :persist
87
+ task :publish
88
+ error :notify, catch: ProductMissingFromCartItemError
89
+ error :reraise
90
+
91
+ # ...
92
+ end
93
+ ```
94
+
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!
96
+
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.
98
+
99
+ Okay, so on to our first step:
100
+
101
+ ``` ruby
102
+ class AddToCartOperation
103
+ # ...
104
+
105
+ state :check_for_missing_product do
106
+ field :cart_item, type: Types.Instance(CartItem)
107
+ end
108
+ step :check_for_missing_product do |state|
109
+ raise ProductMissingFromCartItemError if state.cart_item.product.nil?
110
+ end
111
+
112
+ # ...
113
+ end
114
+ ```
115
+
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:
119
+
120
+ - 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)
122
+ - Return a fresh state, which will be described below
123
+ - Return a drift instruction, which will be described below
124
+
125
+ ### Fresh State
126
+
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:
128
+
129
+ ``` ruby
130
+ class AddToCartOperation
131
+ # ...
132
+
133
+ step :persist do |state|
134
+ CartItem.transaction do
135
+ state.cart_item.save!
136
+ end
137
+
138
+ fresh(current_account: state.cart_item.owner, cart_item: state.cart_item)
139
+ end
140
+
141
+ # ...
142
+ end
143
+ ```
144
+
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
+
173
+ ### Drifting
174
+
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:
176
+
177
+ ``` ruby
178
+ class S3UploadOperation
179
+ include ActionOperation
180
+
181
+ task :upload
182
+
183
+ state :upload do
184
+ field :document, type: Types.Instance(Document)
185
+ end
186
+ step :upload do |state|
187
+ fresh(document: state.document, location: S3.push(state.document))
188
+ rescue StandardError => exception
189
+ raise FailedUploadError
190
+ end
191
+ end
192
+
193
+ ```
194
+
195
+ ``` ruby
196
+ class DocumentUploadOperation
197
+ include ActionOperation
198
+
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
202
+ task :publish
203
+ error :retry, catch: FailedUploadError
204
+ error :reraise
205
+
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
211
+ end
212
+
213
+ state :publish do
214
+ field :document, type: Types.Instance(Document)
215
+ field :location, type: Types::Strict::String
216
+ end
217
+ step :publish do |state|
218
+ DocumentSuccessfullyUploadedMessage.(owner: state.document.owner, location: state.location).via_pubsub.deliver_later!
219
+ end
220
+ end
221
+ ```
222
+
223
+ So here's how this works:
224
+
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`
226
+ 2. `retry` looks at the last known step and then drifts to `upload_to_azure`, which functions just like above.
227
+ 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
235
+
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`.
237
+
238
+
239
+ ## Installing
240
+
241
+ Add this line to your application's Gemfile:
242
+
243
+ gem "action_operation", "1.0.0"
244
+
245
+ And then execute:
246
+
247
+ $ bundle
248
+
249
+ Or install it yourself with:
250
+
251
+ $ gem install action_operation
252
+
253
+
254
+ ## Contributing
255
+
256
+ 1. Read the [Code of Conduct](/CONDUCT.md)
257
+ 2. Fork it
258
+ 3. Create your feature branch (`git checkout -b my-new-feature`)
259
+ 4. Commit your changes (`git commit -am 'Add some feature'`)
260
+ 5. Push to the branch (`git push origin my-new-feature`)
261
+ 6. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ desc "Run all the tests in spec"
7
+ RSpec::Core::RakeTask.new(:spec) do |let|
8
+ let.pattern = "lib/**{,/*/**}/*_spec.rb"
9
+ end
10
+
11
+ desc "Default: run tests"
12
+ task default: :spec
@@ -0,0 +1,123 @@
1
+ require "active_support/concern"
2
+ require "active_support/core_ext/array"
3
+ require "smart_params"
4
+
5
+ module ActionOperation
6
+ extend ActiveSupport::Concern
7
+
8
+ require_relative "action_operation/version"
9
+ require_relative "action_operation/types"
10
+ require_relative "action_operation/error"
11
+
12
+ State = Struct.new(:raw)
13
+ Drift = Struct.new(:to)
14
+
15
+ attr_reader :raw
16
+ attr_reader :state
17
+ attr_reader :step
18
+
19
+ def initialize(raw:)
20
+ @raw = raw
21
+ end
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
29
+
30
+ # NOTE: We store this so an error step can ask for the last ran step
31
+ @step = function.name
32
+
33
+ raise Error::MissingTask, function unless function.receiver.steps.key?(function.as)
34
+ raise Error::MissingSchemaForTask, function unless function.receiver.schemas.key?(function.as)
35
+
36
+ value = instance_exec(function.receiver.schemas.fetch(function.as).new(state), &function.receiver.steps.fetch(function.as))
37
+
38
+ case value
39
+ when State then value.raw
40
+ when Drift then break call(forced: right.find_index { |step| step.name == value.to })
41
+ else state
42
+ end
43
+ end
44
+ rescue *left.select(&:catch).map(&:catch).uniq => handled_exception
45
+ 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)
49
+
50
+ value = instance_exec(exception, @state, @step, &function.receiver.steps.fetch(function.as))
51
+
52
+ if value.kind_of?(Drift)
53
+ break call(forced: right.find_index { |step| step.name == value.to })
54
+ else
55
+ exception
56
+ end
57
+ end
58
+ end
59
+
60
+ def fresh(raw)
61
+ State.new(raw)
62
+ end
63
+
64
+ def drift(to:)
65
+ Drift.new(to)
66
+ end
67
+
68
+ private def left
69
+ self.class.left
70
+ end
71
+
72
+ private def right
73
+ self.class.right
74
+ end
75
+
76
+ included do
77
+ step :reraise do |exception|
78
+ raise exception
79
+ end
80
+ end
81
+
82
+ class_methods do
83
+ def state(name, &structure)
84
+ schemas[name] = Class.new do
85
+ include(SmartParams)
86
+
87
+ schema type: SmartParams::Strict::Hash, &structure
88
+ end
89
+ end
90
+
91
+ def task(name, receiver: self, as: name, required: true)
92
+ right.<<(OpenStruct.new({name: name, as: as, receiver: receiver || self, required: required}))
93
+ end
94
+
95
+ def error(name, receiver: self, catch: StandardError, as: name)
96
+ left.<<(OpenStruct.new({name: name, as: as, receiver: receiver || self, catch: catch || StandardError}))
97
+ end
98
+
99
+ def step(name, &process)
100
+ steps[name] = process
101
+ end
102
+
103
+ def call(raw = {})
104
+ new(raw: raw).call
105
+ end
106
+
107
+ def right
108
+ @right ||= Array.new
109
+ end
110
+
111
+ def left
112
+ @left ||= Array.new
113
+ end
114
+
115
+ def schemas
116
+ @schemas ||= Hash.new
117
+ end
118
+
119
+ def steps
120
+ @steps ||= Hash.new
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,7 @@
1
+ module ActionOperation
2
+ class Error < StandardError
3
+ require_relative "error/missing_error"
4
+ require_relative "error/missing_schema_for_task"
5
+ require_relative "error/missing_task"
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module ActionOperation
2
+ class Error
3
+ class MissingError < Error
4
+ def initialize(function)
5
+ @function = function
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module ActionOperation
2
+ class Error
3
+ class MissingSchemaForTask < Error
4
+ def initialize(function)
5
+ @function = function
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module ActionOperation
2
+ class Error
3
+ class MissingTask < Error
4
+ def initialize(function)
5
+ @function = function
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module ActionOperation
2
+ module Types
3
+ include Dry::Types.module
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module ActionOperation
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,7 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe ActionOperation::VERSION do
4
+ it "should be a string" do
5
+ expect(ActionOperation::VERSION).to be_kind_of(String)
6
+ end
7
+ end
@@ -0,0 +1,50 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe ActionOperation do
4
+ let(:operation) { DocumentUploadOperation }
5
+
6
+ describe "#call" do
7
+ subject { operation.call(arguments) }
8
+
9
+ context "with the right arguments" do
10
+ let(:arguments) {{document: Document.new}}
11
+
12
+ it "works" do
13
+ expect(subject).to match(hash_including(document: a_kind_of(Document), location: "some.s3"))
14
+ end
15
+
16
+ context "drifting from s3" do
17
+ before do
18
+ allow(S3).to receive(:push).and_raise(StandardError, 'something')
19
+ end
20
+
21
+ it "works" do
22
+ expect(subject).to match(hash_including(document: a_kind_of(Document), location: "some.azure"))
23
+ end
24
+ end
25
+
26
+ context "drifting from s3 and azure" do
27
+ before do
28
+ allow(S3).to receive(:push).and_raise(StandardError, 'something')
29
+ allow(Azure).to receive(:push).and_raise(StandardError, 'something')
30
+ end
31
+
32
+ it "works" do
33
+ expect(subject).to match(hash_including(document: a_kind_of(Document), location: "some.spaces"))
34
+ end
35
+ end
36
+
37
+ context "drifting from s3 and azure and spacs" do
38
+ before do
39
+ allow(S3).to receive(:push).and_raise(StandardError, 'something')
40
+ allow(Azure).to receive(:push).and_raise(StandardError, 'something')
41
+ allow(Spaces).to receive(:push).and_raise(StandardError, 'something')
42
+ end
43
+
44
+ it "works" do
45
+ expect{subject}.to raise_exception(FailedUploadError)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
metadata ADDED
@@ -0,0 +1,171 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_operation
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Kurtis Rainbolt-Greene
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-05-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '12.2'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '12.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.11'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.11'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry-doc
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.11'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.11'
83
+ - !ruby/object:Gem::Dependency
84
+ name: activesupport
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 4.0.0
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '4.1'
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 5.0.0
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '5.1'
99
+ type: :runtime
100
+ prerelease: false
101
+ version_requirements: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 4.0.0
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '4.1'
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 5.0.0
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '5.1'
115
+ - !ruby/object:Gem::Dependency
116
+ name: smart_params
117
+ requirement: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '2.0'
122
+ type: :runtime
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '2.0'
129
+ description: A set of BPMN style operation logic
130
+ email:
131
+ - kurtis@rainbolt-greene.online
132
+ executables: []
133
+ extensions: []
134
+ extra_rdoc_files: []
135
+ files:
136
+ - README.md
137
+ - Rakefile
138
+ - lib/action_operation.rb
139
+ - lib/action_operation/error.rb
140
+ - lib/action_operation/error/missing_error.rb
141
+ - lib/action_operation/error/missing_schema_for_task.rb
142
+ - lib/action_operation/error/missing_task.rb
143
+ - lib/action_operation/types.rb
144
+ - lib/action_operation/version.rb
145
+ - lib/action_operation/version_spec.rb
146
+ - lib/action_operation_spec.rb
147
+ homepage: http://krainboltgreene.github.io/action_operation
148
+ licenses:
149
+ - ISC
150
+ metadata: {}
151
+ post_install_message:
152
+ rdoc_options: []
153
+ require_paths:
154
+ - lib
155
+ required_ruby_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ required_rubygems_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ requirements: []
166
+ rubyforge_project:
167
+ rubygems_version: 2.7.3
168
+ signing_key:
169
+ specification_version: 4
170
+ summary: A set of BPMN style operation logic
171
+ test_files: []