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