Merge pull request #134 from houdiniproject/ruby_param_validation

Add ruby-param-validation to repo
This commit is contained in:
Eric Schultz 2019-01-16 11:45:32 -06:00 committed by GitHub
commit e695c7cbcf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 559 additions and 9 deletions

View file

@ -47,8 +47,7 @@ gem 'dalli'
gem 'memcachier' gem 'memcachier'
gem 'param_validation', git: 'https://github.com/commitchange/ruby-param-validation.git' gem 'param_validation', path: 'gems/ruby-param-validation'
#gem 'param_validation', path: '../ruby-param-validation'
# Print colorized text lol # Print colorized text lol
gem 'colorize' gem 'colorize'

View file

@ -1,10 +1,3 @@
GIT
remote: https://github.com/commitchange/ruby-param-validation.git
revision: 4269cdef83eb95eea749f05c22a9b747b8f1f256
specs:
param_validation (0.0.2)
chronic
GIT GIT
remote: https://github.com/commitchange/stripe-ruby-mock.git remote: https://github.com/commitchange/stripe-ruby-mock.git
revision: ee4471a8f654672d5596218c2b68a2913ea3f4cc revision: ee4471a8f654672d5596218c2b68a2913ea3f4cc
@ -33,6 +26,12 @@ GIT
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
multi_json (>= 1.3.2) multi_json (>= 1.3.2)
PATH
remote: gems/ruby-param-validation
specs:
param_validation (0.0.2)
chronic
PATH PATH
remote: gems/ruby-qx remote: gems/ruby-qx
specs: specs:
@ -40,6 +39,7 @@ PATH
activerecord (>= 3.0) activerecord (>= 3.0)
colorize (~> 0.8) colorize (~> 0.8)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 Jay R Bolton, CommitChange
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,162 @@
# ParamValidation
A standalone and simple ruby hash validation lib, useful for validating the data passed to your functions.
```rb
new_charge_validation = {
amount: {
required: true
},
stripe_card_token: {
required: true,
format: /^tok_.*$/
}
}
def update_database(data)
ParamValidation.new(data, new_charge_validation)
# do stuff
return result
end
```
The above checks the `:amount` and `:stripe_card_token` keys inside the `data`
hash and runs validations on them. If a value is invalid, an exception is
thrown. The exception can be handled outside your function call, typically in a
controller/router or in a test suite.
An exception is thrown for each failure.
## ParamValidation::Error
The ParamValidation::Error object has the following information on it:
```rb
begin
update_database(data)
rescue ParamValidation::Error => e
e.message # string validation failure message
e.val # value that failed validation
e.key # key name of the above value inside the data hash
e.name # name of the validator that failed
rescue Exception => e
# a non-validation exception
end
```
## Using in Rails
To handle validation exceptions from the controller, you can add a custom helper function in your ApplicationController like this:
```rb
def render_json(&block)
begin
result = yield block
rescue ParamValidation::Error => e
return {status: 422, json: {error: e.message, key: e.key}}
rescue Error => e # a non-validation related exception
return {status: 500, json: e}
end
return {status: 200, json: result}
end
```
With the above, you can simply call render_json on your validated function call:
```rb
UsersController < ApplicationController
def update
render_json{ update_user(params[:user]) }
end
end
```
```rb
update_validation = {
email: {
presence: :optional,
format: email_regex
}
}
def update_user(params)
ParamValidation.new(params, update_validation)
end
```
### built-in validators
- required: value must be non-nil
- absent: value must be nil
- not_included_in: value must not be in array
- included_in: value must be in array
- format: value must match regex
- is_integer: value must look like an integer (can be a string)
- is_float: value must look like a float (can be a string)
- min_length: array value must have length >= min
- max_length: array value must have length <= max
- length_range: array value must have length within given range
- length_equals: array value length must equal given arg
- equals: value must equal given arg
- min: value must be >= arg
- max: value must be <= arg
- in_range: value must be within the given range
### custom validators
ParamValidation.add_validator takes a name and a block. That block performs the
validations and simply returns true/false. The block takes three params: the
actual value to validate, and the argument provided in the validation hash, and
the entire data hash being validated.
```rb
# You can use other validators inside a new validator
ParamValidation.add_validator(:dollars) do |val, arg, data|
ParamValidation.validators[:format](val, /^\$\d+\.(\d\d)$?, data)
end
# # Validators that don't need an argument typically just get 'true' passed in
# ParamValidation.new(params, {
# amount: { dollars: true }
# })
# Other examples
# Uniqueness validation (of course it may be better to use the UNIQUE constraint in sql)
ParamValidation.add_validator(:unique) do |val, arg, data|
Qx.select("COUNT(*)").from(:table).where(name: val).first['count'] == 0
end
```
### custom validation messages
Within a validation instance, you can set a custom message with the :message key.
```rb
ParamValidation.new(params, {
amount: {
dollars: true,
message: 'Please enter a dollar amount'
}
})
```
To change the global default message, simply use ParamValidation.set_message(:validation_name, &block).
```rb
ParamValidation.set_message(:dollars) do |h|
"#{h[:key]} must be in dollars"
end
```
The `set_message` block receives a hash with some data:
* :key - name of the key in the hash that failed this validation
* :arg - argument to validator (eg format regex)
* :val - actual value that failed validation
* :data - entire data hash that is being validated
#### internationalization -- TODO!
Internationalization support is not yet in place; please make a PR for it!

View file

@ -0,0 +1,9 @@
require 'rake/testtask'
Rake::TestTask.new do |t|
t.libs << 'test'
t.test_files = FileList['test/test*.rb', 'test/*test.rb']
end
desc "Run tests"
task :default => :test

View file

@ -0,0 +1,130 @@
require 'json'
require 'chronic'
class ParamValidation
# Given a hash of data and a validation hash, check all the validations, raising an Error on the first invalid key
# @raise [ValidationError] if one or more of the validations fail.
def initialize(data, validations)
errors = []
validations.each do |key, validators|
val = key === :root ? data : (data[key] || data[key.to_s] || data[key.to_sym])
next if validators[:required].nil? && val.nil?
validators.each do |name, arg|
validator = @@validators[name]
msg = validations[key][:message]
next unless validator
is_valid = @@validators[name].call(val, arg, data)
msg_proc = @@messages[name]
msg ||= @@messages[name].call({key: key, data: data, val: val, arg: arg}) if msg_proc
errors.push({msg: msg, data: {key: key, val: val, name: name, msg: msg}}) unless is_valid
end
end
if errors.length == 1
raise ValidationError.new(errors[0][:msg], errors[0][:data])
elsif errors.length > 1
msg = errors.collect {|e| e[:msg]}.join('\n')
raise ValidationError.new(msg, errors.collect{|e| e[:data]})
end
end
def self.messages; @@messages; end
def self.set_message(name, &block)
@@messages[name] = block
end
def self.validators; @@validators; end
def self.add_validator(name, &block)
@@validators[name] = block
end
def self.structure_validators; @@structure_validators; end
def self.add_structure_validator(name, &block)
@@structure_validators[name] = block
end
# In each Proc
# - val is the value we are actually validating from the data passed in
# - arg is the argument passed into the validator (eg for {required: true}, it is `true`)
# - data is the entire set of data
@@validators = {
required: lambda {|val, arg, data| !val.nil?},
absent: lambda {|val, arg, data| val.nil?},
not_blank: lambda {|val, arg, data| val.is_a?(String) && val.length > 0},
not_included_in: lambda {|val, arg, data| !arg.include?(val) rescue false},
included_in: lambda {|val, arg, data| arg.include?(val) rescue false},
format: lambda {|val, arg, data| val =~ arg rescue false},
is_integer: lambda {|val, arg, data| val.is_a?(Integer) || val =~ /\A[+-]?\d+\Z/},
is_float: lambda {|val, arg, data| val.is_a?(Float) || (!!Float(val) rescue false) },
min_length: lambda {|val, arg, data| val.length >= arg rescue false},
max_length: lambda {|val, arg, data| val.length <= arg rescue false},
length_range: lambda {|val, arg, data| arg.cover?(val.length) rescue false},
length_equals: lambda {|val, arg, data| val.length == arg},
is_reference: lambda{|val, arg, data| (val.is_a?(Integer)&& val >=0) || val =~ /\A\d+\Z/ || val == ''},
equals: lambda {|val, arg, data| val == arg},
min: lambda {|val, arg, data| val >= arg rescue false},
max: lambda {|val, arg, data| val <= arg rescue false},
is_array: lambda {|val, arg, data| val.is_a?(Array)},
is_hash: lambda {|val, arg, data| val.is_a?(Hash)},
is_json: lambda {|val, arg, data| ParamValidation.is_valid_json?(val)},
in_range: lambda {|val, arg, data| arg.cover?(val) rescue false},
is_a: lambda {|val, arg, data| arg.kind_of?(Enumerable) ? arg.any? {|i| val.is_a?(i)} : val.is_a?(arg)},
can_be_date: lambda {|val, arg, data| val.is_a?(Date) || val.is_a?(DateTime) || Chronic.parse(val)},
array_of_hashes: lambda {|val, arg, data| data.is_a?(Array) && data.map{|pair| ParamValidation.new(pair.to_h, arg)}.all?}
}
@@messages = {
required: lambda {|h| "#{h[:key]} is required"},
absent: lambda {|h| "#{h[:key]} must not be present"},
not_blank: lambda {|h| "#{h[:key]} must not be blank"},
not_included_in: lambda {|h| "#{h[:key]} must not be included in #{h[:arg].join(", ")}"},
included_in: lambda {|h|"#{h[:key]} must be one of #{h[:arg].join(", ")}"},
format: lambda {|h|"#{h[:key]} doesn't have the right format"},
is_integer: lambda {|h|"#{h[:key]} should be an integer"},
is_float: lambda {|h|"#{h[:key]} should be a float"},
min_length: lambda {|h|"#{h[:key]} has a minimum length of #{h[:arg]}"},
max_length: lambda {|h|"#{h[:key]} has a maximum length of #{h[:arg]}"},
length_range: lambda {|h|"#{h[:key]} should have a length within #{h[:arg]}"},
length_equals: lambda {|h|"#{h[:key]} should have a length of #{h[:arg]}"},
is_reference: lambda{|h| "#{h[:key]} should be an integer or blank"},
equals: lambda {|h|"#{h[:key]} should equal #{h[:arg]}"},
min: lambda {|h|"#{h[:key]} must be at least #{h[:arg]}"},
max: lambda {|h|"#{h[:key]} cannot be more than #{h[:arg]}"},
in_range: lambda {|h|"#{h[:key]} should be within #{h[:arg]}"},
is_json: lambda {|h| "#{h[:key]} should be valid JSON"},
is_hash: lambda {|h| "#{h[:key]} should be a hash"},
is_a: lambda {|h| "#{h[:key]} should be of the type(s): #{h[:arg].kind_of?(Enumerable) ? h[:arg].join(', '): h[:arg]}"},
can_be_date: lambda {|h| "#{h[:key]} should be a datetime or be parsable as one"},
array_of_hashes: lambda {|h| "Please pass in an array of hashes"}
}
# small utility for testing json validity
def self.is_valid_json?(str)
begin
JSON.parse(str)
return true
rescue => e
return false
end
end
# Special error class that holds all the error data for reference
class ValidationError < TypeError
attr_reader :data
# @param [String] msg message for the validation error(s). Multiple error
# messages are split by new lines (\n)
# @param [Hash, Array<Hash>] data information about the validation failure
# or failures. If one failure, a single failure hash is returned, if multiple, an array is returned.
# Each failure hash has the following:
# * :key - the [Symbol] of the key in the hash where verification failed
# * :val - the value of pair in the hash selected by :key
# * :name - the [Symbol] for the verification which failed
# * :msg - the [String] for the msg for the verifications which failed
def initialize(msg, data)
@data = data
super(msg)
end
end
end

View file

@ -0,0 +1,14 @@
Gem::Specification.new do |s|
s.name = 'param_validation'
s.version = '0.0.2'
s.date = '2017-07-21'
s.summary = 'Simple hash validator'
s.description = 'A hash validator that throws exceptions, with a lot of customization options'
s.authors = ['Jay R Bolton']
s.email = 'jayrbolton@gmail.com'
s.files = 'lib/param_validation.rb'
s.homepage = 'https://github.com/jayrbolton/ruby-param-validation'
s.license = 'MIT'
s.add_runtime_dependency 'chronic'
end

View file

@ -0,0 +1,215 @@
require './lib/param_validation.rb'
require 'minitest/autorun'
class ParamValidationTest < Minitest::Test
def setup
end
def test_required
begin; ParamValidation.new({}, {x: {required: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
# If a key is not required, then don't run the tests on it
def test_not_required_and_absent_then_tests_do_not_run
ParamValidation.new({}, {x: {max: 100}})
assert true
end
def test_not_blank_fail
begin; ParamValidation.new({x: ''}, {x: {not_blank: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_not_blank_fail_nil
begin; ParamValidation.new({x: nil}, {x: {not_blank: true, required: true}})
rescue ParamValidation::ValidationError => e; e; end
assert(e.data.one?{|i| i[:name] == :not_blank && i[:key] == :x})
assert(e.data.one?{|i| i[:name] == :required && i[:key] == :x})
end
def test_not_blank_succeed
ParamValidation.new({x: 'x'}, {x: {not_blank: true}})
assert true
end
def test_require_no_err
begin; ParamValidation.new({x: 1}, {x: {required: true}})
rescue ParamValidation::ValidationError => e; end
assert e.nil?
end
def test_absent
begin; ParamValidation.new({x: 1}, {x: {absent: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_not_included_in
begin; ParamValidation.new({x: 1}, {x: {not_included_in: [1]}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_included_in
begin; ParamValidation.new({x: 1}, {x: {included_in: [2]}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_format
begin; ParamValidation.new({x: 'x'}, {x: {format: /y/}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_is_reference_string
begin; ParamValidation.new({x: '-0'}, {x: {is_reference: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_is_reference_negative_integer
begin; ParamValidation.new({x: -1}, {x: {is_reference: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_is_reference_passes
ParamValidation.new({x: '0'}, {x: {is_reference: true}})
ParamValidation.new({x: 1}, {x: {is_reference: true}})
ParamValidation.new({x: ''}, {x: {is_reference: true}})
pass()
end
def test_is_integer
begin; ParamValidation.new({x: 'x'}, {x: {is_integer: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_is_float
begin; ParamValidation.new({x: 'x'}, {x: {is_float: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_min_length
begin; ParamValidation.new({x: []}, {x: {min_length: 2}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal :x, e.data[:key]
end
def test_max_length
begin; ParamValidation.new({x: [1,2,3]}, {x: {max_length: 2}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal e.data[:key], :x
end
def test_length_range
begin; ParamValidation.new({x: [1,2,3,4]}, {x: {length_range: 1..3}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal e.data[:key], :x
end
def test_length_equals
begin; ParamValidation.new({x: [1,2]}, {x: {length_equals: 1}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal e.data[:key], :x
end
def test_min
begin; ParamValidation.new({x: 1}, {x: {min: 2}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal e.data[:key], :x
end
def test_max
begin; ParamValidation.new({x: 4}, {x: {max: 2}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal e.data[:name], :max
end
def test_in_range
begin; ParamValidation.new({x: 1}, {x: {in_range: 2..4}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal e.data[:val], 1
end
def test_equals
begin; ParamValidation.new({x: 1}, {x: {equals: 2}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal "x should equal #{2}", e.to_s
end
def test_root_array_of_hashes
begin; ParamValidation.new({x: 1}, {root: {array_of_hashes: {x: {required: true}}}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal "Please pass in an array of hashes", e.to_s
end
def test_root_array_of_hashes_with_nesting_ok
v = ParamValidation.new([{'x' => 1}, {x: 1}], {root: {array_of_hashes: {x: {is_integer: true}}}})
assert_equal v, v # test that it does not raise
end
def test_root_array_of_hashes_with_nesting
begin; ParamValidation.new([{x: 1}, {x: 'hi'}], {root: {array_of_hashes: {x: {is_integer: true}}}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal "x should be an integer", e.to_s
end
def test_is_json_with_string
begin; ParamValidation.new({x: '[[[[[[['}, {x: {is_json: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal "x should be valid JSON", e.to_s
end
def test_is_json_without_string
begin; ParamValidation.new({x: {}}, {x: {is_json: true}})
rescue ParamValidation::ValidationError => e; e; end
assert_equal "x should be valid JSON", e.to_s
end
def test_is_a_single
ParamValidation.new({x: 5.6}, {x: {is_a: Float}})
begin
ParamValidation.new({x: 5.6}, {x: {is_a: Integer}})
rescue ParamValidation::ValidationError => e
e
end
assert_equal 'x should be of the type(s): Integer', e.to_s
end
def test_is_a_multiple
ParamValidation.new({x: 5.6}, {x: {is_a: [Integer,Float]}})
begin
ParamValidation.new({x: 5.6}, {x: {is_a: [Integer, Array]}})
rescue ParamValidation::ValidationError => e
e
end
assert_equal 'x should be of the type(s): Integer, Array', e.to_s
end
def test_can_be_date
ParamValidation.new({x: Date.new()}, {x: {can_be_date: true}})
ParamValidation.new({x: DateTime.new()}, {x: {can_be_date: true}})
ParamValidation.new({x: '2017-05-15T12:00:00.000Z'}, {x: {can_be_date: true}})
ParamValidation.new({x: '2017-05-15'}, {x: {can_be_date: true}})
begin
ParamValidation.new({x: 'not_a _date'}, {x: {can_be_date: true}})
rescue ParamValidation::ValidationError => e
e
end
assert_equal 'x should be a datetime or be parsable as one', e.to_s
end
def test_add_validator
ParamValidation.add_validator(:dollars){|val, arg, data| val =~ /^\d+(\.\d\d)?$/}
begin
ParamValidation.new({x: 'hi'}, {x: {dollars: true}})
rescue ParamValidation::ValidationError => e
e
end
assert_equal :dollars, e.data[:name]
end
def test_set_message
ParamValidation.add_validator(:dollars){|val, arg, data| val =~ /^\d+(\.\d\d)?$/}
ParamValidation.set_message(:dollars){|h| "#{h[:key]} must be a dollar amount"}
begin
ParamValidation.new({x: 'hi'}, {x: {dollars: true}})
rescue ParamValidation::ValidationError => e
e
end
assert_equal "x must be a dollar amount", e.to_s
end
def test_custom_validator
end
end