Skip to content

Commit

Permalink
Merge pull request #18 from futuresimple/public-api-seekering
Browse files Browse the repository at this point in the history
Validation context and fallbacks
  • Loading branch information
iaintshine committed Jul 30, 2015
2 parents fe56867 + 409d460 commit bacee88
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 15 deletions.
2 changes: 1 addition & 1 deletion lib/input_sanitizer/v2/clean_field.rb
Expand Up @@ -3,7 +3,7 @@ def call
if has_key
convert
elsif default
converter.call(default)
converter.call(default, options)
elsif options[:required]
raise InputSanitizer::ValueMissingError
else
Expand Down
31 changes: 28 additions & 3 deletions lib/input_sanitizer/v2/payload_sanitizer.rb
@@ -1,4 +1,17 @@
class InputSanitizer::V2::PayloadSanitizer < InputSanitizer::Sanitizer
attr_reader :validation_context

def initialize(data, validation_context = {})
super data

self.validation_context = validation_context || {}
end

def validation_context=(context)
raise ArgumentError, "validation_context must be a Hash" unless context && context.is_a?(Hash)
@validation_context = context
end

def error_collection
@error_collection ||= InputSanitizer::V2::ErrorCollection.new(errors)
end
Expand All @@ -20,8 +33,8 @@ def self.nested(*keys)
sanitizer = options.delete(:sanitizer)
keys.push(options)
raise "You did not define a sanitizer for nested value" if sanitizer == nil
converter = lambda { |value, _|
instance = sanitizer.new(value)
converter = lambda { |value, converter_options|
instance = sanitizer.new(value, converter_options)
raise InputSanitizer::NestedError.new(instance.errors) unless instance.valid?
instance.cleaned
}
Expand All @@ -38,6 +51,18 @@ def perform_clean
@data.reject { |key, _| self.class.fields.keys.include?(key) }.each { |key, _| @errors << InputSanitizer::ExtraneousParamError.new("/#{key}") }
end

def prepare_options!(options)
return options if @validation_context.empty?
intersection = options.keys & @validation_context.keys
unless intersection.empty?
message = "validation context and converter options have the same keys: #{intersection}. " \
"In order to proceed please fix the configuration. " \
"In the meantime aborting ..."
raise RuntimeError, message
end
options.merge(@validation_context)
end

def clean_field(field, hash)
options = hash[:options].clone
collection = options.delete(:collection)
Expand All @@ -60,7 +85,7 @@ def clean_field(field, hash)
:collection => collection,
:type => sanitizer_type,
:converter => hash[:converter],
:options => options
:options => prepare_options!(options)
)
rescue InputSanitizer::OptionalValueOmitted
rescue InputSanitizer::ValidationError => error
Expand Down
17 changes: 12 additions & 5 deletions lib/input_sanitizer/v2/payload_transform.rb
@@ -1,13 +1,20 @@
class InputSanitizer::V2::PayloadTransform < MethodStruct.new(:original_payload)
class InputSanitizer::V2::PayloadTransform
attr_reader :original_payload, :context

def self.call(original_payload, context = {})
new(original_payload, context).call
end

def initialize(original_payload, context = {})
fail "#{self.class} is missing #transform method" unless respond_to?(:transform)
@original_payload, @context = original_payload, context
end

def call
transform
payload
end

def initialize(*args)
fail "#{self.class} is missing #transform method" unless respond_to?(:transform)
super
end

private
def rename(from, to)
Expand Down
5 changes: 3 additions & 2 deletions lib/input_sanitizer/v2/query_sanitizer.rb
Expand Up @@ -5,13 +5,14 @@ def self.converters
:string => InputSanitizer::V2::Types::StringCheck.new,
:boolean => InputSanitizer::V2::Types::CoercingBooleanCheck.new,
:datetime => InputSanitizer::V2::Types::DatetimeCheck.new,
:date => InputSanitizer::V2::Types::DatetimeCheck.new(:check_date => true),
:url => InputSanitizer::V2::Types::URLCheck.new,
}
end
initialize_types_dsl

def self.sort_by(allowed_values)
set_keys_to_converter([:sort_by, { :allow => allowed_values }], InputSanitizer::V2::Types::SortByCheck.new)
def self.sort_by(allowed_values, options = {})
set_keys_to_converter([:sort_by, { :allow => allowed_values }.merge(options)], InputSanitizer::V2::Types::SortByCheck.new)
end

# allow underscore cache buster by default
Expand Down
35 changes: 32 additions & 3 deletions lib/input_sanitizer/v2/types.rb
Expand Up @@ -127,17 +127,46 @@ def call(value, options = {})

class SortByCheck
def call(value, options = {})
key, direction = value.to_s.split(':', 2)
check_options!(options)

key, direction = split(value)
direction = 'asc' if direction.blank?

unless options[:allow].include?(key) && allowed_directions.include?(direction)
# special case when fallback takes care of separator sanitization e.g. custom fields
if options[:fallback] && !allowed_directions.include?(direction)
direction = 'asc'
key = value
end

unless valid?(key, direction, options)
raise InputSanitizer::ValueNotAllowedError.new(value)
end

[key, direction]
end

private
private
def valid?(key, direction, options)
allowed_keys = options[:allow]
fallback = options[:fallback]

allowed_directions.include?(direction) &&
((allowed_keys && allowed_keys.include?(key)) ||
(fallback && fallback.call(key, direction, options)))
end

def split(value)
head, _, tail = value.to_s.rpartition(':')
head.empty? ? [tail, head] : [head, tail]
end

def check_options!(options)
fallback = options[:fallback]
if fallback && !fallback.respond_to?(:call)
raise ArgumentError, ":fallback option must respond to method :call (proc, lambda etc)"
end
end

def allowed_directions
['asc', 'desc']
end
Expand Down
81 changes: 80 additions & 1 deletion spec/v2/query_sanitizer_spec.rb
@@ -1,5 +1,13 @@
require 'spec_helper'

class CustomFieldsSortByQueryFallback
def self.call(key, direction, context)
filterable_keys = %w(slt number)
_, field = key.split(':', 2)
filterable_keys.include?(field)
end
end

class TestedQuerySanitizer < InputSanitizer::V2::QuerySanitizer
string :status, :allow => ['', 'current', 'past']

Expand All @@ -11,7 +19,17 @@ class TestedQuerySanitizer < InputSanitizer::V2::QuerySanitizer

integer :ids, :collection => true
string :tags, :collection => true
sort_by %w(name updated_at created_at)
sort_by %w(name updated_at created_at), :default => 'name:asc', :fallback => CustomFieldsSortByQueryFallback
end

class ContextQuerySanitizer < InputSanitizer::V2::QuerySanitizer
sort_by %w(id created_at updated_at), :fallback => Proc.new { |key, _, context|
context && context[:allowed] && context[:allowed].include?(key)
}
end

class ContextForwardingSanitizer < InputSanitizer::V2::PayloadSanitizer
nested :nested1, :sanitizer => ContextQuerySanitizer
end

describe InputSanitizer::V2::QuerySanitizer do
Expand Down Expand Up @@ -167,6 +185,12 @@ class TestedQuerySanitizer < InputSanitizer::V2::QuerySanitizer
end

describe "sort_by" do
it "considers default" do
@params = { }
sanitizer.should be_valid
sanitizer[:sort_by].should eq(["name", "asc"])
end

it "accepts correct sorting format" do
@params = { :sort_by => "updated_at:desc" }
sanitizer.should be_valid
Expand All @@ -178,5 +202,60 @@ class TestedQuerySanitizer < InputSanitizer::V2::QuerySanitizer
sanitizer.should be_valid
sanitizer[:sort_by].should eq(["name", "asc"])
end

it "bails to fallback" do
@params = { :sort_by => 'custom_field:slt:asc' }
sanitizer.should be_valid
sanitizer[:sort_by].should eq(["custom_field:slt", "asc"])
end

[
['name', true, ["name", "asc"]],
['name:asc', true, ["name", "asc"]],
['name:desc', true, ["name", "desc"]],
['name:', true, ["name", "asc"]],
['custom_field:slt', true, ['custom_field:slt', 'asc']],
['custom_field:slt:', true, ['custom_field:slt', 'asc']],
['custom_field:slt:asc', true, ['custom_field:slt', 'asc']],
['custom_field:slt:desc', true, ['custom_field:slt', 'desc']],
['unknown', false, nil],
['name:invalid', false, nil],
['custom_field', false, nil],
['custom_field:', false, nil],
['custom_field:invalid', false, nil],
['custom_field:invalid:asc', false, nil],
['custom_field:invalid:desc', false, nil],
['custom_field2', false, nil]
].each do |sort_param, valid, expectation|
it "sort by #{sort_param} and returns #{valid}" do
@params = { :sort_by => sort_param }
sanitizer.valid?.should eq(valid)
sanitizer[:sort_by].should eq(expectation)
end
end
end

describe 'validation context' do
let(:sanitizer) { ContextQuerySanitizer.new(@params, @context) }

describe 'sort_by' do
it 'passes context to :fallback' do
@params = { :sort_by => 'custom_field.external_id' }
@context = { :allowed => ['custom_field.external_id'] }
sanitizer.should be_valid
sanitizer[:sort_by].should eq(["custom_field.external_id", "asc"])
end
end

describe 'forwarding to nested sanitizers' do
it 'passes context down' do
params = { :nested1 => { :sort_by => 'custom_field.external_id' } }
context = { :allowed => ['custom_field.external_id'] }
sanitizer = ContextForwardingSanitizer.new(params, context)

sanitizer.should be_valid
sanitizer[:nested1].should eq(:sort_by => ["custom_field.external_id", "asc"])
end
end
end
end

0 comments on commit bacee88

Please sign in to comment.