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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/path'
4
+
5
+ module TypedParams
6
+ class Parameter
7
+ attr_accessor :key,
8
+ :value
9
+
10
+ attr_reader :schema,
11
+ :parent
12
+
13
+ def initialize(key:, value:, schema:, parent: nil)
14
+ @key = key
15
+ @value = value
16
+ @schema = schema
17
+ @parent = parent
18
+ end
19
+
20
+ def array? = Types.array?(value)
21
+ def hash? = Types.hash?(value)
22
+ def scalar? = Types.scalar?(value)
23
+ def parent? = parent.present?
24
+
25
+ def path
26
+ key = @key == ROOT ? nil : @key
27
+
28
+ @path ||= Path.new(*parent&.path&.keys, *key)
29
+ end
30
+
31
+ def key?(key) = keys.include?(key)
32
+ alias :has_key? :key?
33
+
34
+ def keys?(*keys) = keys.all? { key?(_1) }
35
+ alias :has_keys? :keys?
36
+
37
+ def keys
38
+ return [] if
39
+ schema.children.blank?
40
+
41
+ case value
42
+ when Array
43
+ (0...value.size).to_a
44
+ when Hash
45
+ value.keys
46
+ else
47
+ []
48
+ end
49
+ end
50
+
51
+ def delete
52
+ raise NotImplementedError, "cannot delete param: #{key.inspect}" unless
53
+ parent?
54
+
55
+ case parent.value
56
+ when Array
57
+ parent.value.delete(self)
58
+ when Hash
59
+ parent.value.delete(
60
+ parent.value.key(self),
61
+ )
62
+ end
63
+ end
64
+
65
+ def unwrap(formatter: schema.formatter, controller: nil)
66
+ v = case value
67
+ when Hash
68
+ value.transform_values { _1.respond_to?(:unwrap) ? _1.unwrap : _1 }
69
+ when Array
70
+ value.map { _1.respond_to?(:unwrap) ? _1.unwrap : _1 }
71
+ else
72
+ value.respond_to?(:unwrap) ? value.unwrap : value
73
+ end
74
+
75
+ if formatter.present?
76
+ v = case formatter.arity
77
+ when 2
78
+ formatter.call(v, controller:)
79
+ when 1
80
+ formatter.call(v)
81
+ end
82
+ end
83
+
84
+ v
85
+ end
86
+
87
+ # Delegate everything else to the value
88
+ def respond_to_missing?(method_name, ...) = value.respond_to?(method_name, ...)
89
+ def method_missing(method_name, ...) = value.send(method_name, ...)
90
+
91
+ def deconstruct_keys(keys) = { key:, value: }
92
+ def deconstruct = value
93
+
94
+ def inspect
95
+ value = unwrap(formatter: nil)
96
+
97
+ "#<#{self.class.name} key=#{key.inspect} value=#{value.inspect}>"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Parameterizer
5
+ def initialize(schema:, parent: nil)
6
+ @schema = schema
7
+ @parent = parent
8
+ end
9
+
10
+ def call(key: ROOT, value:)
11
+ return value if
12
+ value.is_a?(Parameter)
13
+
14
+ return nil if
15
+ key == ROOT &&
16
+ value.nil?
17
+
18
+ case schema.children
19
+ when Array
20
+ parameterize_array_schema(key:, value:)
21
+ when Hash
22
+ parameterize_hash_schema(key:, value:)
23
+ else
24
+ parameterize_value(key:, value:)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :schema,
31
+ :parent
32
+
33
+ def parameterize_array_schema(key:, value:)
34
+ return parameterize_value(key:, value:) unless
35
+ value.is_a?(Array)
36
+
37
+ param = Parameter.new(key:, value: [], schema:, parent:)
38
+
39
+ value.each_with_index do |v, i|
40
+ unless schema.children.nil?
41
+ child = schema.children.fetch(i) { schema.boundless? ? schema.children.first : nil }
42
+ if child.nil?
43
+ raise UnpermittedParameterError.new('unpermitted parameter', path: Path.new(*param.path.keys, i), source: schema.source) if
44
+ schema.strict?
45
+
46
+ next
47
+ end
48
+
49
+ param << Parameterizer.new(schema: child, parent: param).call(key: i, value: v)
50
+ else
51
+ param << Parameter.new(key: i, value: v, schema:, parent: param)
52
+ end
53
+ end
54
+
55
+ param
56
+ end
57
+
58
+ def parameterize_hash_schema(key:, value:)
59
+ return parameterize_value(key:, value:) unless
60
+ value.is_a?(Hash)
61
+
62
+ param = Parameter.new(key:, value: {}, schema:, parent:)
63
+
64
+ value.each do |k, v|
65
+ unless schema.children.nil?
66
+ child = schema.children.fetch(k) { nil }
67
+ if child.nil?
68
+ raise UnpermittedParameterError.new('unpermitted parameter', path: Path.new(*param.path.keys, k), source: schema.source) if
69
+ schema.strict?
70
+
71
+ next
72
+ end
73
+
74
+ param[k] = Parameterizer.new(schema: child, parent: param).call(key: k, value: v)
75
+ else
76
+ param[k] = Parameter.new(key: k, value: v, schema:, parent: param)
77
+ end
78
+ end
79
+
80
+ param
81
+ end
82
+
83
+ def parameterize_value(key:, value:)
84
+ Parameter.new(key:, value:, schema:, parent:)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Path
5
+ attr_reader :keys
6
+
7
+ def initialize(*keys, casing: TypedParams.config.path_transform)
8
+ @casing = casing
9
+ @keys = keys
10
+ end
11
+
12
+ def to_json_pointer = '/' + keys.map { transform_key(_1) }.join('/')
13
+ def to_dot_notation = keys.map { transform_key(_1) }.join('.')
14
+
15
+ def to_s
16
+ keys.map { transform_key(_1) }.reduce(+'') do |s, key|
17
+ next s << key if s.blank?
18
+
19
+ case key
20
+ when Integer
21
+ s << "[#{key}]"
22
+ else
23
+ s << ".#{key}"
24
+ end
25
+ end
26
+ end
27
+
28
+ def inspect
29
+ "#<#{self.class.name}: #{to_s.inspect}>"
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :casing
35
+
36
+ def transform_string(str)
37
+ case casing
38
+ when :underscore
39
+ str.underscore
40
+ when :camel
41
+ str.underscore.camelize
42
+ when :lower_camel
43
+ str.underscore.camelize(:lower)
44
+ when :dash
45
+ str.underscore.dasherize
46
+ else
47
+ str
48
+ end
49
+ end
50
+
51
+ def transform_key(key)
52
+ return key if key.is_a?(Integer)
53
+
54
+ transform_string(key.to_s)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Pipeline
5
+ def initialize = @steps = []
6
+ def <<(step) = steps << step
7
+ def call(params) = steps.reduce(params) { |v, step| step.call(v) }
8
+
9
+ private
10
+
11
+ attr_reader :steps
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Processor
5
+ def initialize(schema:, controller: nil)
6
+ @controller = controller
7
+ @schema = schema
8
+ end
9
+
10
+ def call(value)
11
+ params = Parameterizer.new(schema:).call(value:)
12
+ pipeline = Pipeline.new
13
+
14
+ pipeline << Bouncer.new(controller:, schema:)
15
+ pipeline << Coercer.new(schema:)
16
+ pipeline << Validator.new(schema:)
17
+ pipeline << Transformer.new(controller:, schema:)
18
+
19
+ pipeline.call(params)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :controller,
25
+ :schema
26
+ end
27
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedParams
4
+ class Schema
5
+ attr_reader :validations,
6
+ :transforms,
7
+ :formatter,
8
+ :parent,
9
+ :children,
10
+ :source,
11
+ :type,
12
+ :key,
13
+ :if,
14
+ :unless
15
+
16
+ def initialize(
17
+ controller: nil,
18
+ source: nil,
19
+ strict: true,
20
+ parent: nil,
21
+ type: :hash,
22
+ key: nil,
23
+ optional: false,
24
+ coerce: false,
25
+ allow_blank: false,
26
+ allow_nil: false,
27
+ allow_non_scalars: false,
28
+ nilify_blanks: false,
29
+ noop: false,
30
+ inclusion: nil,
31
+ exclusion: nil,
32
+ format: nil,
33
+ length: nil,
34
+ transform: nil,
35
+ validate: nil,
36
+ if: nil,
37
+ unless: nil,
38
+ as: nil,
39
+ casing: TypedParams.config.key_transform,
40
+ &block
41
+ )
42
+ key ||= ROOT
43
+
44
+ raise ArgumentError, 'key is required for child schema' if
45
+ key == ROOT && parent.present?
46
+
47
+ raise ArgumentError, 'root cannot be null' if
48
+ key == ROOT && allow_nil
49
+
50
+ raise ArgumentError, 'source must be one of: :params or :query' unless
51
+ source.nil? || source == :params || source == :query
52
+
53
+ raise ArgumentError, 'inclusion must be a hash with :in key' unless
54
+ inclusion.nil? || inclusion.is_a?(Hash) && inclusion.key?(:in)
55
+
56
+ raise ArgumentError, 'exclusion must be a hash with :in key' unless
57
+ exclusion.nil? || exclusion.is_a?(Hash) && exclusion.key?(:in)
58
+
59
+ raise ArgumentError, 'format must be a hash with :with or :without keys (but not both)' unless
60
+ format.nil? || format.is_a?(Hash) && (
61
+ format.key?(:with) ^
62
+ format.key?(:without)
63
+ )
64
+
65
+ raise ArgumentError, 'length must be a hash with :minimum, :maximum, :within, :in, or :is keys (but not multiple)' unless
66
+ length.nil? || length.is_a?(Hash) && (
67
+ length.key?(:minimum) ^
68
+ length.key?(:maximum) ^
69
+ length.key?(:within) ^
70
+ length.key?(:in) ^
71
+ length.key?(:is)
72
+ )
73
+
74
+ @controller = controller
75
+ @source = source
76
+ @type = Types[type]
77
+ @strict = strict
78
+ @parent = parent
79
+ @key = key
80
+ @optional = optional
81
+ @coerce = coerce && @type.coercable?
82
+ @allow_blank = key == ROOT || allow_blank
83
+ @allow_nil = allow_nil
84
+ @allow_non_scalars = allow_non_scalars
85
+ @nilify_blanks = nilify_blanks
86
+ @noop = noop
87
+ @inclusion = inclusion
88
+ @exclusion = exclusion
89
+ @format = format
90
+ @length = length
91
+ @casing = casing
92
+ @transform = transform
93
+ @children = nil
94
+ @if = binding.local_variable_get(:if)
95
+ @unless = binding.local_variable_get(:unless)
96
+ @formatter = nil
97
+ @options = {}
98
+
99
+ # Validations
100
+ @validations = []
101
+
102
+ @validations << Validations::Inclusion.new(inclusion) if
103
+ inclusion.present?
104
+
105
+ @validations << Validations::Exclusion.new(exclusion) if
106
+ exclusion.present?
107
+
108
+ @validations << Validations::Format.new(format) if
109
+ format.present?
110
+
111
+ @validations << Validations::Length.new(length) if
112
+ length.present?
113
+
114
+ @validations << Validations::Validation.wrap(validate) if
115
+ validate.present?
116
+
117
+ # Transforms
118
+ @transforms = []
119
+
120
+ @transforms << Transforms::KeyAlias.new(as) if
121
+ as.present?
122
+
123
+ @transforms << Transforms::NilifyBlanks.new if
124
+ nilify_blanks
125
+
126
+ @transforms << Transforms::Transform.wrap(transform) if
127
+ transform.present?
128
+
129
+ @transforms << Transforms::KeyCasing.new(casing) if
130
+ casing.present?
131
+
132
+ @transforms << Transforms::Noop.new if
133
+ noop
134
+
135
+ raise ArgumentError, "type #{type} is a not registered type" if
136
+ @type.nil?
137
+
138
+ if block_given?
139
+ raise ArgumentError, "type #{@type} does not accept a block" if
140
+ @type.present? && !@type.accepts_block?
141
+
142
+ @children = case
143
+ when Types.array?(@type)
144
+ []
145
+ when Types.hash?(@type)
146
+ {}
147
+ end
148
+
149
+ self.instance_exec &block
150
+ end
151
+ end
152
+
153
+ ##
154
+ # format defines the final output format for the schema, transforming
155
+ # the params from an input format to an output format, e.g. a JSONAPI
156
+ # document to Rails' standard params format. This also applies the
157
+ # formatter's decorators onto the controller.
158
+ def format(format)
159
+ raise NotImplementedError, 'cannot define format for child schema' if
160
+ child?
161
+
162
+ formatter = Formatters[format]
163
+
164
+ raise ArgumentError, "invalid format: #{format.inspect}" if
165
+ formatter.nil?
166
+
167
+ # Apply the formatter's decorators onto the controller.
168
+ controller.instance_exec(&formatter.decorator) if
169
+ controller.present? && formatter.decorator?
170
+
171
+ @formatter = formatter
172
+ end
173
+
174
+ ##
175
+ # with defines a set of options to use for all direct children of the
176
+ # schema defined within the block.
177
+ #
178
+ # For example, it can be used to define a common guard:
179
+ #
180
+ # with if: -> { ... } do
181
+ # param :foo, type: :string
182
+ # param :bar, type: :string
183
+ # param :baz, type: :hash do
184
+ # param :qux, type: :string
185
+ # end
186
+ # end
187
+ #
188
+ # In this example, :foo, :bar, and :baz will inherit the if: guard,
189
+ # but :qux will not, since it is not a direct child.
190
+ #
191
+ def with(**kwargs, &)
192
+ orig = @options
193
+ @options = kwargs
194
+
195
+ yield
196
+
197
+ @options = orig
198
+ end
199
+
200
+ ##
201
+ # param defines a keyed parameter for a hash schema.
202
+ def param(key, type:, **kwargs, &block)
203
+ raise NotImplementedError, "cannot define param for non-hash type (got #{self.type})" unless
204
+ Types.hash?(children)
205
+
206
+ raise ArgumentError, "key #{key} has already been defined" if
207
+ children.key?(key)
208
+
209
+ children[key] = Schema.new(**options, **kwargs, key:, type:, strict:, source:, casing:, parent: self, &block)
210
+ end
211
+
212
+ ##
213
+ # params defines multiple like-parameters for a hash schema.
214
+ def params(*keys, **kwargs, &) = keys.each { param(_1, **kwargs, &) }
215
+
216
+ ##
217
+ # item defines an indexed parameter for an array schema.
218
+ def item(key = children&.size || 0, type:, **kwargs, &block)
219
+ raise NotImplementedError, "cannot define item for non-array type (got #{self.type})" unless
220
+ Types.array?(children)
221
+
222
+ raise ArgumentError, "index #{key} has already been defined" if
223
+ children[key].present? || boundless?
224
+
225
+ children << Schema.new(**options, **kwargs, key:, type:, strict:, source:, casing:, parent: self, &block)
226
+ end
227
+
228
+ ##
229
+ # items defines a set of like-parameters for an array schema.
230
+ def items(**kwargs, &)
231
+ item(0, **kwargs, &)
232
+
233
+ boundless!
234
+ end
235
+
236
+ def path
237
+ key = @key == ROOT ? nil : @key
238
+
239
+ @path ||= Path.new(*parent&.path&.keys, *key)
240
+ end
241
+
242
+ def keys
243
+ return [] if
244
+ children.blank?
245
+
246
+ case children
247
+ when Array
248
+ (0...children.size).to_a
249
+ when Hash
250
+ children.keys
251
+ else
252
+ []
253
+ end
254
+ end
255
+
256
+ def root? = key == ROOT
257
+ def child? = !root?
258
+ def children? = !children.blank?
259
+ def strict? = !!strict
260
+ def lenient? = !strict?
261
+ def optional? = !!@optional
262
+ def required? = !optional?
263
+ def coerce? = !!@coerce
264
+ def allow_blank? = !!@allow_blank
265
+ def allow_nil? = !!@allow_nil
266
+ def allow_non_scalars? = !!@allow_non_scalars
267
+ def nilify_blanks? = !!@nilify_blanks
268
+ def boundless? = !!@boundless
269
+ def indexed? = !boundless?
270
+ def if? = !@if.nil?
271
+ def unless? = !@unless.nil?
272
+ def array? = Types.array?(type)
273
+ def hash? = Types.hash?(type)
274
+ def scalar? = Types.scalar?(type)
275
+ def formatter? = !!@formatter
276
+
277
+ def inspect
278
+ "#<#{self.class.name} key=#{key.inspect} type=#{type.inspect} children=#{children.inspect}>"
279
+ end
280
+
281
+ private
282
+
283
+ attr_reader :controller,
284
+ :options,
285
+ :strict,
286
+ :casing
287
+
288
+ def boundless! = @boundless = true
289
+ end
290
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/namespaced_set'
4
+
5
+ module TypedParams
6
+ class SchemaSet < NamespacedSet; end
7
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/mapper'
4
+
5
+ module TypedParams
6
+ class Transformer < Mapper
7
+ def call(params)
8
+ depth_first_map(params) do |param|
9
+ schema = param.schema
10
+ parent = param.parent
11
+
12
+ # Ignore nil optionals when config is enabled
13
+ unless schema.allow_nil?
14
+ if param.value.nil? && schema.optional? && TypedParams.config.ignore_nil_optionals
15
+ param.delete
16
+
17
+ break
18
+ end
19
+ end
20
+
21
+ schema.transforms.map do |transform|
22
+ key, value = transform.call(param.key, param.value)
23
+ if key.nil?
24
+ param.delete
25
+
26
+ break
27
+ end
28
+
29
+ # Check for nils again after transform
30
+ unless schema.allow_nil?
31
+ if value.nil? && schema.optional? && TypedParams.config.ignore_nil_optionals
32
+ param.delete
33
+
34
+ break
35
+ end
36
+ end
37
+
38
+ # If param's key has changed, we want to rename the key
39
+ # for its parent too.
40
+ if param.parent? && param.key != key
41
+ parent[key] = param.delete
42
+ end
43
+
44
+ param.key, param.value = key, value
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -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 KeyAlias < Transform
8
+ def initialize(as) = @as = as
9
+ def call(_, value) = [as, value]
10
+
11
+ private
12
+
13
+ attr_reader :as
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typed_params/transforms/transform'
4
+
5
+ module TypedParams
6
+ module Transforms
7
+ class KeyCasing < Transform
8
+ def initialize(casing) = @casing = casing
9
+
10
+ def call(key, value)
11
+ transformed_key = transform_key(key)
12
+ transformed_value = transform_value(value)
13
+
14
+ [transformed_key, transformed_value]
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :casing
20
+
21
+ def transform_string(str)
22
+ case casing
23
+ when :underscore
24
+ str.underscore
25
+ when :camel
26
+ str.underscore.camelize
27
+ when :lower_camel
28
+ str.underscore.camelize(:lower)
29
+ when :dash
30
+ str.underscore.dasherize
31
+ else
32
+ str
33
+ end
34
+ end
35
+
36
+ def transform_key(key)
37
+ case key
38
+ when String
39
+ transform_string(key)
40
+ when Symbol
41
+ transform_string(key.to_s).to_sym
42
+ else
43
+ key
44
+ end
45
+ end
46
+
47
+ def transform_value(value)
48
+ case value
49
+ when Hash
50
+ value.deep_transform_keys { transform_key(_1) }
51
+ when Array
52
+ value.map { transform_value(_1) }
53
+ else
54
+ value
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end