subroutine 0.0.1

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
+ 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