mutations 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ .rvmrc
2
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gem 'minitest', '~> 4.0'
4
+ gem 'activesupport'
data/Gemfile.lock ADDED
@@ -0,0 +1,16 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ activesupport (3.2.8)
5
+ i18n (~> 0.6)
6
+ multi_json (~> 1.0)
7
+ i18n (0.6.1)
8
+ minitest (4.1.0)
9
+ multi_json (1.3.6)
10
+
11
+ PLATFORMS
12
+ ruby
13
+
14
+ DEPENDENCIES
15
+ activesupport
16
+ minitest (~> 4.0)
data/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # Mutations
2
+
3
+ Compose your business logic into commands that sanitize and validate input. Write safe, reusable, and maintainable code for Ruby and Rails apps.
4
+
5
+ ## Installation
6
+
7
+ gem install mutations
8
+
9
+ Or add it to your Gemfile:
10
+
11
+ gem 'mutations'
12
+
13
+ ## Example
14
+
15
+ ```ruby
16
+ # Define a command that signs up a user.
17
+ class UserSignup < Mutations::Command
18
+
19
+ # These inputs are required
20
+ required do
21
+ string :email, matches: EMAIL_REGEX
22
+ string :name
23
+ end
24
+
25
+ # These inputs are optional
26
+ optional do
27
+ boolean :newsletter_subscribe
28
+ end
29
+
30
+ # The execute method is called only if the inputs validate. It does your business action.
31
+ def execute
32
+ user = User.create!(inputs)
33
+ NewsletterSubscriptions.create(email: email, user_id: user.id) if newsletter_subscribe
34
+ UserMailer.async(:deliver_welcome, user.id)
35
+ user
36
+ end
37
+ end
38
+
39
+ # In a controller action (for instance), you can run it:
40
+ def create
41
+ outcome = UserSignup.run(params[:user])
42
+
43
+ # Then check to see if it worked:
44
+ if outcome.success?
45
+ render json: {message: "Great success, #{outcome.result.name}!"}
46
+ else
47
+ render json: outcome.errors.symbolic
48
+ end
49
+ end
50
+ ```
51
+
52
+ Some things to note about the example:
53
+
54
+ * We don't need attr_accessible or strong_attributes to protect against mass assignment attacks
55
+ * We're guaranteed that within execute, the inputs will be the correct data types, even if they needed some coercion (all strings are stripped by default, and strings like "1" / "0" are converted to true/false for newsletter_subscribe)
56
+ * We don't need ActiveRecord/ActiveModel validations
57
+ * We don't need Callbacks on our models -- everything is in the execute method (helper methods are also encouraged).
58
+ * We don't use accepts_nested_attributes_for, even though multiple AR models are created.
59
+ * This code is completely re-usable in other contexts (need an API?)
60
+ * The inputs to this 'function' are documented by default -- the bare minimum to use it (name and email) are documented, as are 'extras' (newsletter_subscribe).
61
+
62
+ ## Why is it called 'mutations'?
63
+
64
+ Imagine you had a folder in your Rails project:
65
+
66
+ app/mutations
67
+
68
+ And inside, you had a library of business operations that you can do against your datastore:
69
+
70
+ app/mutations/users/signup.rb
71
+ app/mutations/users/login.rb
72
+ app/mutations/users/update_profile.rb
73
+ app/mutations/users/change_password.rb
74
+ ...
75
+ app/mutations/articles/create.rb
76
+ app/mutations/articles/update.rb
77
+ app/mutations/articles/publish.rb
78
+ app/mutations/articles/comment.rb
79
+ ...
80
+ app/mutations/ideas/upsert.rb
81
+ ...
82
+
83
+ Each of these _mutations_ takes your application from one state to the next.
84
+
85
+ That being said, you can easily use the input validation/specification capabilities for things that don't mutate your database.
86
+
87
+ ## How do I call mutations?
88
+
89
+ You have two choices. Given a mutation UserSignup, you can do this:
90
+
91
+ ```ruby
92
+ outcome = UserSignup.run(params)
93
+ if outcome.success?
94
+ user = outcome.result
95
+ else
96
+ render outcome.errors
97
+ end
98
+ ```
99
+
100
+ Or, you can do this:
101
+
102
+ ```ruby
103
+ user = UserSignup.run!(params) # returns the result of execute, or raises Mutations::ValidationException
104
+ ```
105
+
106
+ ## What can I pass to mutations?
107
+
108
+ Mutations only accept hashes as arguments to #run and #run!
109
+
110
+ That being said, you can pass multiple hashes to run, and they are merged together. Later hashes take precedence. This give you safety in situations where you want to pass unsafe user inputs and safe server inputs into a single mutation. For instance:
111
+
112
+ ```ruby
113
+ # A user comments on an article
114
+ class CreateComment < Mutations::Command
115
+ requried do
116
+ model :user
117
+ model :article
118
+ string :comment, max_length: 500
119
+ end
120
+
121
+ def execute; ...; end
122
+ end
123
+
124
+ def somewhere
125
+ outcome = CreateComment.run(params[:comment],
126
+ user: current_user,
127
+ article: Article.find(params[:article_id])
128
+ )
129
+ end
130
+ ```
131
+
132
+ Here, we pass two hashes to CreateComment. Even if the params[:comment] hash has a user or article field, they're overwritten by the second hash. (Also note: even if they weren't, they couldn't be of the correct data type in this particular case.)
133
+
134
+ ## How do I define mutations?
135
+
136
+ 1. Subclass Mutations::Command
137
+
138
+ ```ruby
139
+ class YourMutation < Mutatons::Command
140
+ # ...
141
+ end
142
+ ```
143
+
144
+ 2. Define your required inputs and their validations:
145
+
146
+ ```ruby
147
+ required do
148
+ string :name, max_length: 10
149
+ string :state, in: %w(AL AK AR ... WY)
150
+ integer :age
151
+ boolean :is_special, default: true
152
+ model :account
153
+ end
154
+ ```
155
+
156
+ 3. Define your optional inputs and their validations:
157
+
158
+ ```ruby
159
+ optional do
160
+ array :tags, class: String
161
+ hash :prefs do
162
+ boolean :smoking
163
+ boolean :view
164
+ end
165
+ end
166
+ ```
167
+
168
+ 4. Define your execute method. It can return a value:
169
+
170
+ ```ruby
171
+ def execute
172
+ record = do_thing(inputs)
173
+ # ...
174
+ record
175
+ end
176
+ ```
177
+
178
+ See a full list of options here: TODO
179
+
180
+ ## How do I write an execute method?
181
+
182
+ Your execute method has access to the inputs passed into it:
183
+
184
+ ```ruby
185
+ self.inputs # white-listed hash of all inputs passed to run. Hash has indifferent access.
186
+ ```
187
+
188
+ If you define an input called _email_, then you'll have these three methods:
189
+
190
+ ```ruby
191
+ self.email # Email value passed in
192
+ self.email=(val) # You can set the email value in execute. Rare, but useful at times.
193
+ self.email_present? # Was an email value passed in? Useful for optional inputs.
194
+ ```
195
+
196
+ You can do extra validation inside of execute:
197
+
198
+ ```ruby
199
+ if email =~ /aol.com/
200
+ add_error(:email, :old_school, "Wow, you still use AOL?")
201
+ return
202
+ end
203
+ ```
204
+
205
+ You can return a value as the result of the command:
206
+
207
+ ```ruby
208
+ def execute
209
+ # ...
210
+ "WIN!"
211
+ end
212
+
213
+ # Get result:
214
+ outcome = YourMutuation.run(...)
215
+ outcome.result # => "WIN!"
216
+ ```
217
+
218
+ ## What about validation errors?
219
+
220
+ If things don't pan out, you'll get back an Mutations::ErrorHash object that maps invalid inputs to either symbols or messages. Example:
221
+
222
+ ```ruby
223
+ # Didn't pass required field 'email', and newsletter_subscribe is the wrong format:
224
+ outcome = UserSignup.run(name: "Bob", newsletter_subscribe: "Wat")
225
+
226
+ unless outcome.success?
227
+ outcome.errors.symbolic # => {email: :required, newsletter_subscribe: :boolean}
228
+ outcome.errors.message # => {email: "Email is required", newsletter_subscribe: "Newsletter Subscription isn't a boolean"}
229
+ outcome.errors.message_list # => ["Email is required", "Newsletter Subscription isn't a boolean"]
230
+ end
231
+ ```
232
+
233
+ You can add errors within execute if the default validations are insufficient:
234
+
235
+ ```ruby
236
+ #...
237
+ def execute
238
+ if password != password_confirmation
239
+ add_error(:password_confirmation, :doesnt_match, "Your passwords don't match")
240
+ return
241
+ end
242
+ end
243
+ # ...
244
+
245
+ # That error would show up in the errors hash:
246
+ outcome.errors.symbolic # => {password_confirmation: :doesnt_match}
247
+ outcome.errors.message # => {password_confirmation: "Your passwords don't match"}
248
+ ```
249
+
250
+ If you want to tie the validation messages into your I18n system, you'll need to write a custom error message generator. TODO: See docs.
251
+
252
+ ## FAQs
253
+
254
+ ### Is this better than the 'Rails Way'?
255
+
256
+ Rails comes with an awesome default stack, and a lot of standard practices that folks use are very reasonable (eg, thin controllers, fat models).
257
+
258
+ That being said, there's a whole slew of patterns that are available to experienced developers. As your Rails app grows in size and complexity, my experience has been that some of these patterns can help your app immensely.
259
+
260
+ ### How do I share code between mutations?
261
+
262
+ Write some modules that you include into multiple mutations.
263
+
264
+ ### Can I subclass my mutations?
265
+
266
+ Yes, but I don't think it's a very good idea. Better to compose.
267
+
268
+ ### Can I use this with Rails forms helpers?
269
+
270
+ Somewhat. This works great with any forms, but there's no built-in way to bake the errors into the HTML with Rails form tag helpers. Right now this is really designed to support a JSON API. You'd probably have to write an adapter of some kind.
271
+
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'rake/testtask'
2
+ Rake::TestTask.new(:test) do |test|
3
+ test.libs << 'spec'
4
+ # test.warning = true # Wow that outputs a lot of shit
5
+ test.pattern = 'spec/**/*_spec.rb'
6
+ end
7
+
8
+ task :default => :test
data/lib/mutations.rb ADDED
@@ -0,0 +1,24 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+ require 'active_support/core_ext/string/inflections'
4
+
5
+ require 'mutations/version'
6
+ require 'mutations/exception'
7
+ require 'mutations/errors'
8
+ require 'mutations/input_filter'
9
+ require 'mutations/string_filter'
10
+ require 'mutations/integer_filter'
11
+ require 'mutations/boolean_filter'
12
+ require 'mutations/model_filter'
13
+ require 'mutations/array_filter'
14
+ require 'mutations/hash_filter'
15
+ require 'mutations/outcome'
16
+ require 'mutations/command'
17
+
18
+ module Mutations
19
+ class << self
20
+ def error_message_creator
21
+ @error_message_creator ||= DefaultErrorMessageCreator.new
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,102 @@
1
+ module Mutations
2
+ class ArrayFilter < InputFilter
3
+ @default_options = {
4
+ nils: false, # true allows an explicit nil to be valid. Overrides any other options
5
+ class: nil, # A constant or string indicates that each element of the array needs to be one of these classes
6
+ arrayize: false # true will convert "hi" to ["hi"]. "" converts to []
7
+ }
8
+
9
+ def initialize(name, opts = {}, &block)
10
+ super(opts)
11
+
12
+ @name = name
13
+ @element_filter = nil
14
+
15
+ if block_given?
16
+ instance_eval &block
17
+ end
18
+
19
+ raise ArgumentError.new("Can't supply both a class and a filter") if @element_filter && self.options[:class]
20
+ end
21
+
22
+ def string(options = {})
23
+ @element_filter = StringFilter.new(options)
24
+ end
25
+
26
+ def integer(options = {})
27
+ @element_filter = IntegerFilter.new(options)
28
+ end
29
+
30
+ def boolean(options = {})
31
+ @element_filter = BooleanFilter.new(options)
32
+ end
33
+
34
+ def hash(options = {}, &block)
35
+ @element_filter = HashFilter.new(options, &block)
36
+ end
37
+
38
+ # Advanced types
39
+ def model(name, options = {})
40
+ @element_filter = ModelFilter.new(name.to_sym, options)
41
+ end
42
+
43
+ def array(options = {}, &block)
44
+ @element_filter = ArrayFilter.new(nil, options, &block)
45
+ end
46
+
47
+ def filter(data)
48
+ # Handle nil case
49
+ if data.nil?
50
+ return [nil, nil] if options[:nils]
51
+ return [nil, :nils]
52
+ end
53
+
54
+ if !data.is_a?(Array) && options[:arrayize]
55
+ return [[], nil] if data == ""
56
+ data = Array(data)
57
+ end
58
+
59
+ if data.is_a?(Array)
60
+ errors = ErrorArray.new
61
+ filtered_data = []
62
+ found_error = false
63
+ data.each_with_index do |el, i|
64
+ el_filtered, el_error = filter_element(el)
65
+ el_error = ErrorAtom.new(@name, el_error, index: i) if el_error.is_a?(Symbol)
66
+
67
+ errors << el_error
68
+ found_error = true if el_error
69
+ if !found_error
70
+ filtered_data << el_filtered
71
+ end
72
+ end
73
+
74
+ if found_error
75
+ [data, errors]
76
+ else
77
+ [filtered_data, nil]
78
+ end
79
+ else
80
+ return [data, :array]
81
+ end
82
+ end
83
+
84
+ # Returns [filtered, errors]
85
+ def filter_element(data)
86
+
87
+ if @element_filter
88
+ data, el_errors = @element_filter.filter(data)
89
+ return [data, el_errors] if el_errors
90
+ elsif options[:class]
91
+ class_const = options[:class]
92
+ class_const = class_const.constantize if class_const.is_a?(String)
93
+
94
+ if !data.is_a?(class_const)
95
+ return [data, :class]
96
+ end
97
+ end
98
+
99
+ [data, nil]
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,33 @@
1
+ module Mutations
2
+ class BooleanFilter < InputFilter
3
+ @default_options = {
4
+ nils: false # true allows an explicit nil to be valid. Overrides any other options
5
+ }
6
+
7
+ BOOL_MAP = {"true" => true, "1" => true, "false" => false, "0" => false}
8
+
9
+ def filter(data)
10
+
11
+ # Handle nil case
12
+ if data.nil?
13
+ return [nil, nil] if options[:nils]
14
+ return [nil, :nils]
15
+ end
16
+
17
+ # If data is true or false, we win.
18
+ return [data, nil] if data == true || data == false
19
+
20
+ # If data is a Fixnum, like 1, let's convert it to a string first
21
+ data = data.to_s if data.is_a?(Fixnum)
22
+
23
+ # If data's a string, try to convert it to a boolean. If we can't, it's invalid.
24
+ if data.is_a?(String)
25
+ res = BOOL_MAP[data.downcase]
26
+ return [res, nil] unless res.nil?
27
+ return [data, :boolean]
28
+ else
29
+ return [data, :boolean]
30
+ end
31
+ end
32
+ end
33
+ end