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 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
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
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
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in subroutine.gemspec
4
+ gemspec
5
+
6
+ gem 'activemodel', '~> 4.2.0'
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
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ t.pattern = "test/**/*_test.rb"
7
+ end
8
+
9
+ task :default => [:test]
10
+
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '../'
4
+
5
+ gem 'activemodel', '~> 3.0.20'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '../'
4
+
5
+ gem 'activemodel', '~> 3.1.12'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '../'
4
+
5
+ gem 'activemodel', '~> 3.2.21'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '../'
4
+
5
+ gem 'activemodel', '~> 4.0.13'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '../'
4
+
5
+ gem 'activemodel', '~> 4.1.9'
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec :path => '../'
4
+
5
+ gem 'activemodel', '~> 4.2.0'
@@ -0,0 +1,10 @@
1
+ module Subroutine
2
+
3
+ MAJOR = 0
4
+ MINOR = 0
5
+ PATCH = 1
6
+ PRE = nil
7
+
8
+ VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join('.')
9
+
10
+ end
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
@@ -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
@@ -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
@@ -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