typed_params 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|