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.
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