diff --git a/.dockerignore b/.dockerignore index cc86e3f0..6e57e57f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,4 @@ !script/build/debian/*.sh !Rakefile !config/* -!db/* \ No newline at end of file +!gems/* \ No newline at end of file diff --git a/Gemfile b/Gemfile index cd9f4edb..04cefe63 100755 --- a/Gemfile +++ b/Gemfile @@ -47,8 +47,7 @@ gem 'dalli' gem 'memcachier' -gem 'param_validation', git: 'https://github.com/commitchange/ruby-param-validation.git' -#gem 'param_validation', path: '../ruby-param-validation' +gem 'param_validation', path: 'gems/ruby-param-validation' # Print colorized text lol gem 'colorize' diff --git a/Gemfile.lock b/Gemfile.lock index 3d0c1352..f64bf4ee 100755 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,3 @@ -GIT - remote: https://github.com/commitchange/ruby-param-validation.git - revision: 4269cdef83eb95eea749f05c22a9b747b8f1f256 - specs: - param_validation (0.0.2) - chronic - GIT remote: https://github.com/commitchange/ruby-qx.git revision: 3c7fbb9c844e3ea86c9faea204058aa76e5ea35d @@ -41,6 +34,12 @@ GIT activesupport (>= 3.0.0) multi_json (>= 1.3.2) +PATH + remote: gems/ruby-param-validation + specs: + param_validation (0.0.2) + chronic + GEM remote: https://rubygems.org/ specs: diff --git a/docker/build/Dockerfile b/docker/build/Dockerfile index c1d27269..c398038f 100644 --- a/docker/build/Dockerfile +++ b/docker/build/Dockerfile @@ -9,6 +9,7 @@ COPY script/build/debian/postgres.sh myapp/script/build/debian/postgres.sh RUN myapp/script/build/debian/postgres.sh COPY script/build/debian/java.sh myapp/script/build/debian/java.sh RUN myapp/script/build/debian/java.sh +COPY gems /myapp/gems/ WORKDIR /myapp COPY Gemfile /myapp/Gemfile COPY Gemfile.lock /myapp/Gemfile.lock diff --git a/docker/debug/Dockerfile b/docker/debug/Dockerfile index 5c8d5468..63f7a0bb 100644 --- a/docker/debug/Dockerfile +++ b/docker/debug/Dockerfile @@ -9,6 +9,7 @@ COPY script/build/debian/postgres.sh myapp/script/build/debian/postgres.sh RUN myapp/script/build/debian/postgres.sh COPY script/build/debian/java.sh myapp/script/build/debian/java.sh RUN myapp/script/build/debian/java.sh +COPY gems /myapp/gems/ WORKDIR /myapp RUN groupadd -r -g 1000 $USER RUN useradd -r -m -g $USER -u 1000 $USER diff --git a/gems/ruby-param-validation/LICENSE b/gems/ruby-param-validation/LICENSE new file mode 100644 index 00000000..54c81cba --- /dev/null +++ b/gems/ruby-param-validation/LICENSE @@ -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. \ No newline at end of file diff --git a/gems/ruby-param-validation/README.md b/gems/ruby-param-validation/README.md new file mode 100644 index 00000000..bd2bb41c --- /dev/null +++ b/gems/ruby-param-validation/README.md @@ -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! diff --git a/gems/ruby-param-validation/Rakefile b/gems/ruby-param-validation/Rakefile new file mode 100644 index 00000000..1f33d053 --- /dev/null +++ b/gems/ruby-param-validation/Rakefile @@ -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 \ No newline at end of file diff --git a/gems/ruby-param-validation/lib/param_validation.rb b/gems/ruby-param-validation/lib/param_validation.rb new file mode 100644 index 00000000..131ffc31 --- /dev/null +++ b/gems/ruby-param-validation/lib/param_validation.rb @@ -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] 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 + diff --git a/gems/ruby-param-validation/param_validation.gemspec b/gems/ruby-param-validation/param_validation.gemspec new file mode 100644 index 00000000..56d8931f --- /dev/null +++ b/gems/ruby-param-validation/param_validation.gemspec @@ -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 diff --git a/gems/ruby-param-validation/test/param_validation_test.rb b/gems/ruby-param-validation/test/param_validation_test.rb new file mode 100644 index 00000000..a58262ef --- /dev/null +++ b/gems/ruby-param-validation/test/param_validation_test.rb @@ -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