typed_params 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/CONTRIBUTING.md +33 -0
- data/LICENSE +20 -0
- data/README.md +736 -0
- data/SECURITY.md +8 -0
- data/lib/typed_params/bouncer.rb +34 -0
- data/lib/typed_params/coercer.rb +21 -0
- data/lib/typed_params/configuration.rb +40 -0
- data/lib/typed_params/controller.rb +192 -0
- data/lib/typed_params/formatters/formatter.rb +20 -0
- data/lib/typed_params/formatters/jsonapi.rb +142 -0
- data/lib/typed_params/formatters/rails.rb +31 -0
- data/lib/typed_params/formatters.rb +20 -0
- data/lib/typed_params/handler.rb +24 -0
- data/lib/typed_params/handler_set.rb +19 -0
- data/lib/typed_params/mapper.rb +74 -0
- data/lib/typed_params/namespaced_set.rb +59 -0
- data/lib/typed_params/parameter.rb +100 -0
- data/lib/typed_params/parameterizer.rb +87 -0
- data/lib/typed_params/path.rb +57 -0
- data/lib/typed_params/pipeline.rb +13 -0
- data/lib/typed_params/processor.rb +27 -0
- data/lib/typed_params/schema.rb +290 -0
- data/lib/typed_params/schema_set.rb +7 -0
- data/lib/typed_params/transformer.rb +49 -0
- data/lib/typed_params/transforms/key_alias.rb +16 -0
- data/lib/typed_params/transforms/key_casing.rb +59 -0
- data/lib/typed_params/transforms/nilify_blanks.rb +16 -0
- data/lib/typed_params/transforms/noop.rb +11 -0
- data/lib/typed_params/transforms/transform.rb +11 -0
- data/lib/typed_params/types/array.rb +12 -0
- data/lib/typed_params/types/boolean.rb +33 -0
- data/lib/typed_params/types/date.rb +10 -0
- data/lib/typed_params/types/decimal.rb +10 -0
- data/lib/typed_params/types/float.rb +10 -0
- data/lib/typed_params/types/hash.rb +13 -0
- data/lib/typed_params/types/integer.rb +10 -0
- data/lib/typed_params/types/nil.rb +11 -0
- data/lib/typed_params/types/number.rb +10 -0
- data/lib/typed_params/types/string.rb +10 -0
- data/lib/typed_params/types/symbol.rb +10 -0
- data/lib/typed_params/types/time.rb +20 -0
- data/lib/typed_params/types/type.rb +78 -0
- data/lib/typed_params/types.rb +69 -0
- data/lib/typed_params/validations/exclusion.rb +17 -0
- data/lib/typed_params/validations/format.rb +19 -0
- data/lib/typed_params/validations/inclusion.rb +17 -0
- data/lib/typed_params/validations/length.rb +29 -0
- data/lib/typed_params/validations/validation.rb +18 -0
- data/lib/typed_params/validator.rb +75 -0
- data/lib/typed_params/version.rb +5 -0
- data/lib/typed_params.rb +89 -0
- metadata +124 -0
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/transforms/transform'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
module Transforms
|
7
|
+
class NilifyBlanks < Transform
|
8
|
+
def call(key, value)
|
9
|
+
return [key, value] if
|
10
|
+
value.is_a?(Array) || value.is_a?(Hash)
|
11
|
+
|
12
|
+
[key, value.blank? ? nil : value]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypedParams
|
4
|
+
module Types
|
5
|
+
module Boolean
|
6
|
+
COERCIBLE_TYPES = [String, Numeric].freeze
|
7
|
+
TRUTHY_VALUES = [
|
8
|
+
1,
|
9
|
+
'1',
|
10
|
+
'true',
|
11
|
+
'TRUE',
|
12
|
+
't',
|
13
|
+
'T',
|
14
|
+
'yes',
|
15
|
+
'YES',
|
16
|
+
'y',
|
17
|
+
'Y',
|
18
|
+
].freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
register(:boolean,
|
22
|
+
coerce: -> v {
|
23
|
+
return nil unless
|
24
|
+
Boolean::COERCIBLE_TYPES.any? { v.is_a?(_1) }
|
25
|
+
|
26
|
+
v.in?(Boolean::TRUTHY_VALUES)
|
27
|
+
},
|
28
|
+
match: -> v {
|
29
|
+
v.is_a?(TrueClass) || v.is_a?(FalseClass)
|
30
|
+
},
|
31
|
+
)
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypedParams
|
4
|
+
module Types
|
5
|
+
register(:time,
|
6
|
+
match: -> v { v.is_a?(Time) },
|
7
|
+
coerce: -> v {
|
8
|
+
return nil if
|
9
|
+
v.blank?
|
10
|
+
|
11
|
+
case
|
12
|
+
when v.to_s.match?(/\A\d+\z/)
|
13
|
+
Time.at(v.to_i)
|
14
|
+
else
|
15
|
+
v.to_time
|
16
|
+
end
|
17
|
+
},
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypedParams
|
4
|
+
module Types
|
5
|
+
class Type
|
6
|
+
attr_reader :type,
|
7
|
+
:name
|
8
|
+
|
9
|
+
def initialize(type:, name:, match:, coerce:, scalar:, abstract:, archetype:, accepts_block:)
|
10
|
+
raise ArgumentError, ':abstract not allowed with :archetype' if
|
11
|
+
abstract && archetype.present?
|
12
|
+
|
13
|
+
raise ArgumentError, ':abstract not allowed with :coerce' if
|
14
|
+
abstract && coerce.present?
|
15
|
+
|
16
|
+
raise ArgumentError, ':coerce must be callable' unless
|
17
|
+
coerce.nil? || coerce.respond_to?(:call)
|
18
|
+
|
19
|
+
raise ArgumentError, ':match must be callable' unless
|
20
|
+
match.respond_to?(:call)
|
21
|
+
|
22
|
+
@name = name || type
|
23
|
+
@type = type
|
24
|
+
@match = match
|
25
|
+
@coerce = coerce
|
26
|
+
@abstract = abstract
|
27
|
+
@archetype = archetype
|
28
|
+
@scalar = scalar
|
29
|
+
@accepts_block = accepts_block
|
30
|
+
end
|
31
|
+
|
32
|
+
def archetype
|
33
|
+
return unless @archetype.present?
|
34
|
+
|
35
|
+
return @archetype if
|
36
|
+
@archetype.is_a?(Type)
|
37
|
+
|
38
|
+
Types[@archetype]
|
39
|
+
end
|
40
|
+
|
41
|
+
def accepts_block? = !!@accepts_block
|
42
|
+
def coercable? = !!@coerce
|
43
|
+
def scalar? = !!@scalar
|
44
|
+
def abstract? = !!@abstract
|
45
|
+
def subtype? = !!@archetype
|
46
|
+
|
47
|
+
# NOTE(ezekg) Using yoda-style self#== because value may be a Type, and
|
48
|
+
# we're overriding Type#coerce, which is a Ruby core method
|
49
|
+
# expected to return an [x, y] tuple, so v == self breaks.
|
50
|
+
def match?(v) = self == v || !!@match.call(v)
|
51
|
+
def mismatch?(v) = !match?(v)
|
52
|
+
|
53
|
+
def humanize
|
54
|
+
if subtype?
|
55
|
+
"#{@name} #{archetype.humanize}"
|
56
|
+
else
|
57
|
+
@name.to_s
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_sym = type.to_sym
|
62
|
+
def to_s = type.to_s
|
63
|
+
|
64
|
+
def inspect
|
65
|
+
"#<#{self.class.name} type=#{@type.inspect} name=#{@name.inspect} abstract=#{@abstract.inspect} archetype=#{@archetype.inspect}>"
|
66
|
+
end
|
67
|
+
|
68
|
+
def coerce(value)
|
69
|
+
raise CoercionError, 'type is not coercable' unless
|
70
|
+
coercable?
|
71
|
+
|
72
|
+
@coerce.call(value)
|
73
|
+
rescue
|
74
|
+
raise CoercionError, "failed to coerce #{Types.for(value).name} to #{@name}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/types/type'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
module Types
|
7
|
+
cattr_reader :registry, default: []
|
8
|
+
cattr_reader :abstracts, default: {}
|
9
|
+
cattr_reader :subtypes, default: {}
|
10
|
+
cattr_reader :types, default: {}
|
11
|
+
|
12
|
+
def self.register(type, match:, name: nil, coerce: nil, archetype: nil, abstract: false, scalar: true, accepts_block: false)
|
13
|
+
raise ArgumentError, "type is already registered: #{type.inspect}" if
|
14
|
+
registry.include?(type)
|
15
|
+
|
16
|
+
registry << type
|
17
|
+
|
18
|
+
t = Type.new(type:, name:, match:, coerce:, archetype:, abstract:, scalar:, accepts_block:)
|
19
|
+
case
|
20
|
+
when abstract
|
21
|
+
abstracts[type] = t
|
22
|
+
when archetype
|
23
|
+
subtypes[type] = t
|
24
|
+
else
|
25
|
+
types[type] = t
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.unregister(type)
|
30
|
+
return unless
|
31
|
+
registry.include?(type)
|
32
|
+
|
33
|
+
t = abstracts.delete(type) || subtypes.delete(type) || types.delete(type)
|
34
|
+
|
35
|
+
registry.delete(type) if
|
36
|
+
t.present?
|
37
|
+
|
38
|
+
t
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.coerce(value, to:) = (subtypes[to] || types[to]).coerce(value)
|
42
|
+
|
43
|
+
def self.array?(value) = types[:array].match?(value)
|
44
|
+
def self.hash?(value) = types[:hash].match?(value)
|
45
|
+
def self.nil?(value) = types[:nil].match?(value)
|
46
|
+
def self.scalar?(value) = self.for(value).scalar?
|
47
|
+
|
48
|
+
def self.[](key)
|
49
|
+
type = abstracts[key] ||
|
50
|
+
subtypes[key] ||
|
51
|
+
types[key]
|
52
|
+
|
53
|
+
raise ArgumentError, "invalid type: #{key.inspect}" if
|
54
|
+
type.nil?
|
55
|
+
|
56
|
+
type
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.for(value, try: nil)
|
60
|
+
_, type = subtypes.slice(*try).find { |_, t| t.match?(value) } ||
|
61
|
+
types.find { |_, t| t.match?(value) }
|
62
|
+
|
63
|
+
raise ArgumentError, "cannot find type for value: #{value.inspect}" if
|
64
|
+
type.nil?
|
65
|
+
|
66
|
+
type
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/validations/validation'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
module Validations
|
7
|
+
class Exclusion < Validation
|
8
|
+
def call(value)
|
9
|
+
raise ValidationError, 'is invalid' if
|
10
|
+
case options
|
11
|
+
in in: Range | Array => e
|
12
|
+
e.include?(value)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/validations/validation'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
module Validations
|
7
|
+
class Format < Validation
|
8
|
+
def call(value)
|
9
|
+
raise ValidationError, 'format is invalid' unless
|
10
|
+
case options
|
11
|
+
in without: Regexp => rx
|
12
|
+
!rx.match?(value)
|
13
|
+
in with: Regexp => rx
|
14
|
+
rx.match?(value)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/validations/validation'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
module Validations
|
7
|
+
class Inclusion < Validation
|
8
|
+
def call(value)
|
9
|
+
raise ValidationError, 'is invalid' unless
|
10
|
+
case options
|
11
|
+
in in: Range | Array => e
|
12
|
+
e.include?(value)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/validations/validation'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
module Validations
|
7
|
+
class Length < Validation
|
8
|
+
def call(value)
|
9
|
+
case options
|
10
|
+
in minimum: Numeric => n
|
11
|
+
raise ValidationError, "length must be greater than or equal to #{n}" unless
|
12
|
+
value.length >= n
|
13
|
+
in maximum: Numeric => n
|
14
|
+
raise ValidationError, "length must be less than or equal to #{n}" unless
|
15
|
+
value.length <= n
|
16
|
+
in within: Range | Array => e
|
17
|
+
raise ValidationError, "length must be between #{e.first} and #{e.last}" unless
|
18
|
+
e.include?(value.length)
|
19
|
+
in in: Range | Array => e
|
20
|
+
raise ValidationError, "length must be between #{e.first} and #{e.last}" unless
|
21
|
+
e.include?(value.length)
|
22
|
+
in is: Numeric => n
|
23
|
+
raise ValidationError, "length must be equal to #{n}" unless
|
24
|
+
value.length == n
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TypedParams
|
4
|
+
module Validations
|
5
|
+
class Validation
|
6
|
+
def initialize(options) = @options = options
|
7
|
+
def call(value) = raise NotImplementedError
|
8
|
+
|
9
|
+
def self.wrap(fn)
|
10
|
+
-> v { raise ValidationError, 'is invalid' unless fn.call(v) }
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
attr_reader :options
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/mapper'
|
4
|
+
|
5
|
+
module TypedParams
|
6
|
+
class Validator < Mapper
|
7
|
+
def call(params)
|
8
|
+
raise InvalidParameterError.new('is missing', path: schema.path, source: schema.source) if
|
9
|
+
params.nil? && schema.required? && !schema.allow_nil?
|
10
|
+
|
11
|
+
depth_first_map(params) do |param|
|
12
|
+
schema = param.schema
|
13
|
+
type = Types.for(param.value,
|
14
|
+
try: schema.type.subtype? ? schema.type.to_sym : nil,
|
15
|
+
)
|
16
|
+
|
17
|
+
raise InvalidParameterError.new("type mismatch (received unknown expected #{schema.type.humanize})", path: param.path, source: schema.source) if
|
18
|
+
type.nil?
|
19
|
+
|
20
|
+
# Handle nils early on
|
21
|
+
if Types.nil?(type)
|
22
|
+
raise InvalidParameterError.new('cannot be null', path: param.path, source: schema.source) unless
|
23
|
+
schema.optional? && TypedParams.config.ignore_nil_optionals ||
|
24
|
+
schema.allow_nil?
|
25
|
+
|
26
|
+
next
|
27
|
+
end
|
28
|
+
|
29
|
+
# Assert type
|
30
|
+
raise InvalidParameterError.new("type mismatch (received #{type.humanize} expected #{schema.type.humanize})", path: param.path, source: schema.source) unless
|
31
|
+
type == schema.type || type.subtype? && type.archetype == schema.type
|
32
|
+
|
33
|
+
# Assertions for params without children
|
34
|
+
if schema.children.nil?
|
35
|
+
# Assert non-scalars only contain scalars (unless allowed)
|
36
|
+
case
|
37
|
+
when schema.hash?
|
38
|
+
param.value.each do |key, value|
|
39
|
+
next if
|
40
|
+
Types.scalar?(value)
|
41
|
+
|
42
|
+
raise InvalidParameterError.new('unpermitted type (expected object of scalar types)', path: Path.new(*param.path.keys, key), source: schema.source) unless
|
43
|
+
schema.allow_non_scalars?
|
44
|
+
end
|
45
|
+
when schema.array?
|
46
|
+
param.value.each_with_index do |value, index|
|
47
|
+
next if
|
48
|
+
Types.scalar?(value)
|
49
|
+
|
50
|
+
raise InvalidParameterError.new('unpermitted type (expected array of scalar types)', path: Path.new(*param.path.keys, index), source: schema.source) unless
|
51
|
+
schema.allow_non_scalars?
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Handle blanks (and false-positive "blank" values)
|
56
|
+
if param.value.blank?
|
57
|
+
unless param.value == false
|
58
|
+
raise InvalidParameterError.new('cannot be blank', path: param.path, source: schema.source) unless
|
59
|
+
schema.allow_blank?
|
60
|
+
|
61
|
+
next
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Assert validations
|
67
|
+
schema.validations.each do |validation|
|
68
|
+
validation.call(param.value)
|
69
|
+
rescue ValidationError => e
|
70
|
+
raise InvalidParameterError.new(e.message, path: param.path, source: schema.source)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
data/lib/typed_params.rb
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'typed_params/bouncer'
|
4
|
+
require 'typed_params/coercer'
|
5
|
+
require 'typed_params/configuration'
|
6
|
+
require 'typed_params/controller'
|
7
|
+
require 'typed_params/formatters'
|
8
|
+
require 'typed_params/formatters/formatter'
|
9
|
+
require 'typed_params/formatters/jsonapi'
|
10
|
+
require 'typed_params/formatters/rails'
|
11
|
+
require 'typed_params/handler_set'
|
12
|
+
require 'typed_params/handler'
|
13
|
+
require 'typed_params/mapper'
|
14
|
+
require 'typed_params/namespaced_set'
|
15
|
+
require 'typed_params/parameter'
|
16
|
+
require 'typed_params/parameterizer'
|
17
|
+
require 'typed_params/path'
|
18
|
+
require 'typed_params/pipeline'
|
19
|
+
require 'typed_params/processor'
|
20
|
+
require 'typed_params/schema_set'
|
21
|
+
require 'typed_params/schema'
|
22
|
+
require 'typed_params/transforms/key_alias'
|
23
|
+
require 'typed_params/transforms/key_casing'
|
24
|
+
require 'typed_params/transforms/nilify_blanks'
|
25
|
+
require 'typed_params/transforms/noop'
|
26
|
+
require 'typed_params/transforms/transform'
|
27
|
+
require 'typed_params/transformer'
|
28
|
+
require 'typed_params/types'
|
29
|
+
require 'typed_params/types/array'
|
30
|
+
require 'typed_params/types/boolean'
|
31
|
+
require 'typed_params/types/date'
|
32
|
+
require 'typed_params/types/decimal'
|
33
|
+
require 'typed_params/types/float'
|
34
|
+
require 'typed_params/types/hash'
|
35
|
+
require 'typed_params/types/integer'
|
36
|
+
require 'typed_params/types/nil'
|
37
|
+
require 'typed_params/types/number'
|
38
|
+
require 'typed_params/types/string'
|
39
|
+
require 'typed_params/types/symbol'
|
40
|
+
require 'typed_params/types/time'
|
41
|
+
require 'typed_params/types/type'
|
42
|
+
require 'typed_params/validations/exclusion'
|
43
|
+
require 'typed_params/validations/format'
|
44
|
+
require 'typed_params/validations/inclusion'
|
45
|
+
require 'typed_params/validations/length'
|
46
|
+
require 'typed_params/validations/validation'
|
47
|
+
require 'typed_params/validator'
|
48
|
+
|
49
|
+
module TypedParams
|
50
|
+
# Sentinel value for determining if something should be automatic.
|
51
|
+
# For example, automatically detecting a param's format via its
|
52
|
+
# schema vs using an explicitly provided format.
|
53
|
+
AUTO = Object.new
|
54
|
+
|
55
|
+
# Sentinel value for determining if something is the root. For
|
56
|
+
# example, determining if a schema is the root node.
|
57
|
+
ROOT = Object.new
|
58
|
+
|
59
|
+
class UndefinedActionError < StandardError; end
|
60
|
+
class InvalidMethodError < StandardError; end
|
61
|
+
class ValidationError < StandardError; end
|
62
|
+
class CoercionError < StandardError; end
|
63
|
+
|
64
|
+
class InvalidParameterError < StandardError
|
65
|
+
attr_reader :source,
|
66
|
+
:path
|
67
|
+
|
68
|
+
def initialize(message, source:, path:)
|
69
|
+
@source = source
|
70
|
+
@path = path
|
71
|
+
|
72
|
+
super(message)
|
73
|
+
end
|
74
|
+
|
75
|
+
def inspect
|
76
|
+
"#<#{self.class.name} message=#{message.inspect} source=#{source.inspect} path=#{path.inspect}>"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class UnpermittedParameterError < InvalidParameterError; end
|
81
|
+
|
82
|
+
def self.formats = Formatters
|
83
|
+
def self.types = Types
|
84
|
+
|
85
|
+
def self.config = @config ||= Configuration.new
|
86
|
+
def self.configure
|
87
|
+
yield config
|
88
|
+
end
|
89
|
+
end
|