subroutine 0.0.1
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 +7 -0
- data/.gitignore +14 -0
- data/.travis.yml +28 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +354 -0
- data/Rakefile +10 -0
- data/gemfiles/am30.gemfile +5 -0
- data/gemfiles/am31.gemfile +5 -0
- data/gemfiles/am32.gemfile +5 -0
- data/gemfiles/am40.gemfile +5 -0
- data/gemfiles/am41.gemfile +5 -0
- data/gemfiles/am42.gemfile +5 -0
- data/lib/subroutine/version.rb +10 -0
- data/lib/subroutine.rb +243 -0
- data/subroutine.gemspec +28 -0
- data/test/subroutine/base_test.rb +149 -0
- data/test/support/ops.rb +97 -0
- data/test/test_helper.rb +13 -0
- metadata +141 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: ab045b5fde4a639699ed15240b6549dd909454da
|
4
|
+
data.tar.gz: 56ac432123c26d31a5265c304fce0c6268dab892
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bebe659c3d84427adbacbe3bc0bffecfcfef2155cd6c4497a530f820d0198c43e2f899933b32c82ec21f678d2d021f1a0fd4f50524ef1faf9793247185f2253a
|
7
|
+
data.tar.gz: 6e561078a1f08f1159f25ee590ad5cdeff1d185aba711f36ec0ef4ac0263f8ae79715e557d84b7ee3ebe318cc74f93de65676ac82986913d11fb738a9092113d
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
language: ruby
|
2
|
+
sudo: false
|
3
|
+
|
4
|
+
rvm:
|
5
|
+
- 1.9.3
|
6
|
+
- 2.0.0
|
7
|
+
- 2.1.1
|
8
|
+
- jruby
|
9
|
+
|
10
|
+
gemfile:
|
11
|
+
- gemfiles/am40.gemfile
|
12
|
+
- gemfiles/am41.gemfile
|
13
|
+
- gemfiles/am42.gemfile
|
14
|
+
|
15
|
+
matrix:
|
16
|
+
exclude:
|
17
|
+
- rvm: 1.8.7
|
18
|
+
gemfile: gemfiles/am40.gemfile
|
19
|
+
- rvm: 1.8.7
|
20
|
+
gemfile: gemfiles/am41.gemfile
|
21
|
+
- rvm: 1.8.7
|
22
|
+
gemfile: gemfiles/am42.gemfile
|
23
|
+
- rvm: 1.9.2
|
24
|
+
gemfile: gemfiles/am40.gemfile
|
25
|
+
- rvm: 1.9.2
|
26
|
+
gemfile: gemfiles/am41.gemfile
|
27
|
+
- rvm: 1.9.2
|
28
|
+
gemfile: gemfiles/am42.gemfile
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2015 Mike Nelson
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,354 @@
|
|
1
|
+
# Subroutine
|
2
|
+
|
3
|
+
A gem that provides an interface for creating feature-driven operations. It loosly implements the command pattern if you're interested in nerding out a bit. See the examples below, it'll be more clear.
|
4
|
+
|
5
|
+
## Examples
|
6
|
+
|
7
|
+
So you need to sign up a user? or maybe update one's account? or change a password? or maybe you need to sign up a business along with a user, associate them, send an email, and queue a worker in a single request? Not a problem, create an op for any of these use cases. Here's the signup example.
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
class SignupOp < ::Subroutine::Op
|
11
|
+
|
12
|
+
field :name
|
13
|
+
field :email
|
14
|
+
field :password
|
15
|
+
|
16
|
+
validates :name, presence: true
|
17
|
+
validates :email, presence: true
|
18
|
+
validates :password, presence: true
|
19
|
+
|
20
|
+
attr_reader :signed_up_user
|
21
|
+
|
22
|
+
protected
|
23
|
+
|
24
|
+
def perform
|
25
|
+
u = build_user
|
26
|
+
u.save!
|
27
|
+
|
28
|
+
deliver_welcome_email!(u)
|
29
|
+
|
30
|
+
@signed_up_user = u
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
def build_user
|
35
|
+
User.new(filtered_params)
|
36
|
+
end
|
37
|
+
|
38
|
+
def deliver_welcome_email!(u)
|
39
|
+
UserMailer.welcome(u.id).deliver_later
|
40
|
+
end
|
41
|
+
end
|
42
|
+
```
|
43
|
+
|
44
|
+
So why is this needed?
|
45
|
+
|
46
|
+
1. No insane cluttering of controllers with strong parameters, etc.
|
47
|
+
2. No insane cluttering of models with validations, callbacks, and random methods that don't relate to integrity or access of model data.
|
48
|
+
3. Insanely testable.
|
49
|
+
4. Insanely easy to read and maintain.
|
50
|
+
5. Multi-model operations become insanely easy.
|
51
|
+
6. Your sanity.
|
52
|
+
|
53
|
+
### Connecting it all
|
54
|
+
|
55
|
+
```txt
|
56
|
+
app/
|
57
|
+
|
|
58
|
+
|- controllers/
|
59
|
+
| |- users_controller.rb
|
60
|
+
|
|
61
|
+
|- models/
|
62
|
+
| |- user.rb
|
63
|
+
|
|
64
|
+
|- ops/
|
65
|
+
|- signup_op.rb
|
66
|
+
|
67
|
+
```
|
68
|
+
|
69
|
+
#### Route
|
70
|
+
```ruby
|
71
|
+
resources :users, only: [] do
|
72
|
+
collection do
|
73
|
+
post :signup
|
74
|
+
end
|
75
|
+
end
|
76
|
+
```
|
77
|
+
|
78
|
+
#### Model
|
79
|
+
```ruby
|
80
|
+
# When ops are around, the point of the model is to ensure the data entering the db is 100% valid.
|
81
|
+
# So most of your models are a series of validations and common accessors, queries, etc.
|
82
|
+
class User
|
83
|
+
validates :name, presence: true
|
84
|
+
validates :email, email: true
|
85
|
+
|
86
|
+
has_secure_password
|
87
|
+
end
|
88
|
+
```
|
89
|
+
|
90
|
+
#### Controller(s)
|
91
|
+
```ruby
|
92
|
+
# I've found that a great way to handle errors with ops is to allow you top level controller to appropriately
|
93
|
+
# render errors in a consisent way. This is exceptionally easy for api-driven apps.
|
94
|
+
class Api::Controller < ApplicationController
|
95
|
+
rescue_from ::Subroutine::Failure, with: :render_op_failure
|
96
|
+
|
97
|
+
def render_op_failure(e)
|
98
|
+
# however you want to do this, `e` will be similar to an ActiveRecord::RecordInvalid error
|
99
|
+
# e.record.errors, etc
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# With ops, your controllers are essentially just connections between routes, operations, and templates.
|
104
|
+
class UsersController < ::Api::Controller
|
105
|
+
def sign_up
|
106
|
+
# If the op fails, a ::Subroutine::Failure will be raised.
|
107
|
+
op = SignupOp.submit!(params)
|
108
|
+
|
109
|
+
# If the op succeeds, it will be returned so you can access it's information.
|
110
|
+
render json: op.signed_up_user
|
111
|
+
end
|
112
|
+
end
|
113
|
+
```
|
114
|
+
|
115
|
+
## Usage
|
116
|
+
|
117
|
+
Both the `Subroutine::Op` class and it's instances provide `submit` and `submit!` methods with identical signatures. Here are ways to invoke an op:
|
118
|
+
|
119
|
+
#### Via the class' `submit` method
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
op = MyOp.submit({foo: 'bar'})
|
123
|
+
# if the op succeeds it will be returned, otherwise it false will be returned.
|
124
|
+
```
|
125
|
+
|
126
|
+
#### Via the class' `submit!` method
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
op = MyOp.submit!({foo: 'bar'})
|
130
|
+
# if the op succeeds it will be returned, otherwise a ::Subroutine::Failure will be raised.
|
131
|
+
```
|
132
|
+
|
133
|
+
#### Via the instance's `submit` method
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
op = MyOp.new({foo: 'bar'})
|
137
|
+
val = op.submit
|
138
|
+
# if the op succeeds, val will be true, otherwise false
|
139
|
+
```
|
140
|
+
|
141
|
+
#### Via the instance's `submit!` method
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
op = MyOp.new({foo: 'bar'})
|
145
|
+
op.submit!
|
146
|
+
# if the op succeeds nothing will be raised, otherwise a ::Subroutine::Failure will be raised.
|
147
|
+
```
|
148
|
+
|
149
|
+
#### Fluff
|
150
|
+
|
151
|
+
Ops have some fluff. Let's see if we can cover it all with one example. I'll pretend I'm using ActiveRecord:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
class ActivateOp < ::Subroutine::Op
|
155
|
+
|
156
|
+
# This will inherit all fields, error mappings, and default values from the SignupOp class.
|
157
|
+
# It currently does not inherit validations
|
158
|
+
inputs_from ::SignupOp
|
159
|
+
|
160
|
+
# This defines new inputs for this op.
|
161
|
+
field :invitation_token
|
162
|
+
field :thank_you_message
|
163
|
+
|
164
|
+
# This maps any "inherited" errors to the op's input.
|
165
|
+
# So if one of our objects that we inherit errors from has an email_address error, it will end up on our errors as "email".
|
166
|
+
error_map email_address: :email
|
167
|
+
|
168
|
+
# If you wanted default values, they can be declared a couple different ways:
|
169
|
+
# default thank_you_message: "Thanks so much"
|
170
|
+
# field thank_you_message: "Thanks so much"
|
171
|
+
# field :thank_you_message, default: "Thanks so much"
|
172
|
+
|
173
|
+
# If your default values need to be evaluated at runtime, simply wrap them in a proc:
|
174
|
+
# default thank_you_message: -> { I18n.t('thank_you') }
|
175
|
+
|
176
|
+
# Validations are declared just like any other ActiveModel
|
177
|
+
validates :token, presence: true
|
178
|
+
validate :validate_invitation_available
|
179
|
+
|
180
|
+
protected
|
181
|
+
|
182
|
+
# This is where the actual operation takes place.
|
183
|
+
def perform
|
184
|
+
user = nil
|
185
|
+
|
186
|
+
# Jump into a transaction to make sure any failure rolls back all changes.
|
187
|
+
ActiveRecord::Base.transaction do
|
188
|
+
user = create_user!
|
189
|
+
associate_invitation!(user)
|
190
|
+
end
|
191
|
+
|
192
|
+
# Set our "success" accessors.
|
193
|
+
@activated_user = user
|
194
|
+
|
195
|
+
# Return a truthy value to declare success.
|
196
|
+
true
|
197
|
+
end
|
198
|
+
|
199
|
+
# Use an existing op! OMG SO DRY
|
200
|
+
# You have access to the original inputs via original_params
|
201
|
+
def create_user!
|
202
|
+
op = ::SignupOp.submit!(original_params)
|
203
|
+
op.signed_up_user
|
204
|
+
end
|
205
|
+
|
206
|
+
# Deal with our invitation after our user is saved.
|
207
|
+
def associate_invitation!(user)
|
208
|
+
_invitation.user_id = user.id
|
209
|
+
_invitation.thank_you_message = defaulted_thank_you_message
|
210
|
+
_invitation.convert!
|
211
|
+
end
|
212
|
+
|
213
|
+
# Build a default value if the user didn't provide one.
|
214
|
+
def defaulted_thank_you_message
|
215
|
+
# You can check to see if a specific field was provided via field_provided?()
|
216
|
+
return thank_you_message if field_provided?(:thank_you_message)
|
217
|
+
thank_you_message.presence || I18n.t('thanks')
|
218
|
+
end
|
219
|
+
|
220
|
+
# Fetch the invitation via the provided token.
|
221
|
+
def _invitation
|
222
|
+
return @_invitation if defined?(@_invitation)
|
223
|
+
@_invitation = token ? ::Invitation.find_by(token: token) : nil
|
224
|
+
end
|
225
|
+
|
226
|
+
# Verbosely validate the existence of the invitation.
|
227
|
+
# In most cases, these validations can be written simpler.
|
228
|
+
# The true/false return value is a style I like but not required.
|
229
|
+
def validate_invitation_available
|
230
|
+
|
231
|
+
# The other validation has already added a message for a blank token.
|
232
|
+
return true if token.blank?
|
233
|
+
|
234
|
+
# Ensure we found an invitation matching the token.
|
235
|
+
# We could have used find_by!() in `_invitation` as well.
|
236
|
+
unless _invitation.present?
|
237
|
+
errors.add(:token, :not_found)
|
238
|
+
return false
|
239
|
+
end
|
240
|
+
|
241
|
+
# Ensure the token is valid.
|
242
|
+
unless _invitation.can_be_converted?
|
243
|
+
errors.add(:token, :not_convertable)
|
244
|
+
return false
|
245
|
+
end
|
246
|
+
|
247
|
+
true
|
248
|
+
end
|
249
|
+
|
250
|
+
end
|
251
|
+
```
|
252
|
+
|
253
|
+
### Extending Subroutine::Op
|
254
|
+
|
255
|
+
Great, so you're sold on using ops. Let's talk about how I usually standardize their usage in my apps. The most common thing needed is `current_user`. For this reason I usually follow the rails convention of declaring an "Application" op which declares all of my common needs. I hate writing `ApplicationOp` all the time so I usually call it `BaseOp`.
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
class BaseOp < ::Subroutine::Op
|
259
|
+
|
260
|
+
attr_reader :current_user
|
261
|
+
|
262
|
+
def initialize(*args)
|
263
|
+
params = args.extract_options!
|
264
|
+
@current_user = args[0]
|
265
|
+
super(params)
|
266
|
+
end
|
267
|
+
|
268
|
+
end
|
269
|
+
```
|
270
|
+
|
271
|
+
Great, so now I can pass the current user as my first argument to any op constructor. The next most common case is permissions. In a common role-based system things become pretty easy. I usually just add a class method which declares the minimum required role.
|
272
|
+
|
273
|
+
```ruby
|
274
|
+
class SendInvitationOp < BaseOp
|
275
|
+
require_role :admin
|
276
|
+
end
|
277
|
+
```
|
278
|
+
|
279
|
+
In the case of a more complex permission system, I'll usually utilize pundit but still standardize the check as a validation.
|
280
|
+
|
281
|
+
```ruby
|
282
|
+
class BaseOp < ::Subroutine::Op
|
283
|
+
|
284
|
+
validate :validate_permissions
|
285
|
+
|
286
|
+
protected
|
287
|
+
|
288
|
+
# default implementation is to allow access.
|
289
|
+
def validate_permissions
|
290
|
+
true
|
291
|
+
end
|
292
|
+
|
293
|
+
def not_authorized!
|
294
|
+
errors.add(:current_user, :not_authorized)
|
295
|
+
false
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
class SendInvitationOp < BaseOp
|
300
|
+
|
301
|
+
protected
|
302
|
+
|
303
|
+
def validate_permissions
|
304
|
+
unless UserPolicy.new(current_user).send_invitations?
|
305
|
+
return not_authorized!
|
306
|
+
end
|
307
|
+
|
308
|
+
true
|
309
|
+
end
|
310
|
+
|
311
|
+
end
|
312
|
+
```
|
313
|
+
|
314
|
+
Clearly there are a ton of ways this could be implemented but that should be a good jumping-off point.
|
315
|
+
|
316
|
+
Performance monitoring is also important to me so I've added a few hooks to observe what's going on during an op's submission. I'm primarily using Skylight at the moment.
|
317
|
+
|
318
|
+
```ruby
|
319
|
+
class BaseOp < ::Subroutine::Op
|
320
|
+
|
321
|
+
protected
|
322
|
+
|
323
|
+
def observe_submission
|
324
|
+
Skylight.instrument category: 'op.submission', title: "#{self.class.name}#submit" do
|
325
|
+
yield
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
def observe_validation
|
330
|
+
Skylight.instrument category: 'op.validation', title: "#{self.class.name}#valid?" do
|
331
|
+
yield
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
def observe_perform
|
336
|
+
Skylight.instrument category: 'op.perform', title: "#{self.class.name}#perform" do
|
337
|
+
yield
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
341
|
+
```
|
342
|
+
|
343
|
+
## Todo
|
344
|
+
|
345
|
+
1. Enable ActiveModel 3.0-3.2 users by removing the ActiveModel::Model dependency.
|
346
|
+
2. Demo app?
|
347
|
+
|
348
|
+
## Contributing
|
349
|
+
|
350
|
+
1. Fork it ( https://github.com/[my-github-username]/subroutine/fork )
|
351
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
352
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
353
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
354
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
data/lib/subroutine.rb
ADDED
@@ -0,0 +1,243 @@
|
|
1
|
+
require "subroutine/version"
|
2
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
3
|
+
require 'active_model'
|
4
|
+
|
5
|
+
module Subroutine
|
6
|
+
|
7
|
+
class Failure < StandardError
|
8
|
+
attr_reader :record
|
9
|
+
def initialize(record)
|
10
|
+
@record = record
|
11
|
+
errors = @record.errors.full_messages.join(", ")
|
12
|
+
super(errors)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Op
|
17
|
+
|
18
|
+
include ::ActiveModel::Model
|
19
|
+
include ::ActiveModel::Validations::Callbacks
|
20
|
+
|
21
|
+
class << self
|
22
|
+
|
23
|
+
# fields can be provided in the following way:
|
24
|
+
# field :field1, :field2
|
25
|
+
# field :field3, :field4, default: 'my default'
|
26
|
+
# field field5: 'field5 default', field6: 'field6 default'
|
27
|
+
def field(*fields)
|
28
|
+
last_hash = fields.extract_options!
|
29
|
+
options = last_hash.slice(:default, :scope)
|
30
|
+
|
31
|
+
fields << last_hash.except(:default, :scope)
|
32
|
+
|
33
|
+
fields.each do |f|
|
34
|
+
|
35
|
+
if f.is_a?(Hash)
|
36
|
+
f.each do |k,v|
|
37
|
+
field(k, options.merge(:default => v))
|
38
|
+
end
|
39
|
+
else
|
40
|
+
|
41
|
+
_field(f, options)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
alias_method :fields, :field
|
47
|
+
|
48
|
+
|
49
|
+
def inputs_from(*ops)
|
50
|
+
ops.each do |op|
|
51
|
+
field(*op._fields)
|
52
|
+
defaults(op._defaults)
|
53
|
+
error_map(op._error_map)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
def default(pairs)
|
59
|
+
self._defaults.merge!(pairs.stringify_keys)
|
60
|
+
end
|
61
|
+
alias_method :defaults, :default
|
62
|
+
|
63
|
+
|
64
|
+
def error_map(map)
|
65
|
+
self._error_map.merge!(map)
|
66
|
+
end
|
67
|
+
alias_method :error_maps, :error_map
|
68
|
+
|
69
|
+
|
70
|
+
def inherited(child)
|
71
|
+
super
|
72
|
+
|
73
|
+
child._fields = []
|
74
|
+
child._defaults = {}
|
75
|
+
child._error_map = {}
|
76
|
+
|
77
|
+
child._fields |= self._fields
|
78
|
+
child._defaults.merge!(self._defaults)
|
79
|
+
child._error_map.merge!(self._error_map)
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
def submit!(*args)
|
84
|
+
op = new(*args)
|
85
|
+
op.submit!
|
86
|
+
|
87
|
+
op
|
88
|
+
end
|
89
|
+
|
90
|
+
def submit(*args)
|
91
|
+
op = new(*args)
|
92
|
+
op.submit
|
93
|
+
op
|
94
|
+
end
|
95
|
+
|
96
|
+
protected
|
97
|
+
|
98
|
+
def _field(field_name, options = {})
|
99
|
+
field = [options[:scope], field_name].compact.join('_')
|
100
|
+
self._fields += [field]
|
101
|
+
|
102
|
+
attr_accessor field
|
103
|
+
|
104
|
+
default(field => options[:default]) if options[:default]
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
|
110
|
+
class_attribute :_fields
|
111
|
+
self._fields = []
|
112
|
+
class_attribute :_defaults
|
113
|
+
self._defaults = {}
|
114
|
+
class_attribute :_error_map
|
115
|
+
self._error_map = {}
|
116
|
+
|
117
|
+
attr_reader :original_params
|
118
|
+
attr_reader :params
|
119
|
+
|
120
|
+
|
121
|
+
def initialize(inputs = {})
|
122
|
+
@original_params = inputs.with_indifferent_access
|
123
|
+
@params = {}
|
124
|
+
|
125
|
+
self.class._defaults.each do |k,v|
|
126
|
+
self.send("#{k}=", v.respond_to?(:call) ? v.call : v)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
|
131
|
+
def submit!
|
132
|
+
unless submit
|
133
|
+
raise ::Subroutine::Failure.new(self)
|
134
|
+
end
|
135
|
+
true
|
136
|
+
end
|
137
|
+
|
138
|
+
# the action which should be invoked upon form submission (from the controller)
|
139
|
+
def submit
|
140
|
+
observe_submission do
|
141
|
+
@params = filter_params(@original_params)
|
142
|
+
|
143
|
+
set_accessors(@params)
|
144
|
+
|
145
|
+
validate_and_perform
|
146
|
+
end
|
147
|
+
|
148
|
+
rescue Exception => e
|
149
|
+
if e.respond_to?(:record)
|
150
|
+
inherit_errors_from(e.record) unless e.record == self
|
151
|
+
false
|
152
|
+
else
|
153
|
+
raise e
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
protected
|
158
|
+
|
159
|
+
# these enable you to 1) add log output or 2) add performance monitoring such as skylight.
|
160
|
+
def observe_submission
|
161
|
+
yield
|
162
|
+
end
|
163
|
+
|
164
|
+
def observe_validation
|
165
|
+
yield
|
166
|
+
end
|
167
|
+
|
168
|
+
def observe_perform
|
169
|
+
yield
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
def validate_and_perform
|
174
|
+
bool = observe_validation do
|
175
|
+
valid?
|
176
|
+
end
|
177
|
+
return false unless bool
|
178
|
+
|
179
|
+
observe_perform do
|
180
|
+
perform
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# implement this in your concrete class.
|
185
|
+
def perform
|
186
|
+
raise NotImplementedError
|
187
|
+
end
|
188
|
+
|
189
|
+
def field_provided?(key)
|
190
|
+
@params.has_key?(key)
|
191
|
+
end
|
192
|
+
|
193
|
+
|
194
|
+
# applies the errors to the form object from the child object, optionally at the namespace provided
|
195
|
+
def inherit_errors_from(object, namespace = nil)
|
196
|
+
inherit_errors(object.errors, namespace)
|
197
|
+
end
|
198
|
+
|
199
|
+
|
200
|
+
# applies the errors in error_object to self, optionally at the namespace provided
|
201
|
+
# returns false so failure cases can end with this invocation
|
202
|
+
def inherit_errors(error_object, namespace = nil)
|
203
|
+
error_object.each do |k,v|
|
204
|
+
|
205
|
+
keys = [k, [namespace, k].compact.join('_')].map(&:to_sym).uniq
|
206
|
+
keys = keys.map{|key| _error_map[key] || key }
|
207
|
+
|
208
|
+
match = keys.detect{|key| self.respond_to?(key) || @original_params.try(:has_key?, key) }
|
209
|
+
|
210
|
+
if match
|
211
|
+
errors.add(match, v)
|
212
|
+
else
|
213
|
+
errors.add(:base, error_object.full_message(k, v))
|
214
|
+
end
|
215
|
+
|
216
|
+
end
|
217
|
+
|
218
|
+
false
|
219
|
+
end
|
220
|
+
|
221
|
+
|
222
|
+
# if you want to use strong parameters or something in your form object you can do so here.
|
223
|
+
def filter_params(inputs)
|
224
|
+
inputs.slice(*_fields)
|
225
|
+
end
|
226
|
+
|
227
|
+
|
228
|
+
def set_accessors(inputs, namespace = nil)
|
229
|
+
inputs.each do |key, value|
|
230
|
+
|
231
|
+
setter = [namespace, key].compact.join('_')
|
232
|
+
|
233
|
+
if respond_to?("#{setter}=") && _fields.include?(setter)
|
234
|
+
send("#{setter}=", value)
|
235
|
+
elsif value.is_a?(Hash)
|
236
|
+
set_accessors(value, setter)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
end
|
242
|
+
|
243
|
+
end
|
data/subroutine.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'subroutine/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "subroutine"
|
8
|
+
spec.version = Subroutine::VERSION
|
9
|
+
spec.authors = ["Mike Nelson"]
|
10
|
+
spec.email = ["mike@mnelson.io"]
|
11
|
+
spec.summary = %q{Feature-driven operation objects.}
|
12
|
+
spec.description = %q{An interface for creating feature-driven operations.}
|
13
|
+
spec.homepage = "https://github.com/mnelson/subroutine"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(gemfiles|test)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "activemodel", ">= 4.0.0"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.7"
|
24
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
25
|
+
|
26
|
+
spec.add_development_dependency "minitest"
|
27
|
+
spec.add_development_dependency "minitest-reporters"
|
28
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Subroutine
|
4
|
+
class OpTest < TestCase
|
5
|
+
|
6
|
+
def test_simple_fields_definition
|
7
|
+
op = ::SignupOp.new
|
8
|
+
assert_equal ['email', 'password'], op._fields
|
9
|
+
end
|
10
|
+
|
11
|
+
def test_inherited_fields
|
12
|
+
op = ::AdminSignupOp.new
|
13
|
+
assert_equal ['email', 'password', 'priveleges'], op._fields
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_class_attribute_usage
|
17
|
+
assert ::AdminSignupOp < ::SignupOp
|
18
|
+
|
19
|
+
sid = ::SignupOp._fields.object_id
|
20
|
+
bid = ::AdminSignupOp._fields.object_id
|
21
|
+
|
22
|
+
refute_equal sid, bid
|
23
|
+
|
24
|
+
sid = ::SignupOp._defaults.object_id
|
25
|
+
bid = ::AdminSignupOp._defaults.object_id
|
26
|
+
|
27
|
+
refute_equal sid, bid
|
28
|
+
|
29
|
+
sid = ::SignupOp._error_map.object_id
|
30
|
+
bid = ::AdminSignupOp._error_map.object_id
|
31
|
+
|
32
|
+
refute_equal sid, bid
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_inputs_from_inherited_fields_without_inheriting_from_the_class
|
37
|
+
refute ::BusinessSignupOp < ::SignupOp
|
38
|
+
|
39
|
+
::SignupOp._fields.each do |field|
|
40
|
+
assert_includes ::BusinessSignupOp._fields, field
|
41
|
+
end
|
42
|
+
|
43
|
+
::SignupOp._defaults.each_pair do |k,v|
|
44
|
+
assert_equal v, ::BusinessSignupOp._defaults[k]
|
45
|
+
end
|
46
|
+
|
47
|
+
::SignupOp._error_map.each_pair do |k,v|
|
48
|
+
assert_equal v, ::BusinessSignupOp._error_map[k]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_defaults_declaration_options
|
53
|
+
assert_equal ::DefaultsOp._defaults, {
|
54
|
+
'foo' => 'foo',
|
55
|
+
'baz' => 'baz',
|
56
|
+
'bar' => 'bar'
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_inherited_defaults_override_correctly
|
61
|
+
assert_equal 'barstool', ::InheritedDefaultsOp._defaults['bar']
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_accessors_are_created
|
65
|
+
op = ::SignupOp.new
|
66
|
+
|
67
|
+
assert_respond_to op, :email
|
68
|
+
assert_respond_to op, :email=
|
69
|
+
|
70
|
+
assert_respond_to op, :password
|
71
|
+
assert_respond_to op, :password=
|
72
|
+
|
73
|
+
refute_respond_to ::SignupOp, :email
|
74
|
+
refute_respond_to ::SignupOp, :email=
|
75
|
+
refute_respond_to ::SignupOp, :password
|
76
|
+
refute_respond_to ::SignupOp, :password=
|
77
|
+
end
|
78
|
+
|
79
|
+
def test_defaults_are_applied_to_new_instances
|
80
|
+
op = ::SignupOp.new
|
81
|
+
|
82
|
+
assert_nil op.email
|
83
|
+
assert_nil op.password
|
84
|
+
|
85
|
+
op = ::AdminSignupOp.new
|
86
|
+
|
87
|
+
assert_nil op.email
|
88
|
+
assert_nil op.password
|
89
|
+
assert_equal 'min', op.priveleges
|
90
|
+
end
|
91
|
+
|
92
|
+
def test_validations_are_evaluated_before_perform_is_invoked
|
93
|
+
op = ::SignupOp.new
|
94
|
+
|
95
|
+
refute op.submit
|
96
|
+
|
97
|
+
refute op.perform_called
|
98
|
+
|
99
|
+
assert_equal ["can't be blank"], op.errors[:email]
|
100
|
+
end
|
101
|
+
|
102
|
+
def test_validation_errors_can_be_inherited_and_transformed
|
103
|
+
op = ::AdminSignupOp.new(:email => 'foo@bar.com', :password => 'password123')
|
104
|
+
|
105
|
+
refute op.submit
|
106
|
+
|
107
|
+
assert op.perform_called
|
108
|
+
refute op.perform_finished
|
109
|
+
|
110
|
+
assert_equal ["has gotta be @admin.com"], op.errors[:email]
|
111
|
+
end
|
112
|
+
|
113
|
+
def test_when_valid_perform_completes_it_returns_control
|
114
|
+
op = ::SignupOp.new(:email => 'foo@bar.com', :password => 'password123')
|
115
|
+
op.submit!
|
116
|
+
|
117
|
+
assert op.perform_called
|
118
|
+
assert op.perform_finished
|
119
|
+
|
120
|
+
u = op.created_user
|
121
|
+
|
122
|
+
assert_equal 'foo@bar.com', u.email_address
|
123
|
+
end
|
124
|
+
|
125
|
+
def test_it_raises_an_error_when_used_with_a_bang_and_performing_or_validation_fails
|
126
|
+
op = ::SignupOp.new(:email => 'foo@bar.com')
|
127
|
+
|
128
|
+
err = assert_raises ::Subroutine::Failure do
|
129
|
+
op.submit!
|
130
|
+
end
|
131
|
+
|
132
|
+
assert_equal "Password can't be blank", err.message
|
133
|
+
end
|
134
|
+
|
135
|
+
def test_it_allows_submission_from_the_class
|
136
|
+
op = SignupOp.submit
|
137
|
+
assert_equal ["can't be blank"], op.errors[:email]
|
138
|
+
|
139
|
+
assert_raises ::Subroutine::Failure do
|
140
|
+
SignupOp.submit!
|
141
|
+
end
|
142
|
+
|
143
|
+
op = SignupOp.submit! :email => 'foo@bar.com', :password => 'password123'
|
144
|
+
assert_equal 'foo@bar.com', op.created_user.email_address
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
end
|
data/test/support/ops.rb
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
## Models ##
|
2
|
+
|
3
|
+
class User
|
4
|
+
include ::ActiveModel::Model
|
5
|
+
|
6
|
+
attr_accessor :email_address
|
7
|
+
attr_accessor :password
|
8
|
+
|
9
|
+
validates :email_address, :presence => true
|
10
|
+
end
|
11
|
+
|
12
|
+
class AdminUser < ::User
|
13
|
+
validates :email_address, :format => {:with => /@admin\.com/, :message => 'has gotta be @admin.com'}
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
## Ops ##
|
18
|
+
|
19
|
+
class SignupOp < ::Subroutine::Op
|
20
|
+
|
21
|
+
field :email
|
22
|
+
field :password
|
23
|
+
|
24
|
+
validates :email, :presence => true
|
25
|
+
validates :password, :presence => true
|
26
|
+
|
27
|
+
error_map :email_address => :email
|
28
|
+
|
29
|
+
attr_reader :perform_called
|
30
|
+
attr_reader :perform_finished
|
31
|
+
|
32
|
+
attr_reader :created_user
|
33
|
+
|
34
|
+
protected
|
35
|
+
|
36
|
+
def perform
|
37
|
+
@perform_called = true
|
38
|
+
u = build_user
|
39
|
+
|
40
|
+
unless u.valid?
|
41
|
+
inherit_errors_from(u)
|
42
|
+
return false
|
43
|
+
end
|
44
|
+
|
45
|
+
@perform_finished = true
|
46
|
+
@created_user = u
|
47
|
+
|
48
|
+
true
|
49
|
+
end
|
50
|
+
|
51
|
+
def build_user
|
52
|
+
u = user_class.new
|
53
|
+
u.email_address = email
|
54
|
+
u.password = password
|
55
|
+
u
|
56
|
+
end
|
57
|
+
|
58
|
+
def user_class
|
59
|
+
::User
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
class AdminSignupOp < ::SignupOp
|
64
|
+
|
65
|
+
field :priveleges, :default => 'min'
|
66
|
+
|
67
|
+
protected
|
68
|
+
|
69
|
+
def user_class
|
70
|
+
::AdminUser
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
class BusinessSignupOp < ::Subroutine::Op
|
76
|
+
|
77
|
+
field :business_name
|
78
|
+
inputs_from ::SignupOp
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
class DefaultsOp < ::Subroutine::Op
|
83
|
+
|
84
|
+
field :foo, :default => 'foo'
|
85
|
+
|
86
|
+
field baz: 'baz'
|
87
|
+
|
88
|
+
field :bar
|
89
|
+
default :bar => 'bar'
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
class InheritedDefaultsOp < ::DefaultsOp
|
94
|
+
|
95
|
+
default :bar => 'barstool'
|
96
|
+
|
97
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'subroutine'
|
2
|
+
require 'minitest/autorun'
|
3
|
+
require 'minitest/unit'
|
4
|
+
|
5
|
+
require 'minitest/reporters'
|
6
|
+
|
7
|
+
Minitest::Reporters.use!([Minitest::Reporters::DefaultReporter.new])
|
8
|
+
|
9
|
+
class TestCase < (MiniTest::Unit::TestCase rescue ::MiniTest::Test); end
|
10
|
+
|
11
|
+
require_relative 'support/ops'
|
12
|
+
|
13
|
+
|
metadata
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: subroutine
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mike Nelson
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-04-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activemodel
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 4.0.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 4.0.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.7'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.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: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: minitest-reporters
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: An interface for creating feature-driven operations.
|
84
|
+
email:
|
85
|
+
- mike@mnelson.io
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- ".gitignore"
|
91
|
+
- ".travis.yml"
|
92
|
+
- Gemfile
|
93
|
+
- LICENSE.txt
|
94
|
+
- README.md
|
95
|
+
- Rakefile
|
96
|
+
- gemfiles/am30.gemfile
|
97
|
+
- gemfiles/am31.gemfile
|
98
|
+
- gemfiles/am32.gemfile
|
99
|
+
- gemfiles/am40.gemfile
|
100
|
+
- gemfiles/am41.gemfile
|
101
|
+
- gemfiles/am42.gemfile
|
102
|
+
- lib/subroutine.rb
|
103
|
+
- lib/subroutine/version.rb
|
104
|
+
- subroutine.gemspec
|
105
|
+
- test/subroutine/base_test.rb
|
106
|
+
- test/support/ops.rb
|
107
|
+
- test/test_helper.rb
|
108
|
+
homepage: https://github.com/mnelson/subroutine
|
109
|
+
licenses:
|
110
|
+
- MIT
|
111
|
+
metadata: {}
|
112
|
+
post_install_message:
|
113
|
+
rdoc_options: []
|
114
|
+
require_paths:
|
115
|
+
- lib
|
116
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
requirements: []
|
127
|
+
rubyforge_project:
|
128
|
+
rubygems_version: 2.4.6
|
129
|
+
signing_key:
|
130
|
+
specification_version: 4
|
131
|
+
summary: Feature-driven operation objects.
|
132
|
+
test_files:
|
133
|
+
- gemfiles/am30.gemfile
|
134
|
+
- gemfiles/am31.gemfile
|
135
|
+
- gemfiles/am32.gemfile
|
136
|
+
- gemfiles/am40.gemfile
|
137
|
+
- gemfiles/am41.gemfile
|
138
|
+
- gemfiles/am42.gemfile
|
139
|
+
- test/subroutine/base_test.rb
|
140
|
+
- test/support/ops.rb
|
141
|
+
- test/test_helper.rb
|