typed_params 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CONTRIBUTING.md +33 -0
  4. data/LICENSE +20 -0
  5. data/README.md +736 -0
  6. data/SECURITY.md +8 -0
  7. data/lib/typed_params/bouncer.rb +34 -0
  8. data/lib/typed_params/coercer.rb +21 -0
  9. data/lib/typed_params/configuration.rb +40 -0
  10. data/lib/typed_params/controller.rb +192 -0
  11. data/lib/typed_params/formatters/formatter.rb +20 -0
  12. data/lib/typed_params/formatters/jsonapi.rb +142 -0
  13. data/lib/typed_params/formatters/rails.rb +31 -0
  14. data/lib/typed_params/formatters.rb +20 -0
  15. data/lib/typed_params/handler.rb +24 -0
  16. data/lib/typed_params/handler_set.rb +19 -0
  17. data/lib/typed_params/mapper.rb +74 -0
  18. data/lib/typed_params/namespaced_set.rb +59 -0
  19. data/lib/typed_params/parameter.rb +100 -0
  20. data/lib/typed_params/parameterizer.rb +87 -0
  21. data/lib/typed_params/path.rb +57 -0
  22. data/lib/typed_params/pipeline.rb +13 -0
  23. data/lib/typed_params/processor.rb +27 -0
  24. data/lib/typed_params/schema.rb +290 -0
  25. data/lib/typed_params/schema_set.rb +7 -0
  26. data/lib/typed_params/transformer.rb +49 -0
  27. data/lib/typed_params/transforms/key_alias.rb +16 -0
  28. data/lib/typed_params/transforms/key_casing.rb +59 -0
  29. data/lib/typed_params/transforms/nilify_blanks.rb +16 -0
  30. data/lib/typed_params/transforms/noop.rb +11 -0
  31. data/lib/typed_params/transforms/transform.rb +11 -0
  32. data/lib/typed_params/types/array.rb +12 -0
  33. data/lib/typed_params/types/boolean.rb +33 -0
  34. data/lib/typed_params/types/date.rb +10 -0
  35. data/lib/typed_params/types/decimal.rb +10 -0
  36. data/lib/typed_params/types/float.rb +10 -0
  37. data/lib/typed_params/types/hash.rb +13 -0
  38. data/lib/typed_params/types/integer.rb +10 -0
  39. data/lib/typed_params/types/nil.rb +11 -0
  40. data/lib/typed_params/types/number.rb +10 -0
  41. data/lib/typed_params/types/string.rb +10 -0
  42. data/lib/typed_params/types/symbol.rb +10 -0
  43. data/lib/typed_params/types/time.rb +20 -0
  44. data/lib/typed_params/types/type.rb +78 -0
  45. data/lib/typed_params/types.rb +69 -0
  46. data/lib/typed_params/validations/exclusion.rb +17 -0
  47. data/lib/typed_params/validations/format.rb +19 -0
  48. data/lib/typed_params/validations/inclusion.rb +17 -0
  49. data/lib/typed_params/validations/length.rb +29 -0
  50. data/lib/typed_params/validations/validation.rb +18 -0
  51. data/lib/typed_params/validator.rb +75 -0
  52. data/lib/typed_params/version.rb +5 -0
  53. data/lib/typed_params.rb +89 -0
  54. 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/transforms/transform'
4
+
5
+ module TypedParams
6
+ module Transforms
7
+ class Noop < Transform
8
+ def call(*) = []
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Transforms
5
+ class Transform
6
+ def call(key, value) = raise NotImplementedError
7
+
8
+ def self.wrap(fn) = fn
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:array,
6
+ accepts_block: true,
7
+ scalar: false,
8
+ coerce: -> v { v.is_a?(String) ? v.split(',') : Array(v) },
9
+ match: -> v { v.is_a?(Array) },
10
+ )
11
+ end
12
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:date,
6
+ coerce: -> v { v.blank? ? nil : v.to_date },
7
+ match: -> v { v.is_a?(Date) },
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:decimal,
6
+ coerce: -> v { v.blank? ? nil : v.to_d },
7
+ match: -> v { v.is_a?(BigDecimal) },
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:float,
6
+ coerce: -> v { v.blank? ? nil : v.to_f },
7
+ match: -> v { v.is_a?(Float) },
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:hash,
6
+ name: :object,
7
+ accepts_block: true,
8
+ scalar: false,
9
+ coerce: -> v { v.respond_to?(:to_h) ? v.to_h : {} },
10
+ match: -> v { v.is_a?(Hash) },
11
+ )
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:integer,
6
+ coerce: -> v { v.blank? ? nil : v.to_i },
7
+ match: -> v { v.is_a?(Integer) },
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:nil,
6
+ name: :null,
7
+ coerce: -> v { nil },
8
+ match: -> v { v.nil? },
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:number,
6
+ match: -> v { v.is_a?(Numeric) },
7
+ abstract: true,
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:string,
6
+ coerce: -> v { v.to_s },
7
+ match: -> v { v.is_a?(String) },
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ module Types
5
+ register(:symbol,
6
+ coerce: -> v { v.to_sym },
7
+ match: -> v { v.is_a?(Symbol) },
8
+ )
9
+ end
10
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ VERSION = '0.2.0'
5
+ end
@@ -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