active_typed_params 0.1.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.
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ class Builder
5
+ PRESENCE_OPTIONS = %i[required optional].freeze
6
+ COMMON_OPTIONS = (%i[allow_nil default allow_if allow_unless as invalid] + PRESENCE_OPTIONS).freeze
7
+ OPTIONS = {
8
+ string: COMMON_OPTIONS + %i[allow_blank in max_length],
9
+ integer: COMMON_OPTIONS + %i[in],
10
+ id: COMMON_OPTIONS + %i[bytes],
11
+ decimal: COMMON_OPTIONS + %i[in],
12
+ float: COMMON_OPTIONS + %i[in],
13
+ boolean: COMMON_OPTIONS,
14
+ date: COMMON_OPTIONS + %i[in],
15
+ time: COMMON_OPTIONS + %i[in],
16
+ enum: COMMON_OPTIONS,
17
+ file: COMMON_OPTIONS,
18
+ any: COMMON_OPTIONS,
19
+ hash: COMMON_OPTIONS + %i[json open],
20
+ array: COMMON_OPTIONS + %i[json max_length],
21
+ map: COMMON_OPTIONS + %i[json max_length keys],
22
+ one_of: COMMON_OPTIONS,
23
+ }.freeze
24
+ ITEM_UNSUPPORTED_OPTIONS = %i[default allow_if allow_unless as invalid].freeze
25
+ INVALID_MODES = %i[raise drop].freeze
26
+ MAP_KEY_TYPES = %i[string id integer].freeze
27
+ DEFAULT_STRING_MAX_LENGTH = 512
28
+ DEFAULT_COLLECTION_MAX_LENGTH = 1_000
29
+ ID_BYTES = [4, 8].freeze
30
+
31
+ # Shared by the builders of one schema: the owner `use` looks schemas up
32
+ # on, and the nameless declarations not yet given to a key.
33
+ Scope = Struct.new(:owner, :described) do
34
+ def initialize(owner = nil)
35
+ super(owner, {}.compare_by_identity)
36
+ end
37
+ end
38
+
39
+ def self.run(block, required: false, owner: nil)
40
+ builder = new(required:, scope: Scope.new(owner))
41
+ builder.instance_exec(&block)
42
+ builder.send(:check_described)
43
+ builder.nodes
44
+ end
45
+
46
+ def initialize(required: false, scope: Scope.new)
47
+ @nodes = {}
48
+ @required = required
49
+ @scope = scope
50
+ end
51
+
52
+ def nodes
53
+ @nodes.values.freeze
54
+ end
55
+
56
+ def required(*names, **pairs, &block)
57
+ with_presence(true) do
58
+ param(*names, **pairs)
59
+ instance_exec(&block) if block
60
+ end
61
+ end
62
+
63
+ def optional(*names, **pairs, &block)
64
+ with_presence(false) do
65
+ param(*names, **pairs)
66
+ instance_exec(&block) if block
67
+ end
68
+ end
69
+
70
+ def param(*names, **pairs)
71
+ consume(pairs)
72
+ names.each { |name| string(name) }
73
+ pairs.each { |name, value| apply_declaration(name, Declaration.from_literal(value)) }
74
+ end
75
+
76
+ (SCALAR_TYPES - [:enum] + [:any]).each do |type|
77
+ define_method(type) do |*names, **options|
78
+ names.empty? ? describe(type, options) : declare(type, names, options)
79
+ end
80
+ end
81
+
82
+ def text(*names, **options)
83
+ string(*names, max_length: ActiveTypedParams.config.text_max_length, **options)
84
+ end
85
+
86
+ def direct_upload(*names, **options)
87
+ string(*names, invalid: :drop, **options)
88
+ end
89
+
90
+ def enum(*args, **options)
91
+ if args.empty? || (args.size == 1 && args.first.is_a?(Symbol))
92
+ raise SchemaError, "enum #{args.first.inspect} needs its values or class, as in `enum :status, Status`"
93
+ end
94
+
95
+ *names, source = args
96
+ raise SchemaError, "no enum adapter handles #{source.inspect}" unless EnumAdapter.lookup(source)
97
+
98
+ names.empty? ? describe(:enum, options, source:) : declare(:enum, names, options, enum_map: EnumAdapter.resolve(source))
99
+ end
100
+
101
+ # Without a block, several symbols or a `key: type` pair are the fields of
102
+ # a nameless hash, as in `hash(:name, role: Role)`. A lone symbol names the
103
+ # hash, and a field named like a hash option needs a `{}` literal.
104
+ def hash(*args, **rest, &block)
105
+ consume([args, rest])
106
+ raise SchemaError, "hash #{args.inspect} takes its fields as arguments; declare a named list as `key: [...]`" if args.any?(Array)
107
+
108
+ options, fields = rest.partition { |key, _| OPTIONS[:hash].include?(key) }.map(&:to_h)
109
+ if block.nil? && (args.size > 1 || fields.any?)
110
+ return describe(:hash, options, children: Declaration.from_list(fields.any? ? [*args, fields] : args).children)
111
+ end
112
+ raise SchemaError, "hash #{args.inspect} declares one key at a time" if args.size > 1
113
+ raise SchemaError, "hash #{args.inspect} needs a block or open: true" if block.nil? && !rest[:open]
114
+
115
+ args.empty? ? describe(:hash, rest, children: [], block:) : declare(:hash, args, rest, children: block ? children_from(block) : [])
116
+ end
117
+
118
+ # A lone type symbol, declaration or list describes an array; anything
119
+ # else names one, so a key named like a type needs an explicit item type.
120
+ def array(*positional, **rest, &block)
121
+ consume([positional, rest])
122
+ options, extras = rest.partition { |key, _| OPTIONS[:array].include?(key) }.map(&:to_h)
123
+ nameless = positional.empty? || (positional.size == 1 && block.nil? && extras.empty? && item_literal?(positional.first))
124
+ name = positional.shift unless nameless
125
+ label = name ? name.inspect : "literal"
126
+ raise SchemaError, "array #{label} needs a symbol name" unless name.nil? || name.is_a?(Symbol)
127
+ raise SchemaError, "array #{label} takes one item type" if positional.size > 1
128
+
129
+ item = array_item(label, positional.first, extras, block)
130
+ name ? declare(:array, [name], options, item: item_node(item)) : describe(:array, options, item:)
131
+ end
132
+
133
+ # A hash keyed by data rather than names, such as preferences by requester
134
+ # id: `map(:prefs, [email: :boolean], keys: :id)`, or nameless as a value.
135
+ # Keys are cast to `keys:` (string, id or integer) and stay as data, so
136
+ # they are not symbolized.
137
+ def map(*positional, **options)
138
+ consume([positional, options])
139
+ raise SchemaError, "map takes a value type, after a name when it declares a key" unless [1, 2].include?(positional.size)
140
+
141
+ *names, value = positional
142
+ raise SchemaError, "map #{names.first.inspect} needs a symbol name" unless names.all?(Symbol)
143
+
144
+ item = Declaration.from_literal(value)
145
+ names.empty? ? describe(:map, options, item:) : declare(:map, names, options, item: item_node(item))
146
+ end
147
+
148
+ # A value of one of several types, picked by its JSON type without
149
+ # converting between them, as in `one_of(:string, array(:string))`.
150
+ def one_of(*alternatives, **options)
151
+ consume([alternatives, options])
152
+ alternatives = alternatives.map { |alternative| Declaration.from_literal(alternative) }
153
+ json_types = alternatives.map { |alternative| JSON_TYPES.fetch(alternative.type) { raise SchemaError, "one_of cannot hold #{alternative.type}" } }
154
+ raise SchemaError, "one_of needs two or more alternatives of different JSON types, got #{json_types.inspect}" if json_types.size < 2 || json_types.uniq.size < json_types.size
155
+
156
+ describe(:one_of, options, children: alternatives.map { |alternative| [nil, alternative] })
157
+ end
158
+
159
+ # Merges the keys of another schema: a fragment, one declared with
160
+ # typed_params on this controller or an ancestor, one on `owner`, or a
161
+ # global definition.
162
+ def use(*owner, name)
163
+ schema = name.is_a?(Schema) ? name : ActiveTypedParams.find_schema(owner.first || @scope.owner, name)
164
+ nodes = schema.root ? schema.nodes.first.children : schema.nodes
165
+ nodes.each { |node| @nodes[node.key] = node }
166
+ end
167
+
168
+ private
169
+
170
+ def with_presence(required)
171
+ previous = @required
172
+ @required = required
173
+ yield
174
+ ensure
175
+ @required = previous
176
+ end
177
+
178
+ def describe(type, options, **node_options)
179
+ required = presence_option(options)
180
+ described(Declaration.build(type, required:, options: options.except(*PRESENCE_OPTIONS), **node_options))
181
+ end
182
+
183
+ def described(declaration)
184
+ @scope.described[declaration] = true
185
+ declaration
186
+ end
187
+
188
+ def consume(value)
189
+ case value
190
+ when Declaration
191
+ @scope.described.delete(value)
192
+ value.children&.each { |_, child| consume(child) }
193
+ consume(value.item)
194
+ when Hash then value.each_value { |child| consume(child) }
195
+ when Array then value.each { |child| consume(child) }
196
+ end
197
+ end
198
+
199
+ # A nameless declaration that no key received, such as `hash(:a, :b)` on
200
+ # its own line, would otherwise vanish without declaring anything.
201
+ def check_described
202
+ declaration = @scope.described.keys.first
203
+ return if declaration.nil?
204
+
205
+ raise SchemaError, "a nameless #{declaration.type} was declared but given to no key; " \
206
+ "pass it as a value, as in `key: #{declaration.type}(…)`, or name it, as in `#{declaration.type}(:key, …)`"
207
+ end
208
+
209
+ def child_builder
210
+ Builder.new(required: @required, scope: @scope)
211
+ end
212
+
213
+ def declare(type, names, options, **node_options)
214
+ raise SchemaError, "unknown type #{type.inspect}" unless TYPES.include?(type)
215
+
216
+ explicit = presence_option(options)
217
+ required = explicit.nil? ? @required : explicit
218
+ options = validate_options(type, names, options.except(*PRESENCE_OPTIONS), required:)
219
+ names.each do |name|
220
+ node = Node.build(key: name, type:, required:, **options, **node_options)
221
+ @nodes[node.key] = node
222
+ end
223
+ end
224
+
225
+ def presence_option(options)
226
+ if options.key?(:required) && options.key?(:optional)
227
+ raise SchemaError, "required: and optional: cannot both be given"
228
+ elsif options.key?(:required)
229
+ options[:required]
230
+ elsif options.key?(:optional)
231
+ !options[:optional]
232
+ end
233
+ end
234
+
235
+ def apply_declaration(name, declaration)
236
+ declaration = Inference.from_attribute(declaration.source, name) if declaration.type == :attribute
237
+ required = declaration.required.nil? ? @required : declaration.required
238
+ with_presence(required) do
239
+ case declaration.type
240
+ when :hash then declare(:hash, [name], declaration.options, children: children_nodes(declaration))
241
+ when :array then declare(:array, [name], declaration.options, item: item_node(declaration.item))
242
+ when :map then declare(:map, [name], declaration.options, item: item_node(declaration.item))
243
+ when :one_of then declare(:one_of, [name], declaration.options, children: declaration.children.map { |_, alternative| item_node(alternative) })
244
+ when :enum then enum(name, declaration.source, **declaration.options)
245
+ else declare(declaration.type, [name], declaration.options)
246
+ end
247
+ end
248
+ end
249
+
250
+ def children_from(block)
251
+ child_builder.tap { |builder| builder.instance_exec(&block) }.nodes
252
+ end
253
+
254
+ # A block given to a nameless `hash` runs here, once the presence of the
255
+ # declaration it belongs to is known.
256
+ def children_nodes(declaration)
257
+ return children_from(declaration.block) if declaration.block
258
+
259
+ children = declaration.children || []
260
+ return children if children.empty? || children.first.is_a?(Node)
261
+
262
+ builder = child_builder
263
+ children.each { |child_name, child| builder.send(:apply_declaration, child_name, child) }
264
+ builder.nodes
265
+ end
266
+
267
+ def item_literal?(value)
268
+ value.is_a?(Declaration) || value.is_a?(Array) || (value.is_a?(Symbol) && LITERAL_NAMES.include?(value))
269
+ end
270
+
271
+ def array_item(label, type, extras, block)
272
+ if block
273
+ raise SchemaError, "array #{label} takes either a block or an item type" if type || extras.any?
274
+
275
+ item_from_block(label, block)
276
+ elsif type.is_a?(Declaration)
277
+ raise SchemaError, "array #{label} item options belong on the item declaration" if extras.any?
278
+
279
+ type
280
+ elsif type
281
+ Declaration.from_literal(type).merge_options(extras)
282
+ elsif extras.any?
283
+ Declaration.from_list([extras])
284
+ else
285
+ raise SchemaError, "array #{label} needs an item type, key: type pairs or a block"
286
+ end
287
+ end
288
+
289
+ def item_from_block(label, block)
290
+ builder = child_builder
291
+ item = builder.instance_exec(&block)
292
+ consume(item)
293
+ unless item.is_a?(Declaration) && builder.nodes.empty?
294
+ raise SchemaError, "array #{label} block must return an item type, such as `hash do … end` or `string(allow_nil: true)`"
295
+ end
296
+
297
+ item
298
+ end
299
+
300
+ # An item is always present or nil, so it is built as required; `optional:`
301
+ # on the item makes nil items valid, and the keys of an item hash take the
302
+ # item's presence or the array's.
303
+ def item_node(item)
304
+ return item if item.is_a?(Node)
305
+ raise SchemaError, "attribute_from reads the attribute named by its key, so it cannot describe an array item" if item.type == :attribute
306
+
307
+ unsupported = item.options.keys & ITEM_UNSUPPORTED_OPTIONS
308
+ raise SchemaError, "array items do not take #{unsupported.inspect}" if unsupported.any?
309
+
310
+ options = item.options.dup
311
+ options[:allow_nil] = true if item.required == false && !options.key?(:allow_nil)
312
+ options[:allow_blank] = true if item.type == :string && !options.key?(:allow_blank)
313
+ children_presence = item.required.nil? ? @required : item.required
314
+ parts = case item.type
315
+ when :hash then {children: with_presence(children_presence) { children_nodes(item) }}
316
+ when :array, :map then {item: item_node(item.item)}
317
+ when :one_of then {children: item.children.map { |_, alternative| item_node(alternative) }}
318
+ when :enum then {enum_map: EnumAdapter.resolve(item.source)}
319
+ else {}
320
+ end
321
+ Node.build(key: nil, type: item.type, required: true, **validate_options(item.type, [nil], options, required: true), **parts)
322
+ end
323
+
324
+ def map_key(label, type)
325
+ raise SchemaError, "#{label} keys: must be one of #{MAP_KEY_TYPES.inspect}" unless MAP_KEY_TYPES.include?(type)
326
+
327
+ Node.build(key: nil, type:, required: true, max_length: (DEFAULT_STRING_MAX_LENGTH if type == :string), bytes: (8 if type == :id))
328
+ end
329
+
330
+ def validate_options(type, names, options, required:)
331
+ label = "#{type} #{names.map { |name| name || "item" }.inspect}"
332
+ unknown = options.keys - OPTIONS.fetch(type)
333
+ raise SchemaError, "#{label} does not accept #{unknown.inspect}" if unknown.any?
334
+ raise SchemaError, "#{label} is required, so default: would never apply" if required && options.key?(:default)
335
+ raise SchemaError, "#{label} takes allow_if: or allow_unless:, not both" if options.key?(:allow_if) && options.key?(:allow_unless)
336
+ raise SchemaError, "#{label} invalid: must be one of #{INVALID_MODES.inspect}" unless INVALID_MODES.include?(options.fetch(:invalid, :raise))
337
+ raise SchemaError, "#{label} is required, so invalid: :drop would leave it missing" if required && options[:invalid] == :drop
338
+ raise SchemaError, "#{label} bytes: must be 4 or 8" if type == :id && !ID_BYTES.include?(options.fetch(:bytes, 8))
339
+ if options.key?(:in) && !options[:in].is_a?(Array) && !options[:in].is_a?(Set) && !options[:in].is_a?(Range) && !options[:in].is_a?(Proc)
340
+ raise SchemaError, "#{label} in: must be an Array, Set, Range or Proc"
341
+ end
342
+
343
+ options = options.dup
344
+ options[:condition] = [:if, options.delete(:allow_if)] if options.key?(:allow_if)
345
+ options[:condition] = [:unless, options.delete(:allow_unless)] if options.key?(:allow_unless)
346
+ # Optional keys accept null unless told otherwise; strings, arrays and maps are capped unless told otherwise.
347
+ options[:allow_nil] = true if !required && !options.key?(:allow_nil)
348
+ options[:max_length] = DEFAULT_STRING_MAX_LENGTH if type == :string && !options.key?(:max_length)
349
+ options[:max_length] = DEFAULT_COLLECTION_MAX_LENGTH if (type == :array || type == :map) && !options.key?(:max_length)
350
+ options[:bytes] = options.fetch(:bytes, 8) if type == :id
351
+ options[:map_key] = map_key(label, options.delete(:keys) || :string) if type == :map
352
+ options[:default] = Runtime.deep_freeze(options[:default]) if options.key?(:default) && !options[:default].is_a?(Proc)
353
+ options[:in] = options[:in].to_set.freeze if options[:in].is_a?(Array)
354
+ options
355
+ end
356
+ end
357
+ end