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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +20 -0
- data/README.md +534 -0
- data/lib/active_typed_params/builder.rb +357 -0
- data/lib/active_typed_params/compiler.rb +360 -0
- data/lib/active_typed_params/controller.rb +29 -0
- data/lib/active_typed_params/declaration.rb +90 -0
- data/lib/active_typed_params/enum_adapter.rb +28 -0
- data/lib/active_typed_params/errors.rb +78 -0
- data/lib/active_typed_params/inference.rb +264 -0
- data/lib/active_typed_params/inline.rb +94 -0
- data/lib/active_typed_params/node.rb +70 -0
- data/lib/active_typed_params/railtie.rb +19 -0
- data/lib/active_typed_params/runtime.rb +182 -0
- data/lib/active_typed_params/schema.rb +176 -0
- data/lib/active_typed_params/version.rb +5 -0
- data/lib/active_typed_params.rb +148 -0
- metadata +92 -0
|
@@ -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
|