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,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Generates one straight-line Ruby method for a schema. Nested hashes are
|
|
5
|
+
# inlined, arrays become loops, and allowlists, enum maps, procs and
|
|
6
|
+
# non-literal defaults become constants on the module the method is defined in.
|
|
7
|
+
class Compiler
|
|
8
|
+
IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*[?!]?\z/
|
|
9
|
+
RUNTIME = "ActiveTypedParams::Runtime"
|
|
10
|
+
BLANK_AS_NIL = (SCALAR_TYPES - [:string]).freeze
|
|
11
|
+
JSON_TESTS = {
|
|
12
|
+
string: ->(value) { "#{value}.is_a?(String)" },
|
|
13
|
+
number: ->(value) { "#{value}.is_a?(Integer) || #{value}.is_a?(Float)" },
|
|
14
|
+
boolean: ->(value) { "#{value} == true || #{value} == false" },
|
|
15
|
+
array: ->(value) { "#{value}.is_a?(Array)" },
|
|
16
|
+
object: ->(value) { "#{RUNTIME}.unwrap_hash(#{value})" },
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :constants
|
|
20
|
+
|
|
21
|
+
def initialize(schema)
|
|
22
|
+
@schema = schema
|
|
23
|
+
@constants = {}
|
|
24
|
+
@counters = Hash.new(0)
|
|
25
|
+
@lines = []
|
|
26
|
+
@indent = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def source
|
|
30
|
+
@source ||= generate
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :schema
|
|
36
|
+
|
|
37
|
+
# The magic comment keeps key lookups and literals from allocating per request.
|
|
38
|
+
def generate
|
|
39
|
+
emit "# frozen_string_literal: true"
|
|
40
|
+
emit "def #{schema.name}(params = #{params_default}, request = #{request_default})"
|
|
41
|
+
indented do
|
|
42
|
+
emit "#{schema.memo_ivar} ||= begin" if schema.memoize?
|
|
43
|
+
indented(schema.memoize? ? 1 : 0) do
|
|
44
|
+
emit "h0 = #{RUNTIME}.#{schema.params_source ? "source_hash" : "raw"}(params)"
|
|
45
|
+
emit "r0 = {}"
|
|
46
|
+
emit "n0 = 0" if counting?
|
|
47
|
+
schema.nodes.each { |node| compile_key(node, 0, []) }
|
|
48
|
+
if schema.root
|
|
49
|
+
emit "r0[#{schema.root.inspect}]"
|
|
50
|
+
else
|
|
51
|
+
compile_unknown_check("h0", schema.nodes, [], root: schema.params_source.nil?)
|
|
52
|
+
emit "r0"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
emit "end" if schema.memoize?
|
|
56
|
+
end
|
|
57
|
+
emit "rescue ActiveTypedParams::InvalidParameter => error"
|
|
58
|
+
indented do
|
|
59
|
+
emit "#{RUNTIME}.report_invalid(error, self)"
|
|
60
|
+
emit "raise"
|
|
61
|
+
end
|
|
62
|
+
emit "end"
|
|
63
|
+
@lines.join("\n") << "\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def params_default
|
|
67
|
+
schema.params_source ? "instance_exec(&#{constant("SOURCE", schema.params_source)})" : "self.params"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def request_default
|
|
71
|
+
schema.request_available? ? "self.request" : "nil"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def compile_key(node, depth, path)
|
|
75
|
+
key = node.key.inspect
|
|
76
|
+
key_path = [*path, node.key]
|
|
77
|
+
hash = "h#{depth}"
|
|
78
|
+
result = "r#{depth}[#{node.result_key.inspect}]"
|
|
79
|
+
value = "v#{depth}"
|
|
80
|
+
|
|
81
|
+
with_condition(node) do
|
|
82
|
+
emit "if #{hash}.key?(#{key})"
|
|
83
|
+
indented do
|
|
84
|
+
emit "n#{depth} += 1" if counting?
|
|
85
|
+
emit "#{value} = #{hash}[#{key}]"
|
|
86
|
+
with_drop(node, key_path, result) do
|
|
87
|
+
emit "if #{nil_test(node, value)}"
|
|
88
|
+
indented do
|
|
89
|
+
if node.allow_nil
|
|
90
|
+
emit "#{result} = nil"
|
|
91
|
+
elsif node.default?
|
|
92
|
+
emit "#{result} = #{default_expression(node.default)}"
|
|
93
|
+
else
|
|
94
|
+
emit fail_call(node.required ? :missing : :invalid_type, key_path, node.type)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
emit "else"
|
|
98
|
+
indented { compile_value(node, value, depth, key_path, result) }
|
|
99
|
+
emit "end"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
if node.required
|
|
103
|
+
emit "else"
|
|
104
|
+
indented { emit fail_call(:missing, key_path) }
|
|
105
|
+
elsif node.default?
|
|
106
|
+
emit "else"
|
|
107
|
+
indented { emit "#{result} = #{default_expression(node.default)}" }
|
|
108
|
+
end
|
|
109
|
+
emit "end"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def with_drop(node, path, result)
|
|
114
|
+
return yield unless node.invalid == :drop
|
|
115
|
+
|
|
116
|
+
emit "begin"
|
|
117
|
+
indented { yield }
|
|
118
|
+
emit "rescue ActiveTypedParams::InvalidParameter => error"
|
|
119
|
+
indented do
|
|
120
|
+
emit "raise unless error.code == :invalid_type && error.path == #{path_literal(path)}"
|
|
121
|
+
emit "#{result} = #{default_expression(node.default)}" if node.default?
|
|
122
|
+
end
|
|
123
|
+
emit "end"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Forms send an empty string for a cleared number, date, select or file
|
|
127
|
+
# input, which ActiveModel and ActiveInteraction both read as nil.
|
|
128
|
+
def nil_test(node, value)
|
|
129
|
+
blank_is_nil = BLANK_AS_NIL.include?(node.type) && !(node.type == :enum && node.enum_map.key?(""))
|
|
130
|
+
blank_is_nil ? "#{value}.nil? || #{value} == \"\"" : "#{value}.nil?"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def with_condition(node)
|
|
134
|
+
if node.condition
|
|
135
|
+
kind, predicate = node.condition
|
|
136
|
+
test = predicate_expression(predicate)
|
|
137
|
+
emit "if #{kind == :if ? test : "!#{test}"}"
|
|
138
|
+
indented { yield }
|
|
139
|
+
emit "end"
|
|
140
|
+
else
|
|
141
|
+
yield
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def predicate_expression(predicate)
|
|
146
|
+
if predicate.is_a?(Proc)
|
|
147
|
+
"instance_exec(&#{constant("PROC", predicate)})"
|
|
148
|
+
elsif IDENTIFIER.match?(predicate.to_s)
|
|
149
|
+
predicate.to_s
|
|
150
|
+
else
|
|
151
|
+
"__send__(#{predicate.to_sym.inspect})"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def compile_value(node, value, depth, path, target)
|
|
156
|
+
case node.type
|
|
157
|
+
when :string then compile_string(node, value, path)
|
|
158
|
+
when :integer then compile_integer(node, value, path)
|
|
159
|
+
when :id then compile_id(node, value, path)
|
|
160
|
+
when :decimal, :float, :boolean
|
|
161
|
+
emit "#{value} = #{RUNTIME}.#{node.type}(#{value})"
|
|
162
|
+
emit "#{fail_call(:invalid_type, path, node.type)} if #{value}.nil?"
|
|
163
|
+
compile_allowed(node, value, path)
|
|
164
|
+
when :date, :time
|
|
165
|
+
emit "#{value} = #{RUNTIME}.#{node.type}(#{value})"
|
|
166
|
+
emit "#{fail_call(:invalid_type, path, node.type)} if #{value}.nil?"
|
|
167
|
+
compile_allowed(node, value, path)
|
|
168
|
+
when :enum
|
|
169
|
+
emit "#{value} = #{constant("ENUM", node.enum_map)}.fetch(#{value}) { #{fail_call(:not_allowed, path)} }"
|
|
170
|
+
when :file
|
|
171
|
+
emit "#{fail_call(:invalid_type, path, :file)} unless #{RUNTIME}.file?(#{value})"
|
|
172
|
+
when :any
|
|
173
|
+
emit "#{value} = #{RUNTIME}.plain(#{value})"
|
|
174
|
+
when :hash
|
|
175
|
+
compile_hash(node, value, depth, path, target)
|
|
176
|
+
return
|
|
177
|
+
when :array
|
|
178
|
+
compile_array(node, value, depth, path, target)
|
|
179
|
+
return
|
|
180
|
+
when :map
|
|
181
|
+
compile_map(node, value, depth, path, target)
|
|
182
|
+
return
|
|
183
|
+
when :one_of
|
|
184
|
+
compile_one_of(node, value, depth, path, target)
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
emit "#{target} = #{value}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def compile_string(node, value, path)
|
|
191
|
+
emit "#{fail_call(:invalid_type, path, :string)} unless #{value}.is_a?(String) && !#{value}.include?(\"\\0\")"
|
|
192
|
+
emit "#{fail_call(:blank, path)} if #{value}.empty?" if node.required && !node.allow_blank
|
|
193
|
+
emit "#{fail_call(:too_long, path)} if #{value}.length > #{node.max_length}" if node.max_length
|
|
194
|
+
compile_allowed(node, value, path)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def compile_integer(node, value, path)
|
|
198
|
+
emit "#{value} = #{RUNTIME}.integer(#{value}) if #{value}.is_a?(String)"
|
|
199
|
+
emit "#{fail_call(:invalid_type, path, :integer)} unless #{value}.is_a?(Integer) && #{value}.bit_length < 64"
|
|
200
|
+
compile_allowed(node, value, path)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def compile_id(node, value, path)
|
|
204
|
+
emit "#{value} = #{RUNTIME}.integer(#{value}) if #{value}.is_a?(String)"
|
|
205
|
+
limit = 2**(node.bytes * 8 - 1)
|
|
206
|
+
emit "#{fail_call(:invalid_type, path, :id)} unless #{value}.is_a?(Integer) && #{value} > 0 && #{value} < #{limit}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def compile_allowed(node, value, path)
|
|
210
|
+
return if node.in.nil?
|
|
211
|
+
|
|
212
|
+
test = case node.in
|
|
213
|
+
when Proc then "#{RUNTIME}.allowed?(instance_exec(&#{constant("PROC", node.in)}), #{value})"
|
|
214
|
+
when Range then "#{constant("RANGE", node.in)}.cover?(#{value})"
|
|
215
|
+
else "#{constant("SET", node.in)}.include?(#{value})"
|
|
216
|
+
end
|
|
217
|
+
emit "#{fail_call(:not_allowed, path)} unless #{test}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def compile_hash(node, value, depth, path, target)
|
|
221
|
+
inner = depth + 1
|
|
222
|
+
emit "#{value} = #{RUNTIME}.parse_json(#{value}, #{path_literal(path)}, :hash) if #{value}.is_a?(String)" if node.json
|
|
223
|
+
emit "h#{inner} = #{RUNTIME}.unwrap_hash(#{value}) || #{fail_call(:invalid_type, path, :hash)}"
|
|
224
|
+
emit "r#{inner} = {}"
|
|
225
|
+
emit "n#{inner} = 0" if counting?
|
|
226
|
+
node.children.each { |child| compile_key(child, inner, path) }
|
|
227
|
+
if node.open
|
|
228
|
+
declared = constant("KEYS", Set.new(node.children.flat_map { |child| [child.key, child.result_key.to_s] }).freeze)
|
|
229
|
+
emit "h#{inner}.each { |k#{inner}, x#{inner}| r#{inner}[k#{inner}.to_sym] = #{RUNTIME}.plain(x#{inner}) unless #{declared}.include?(k#{inner}) }"
|
|
230
|
+
else
|
|
231
|
+
compile_unknown_check("h#{inner}", node.children, path, root: false)
|
|
232
|
+
end
|
|
233
|
+
emit "#{target} = r#{inner}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def compile_array(node, value, depth, path, target)
|
|
237
|
+
inner = depth + 1
|
|
238
|
+
item = "e#{inner}"
|
|
239
|
+
index = :"i#{inner}"
|
|
240
|
+
item_path = [*path, index]
|
|
241
|
+
emit "#{value} = #{RUNTIME}.parse_json(#{value}, #{path_literal(path)}, :array) if #{value}.is_a?(String)" if node.json
|
|
242
|
+
emit "#{fail_call(:invalid_type, path, :array)} unless #{value}.is_a?(Array)"
|
|
243
|
+
emit "#{fail_call(:too_long, path)} if #{value}.length > #{node.max_length}" if node.max_length
|
|
244
|
+
emit "a#{inner} = Array.new(#{value}.length)"
|
|
245
|
+
emit "#{value}.each_with_index do |#{item}, #{index}|"
|
|
246
|
+
indented do
|
|
247
|
+
emit "if #{nil_test(node.item, item)}"
|
|
248
|
+
indented do
|
|
249
|
+
if node.item.allow_nil
|
|
250
|
+
emit "a#{inner}[#{index}] = nil"
|
|
251
|
+
else
|
|
252
|
+
emit fail_call(:invalid_type, item_path, node.item.type)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
emit "else"
|
|
256
|
+
indented { compile_value(node.item, item, inner, item_path, "a#{inner}[#{index}]") }
|
|
257
|
+
emit "end"
|
|
258
|
+
end
|
|
259
|
+
emit "end"
|
|
260
|
+
emit "#{target} = a#{inner}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def compile_one_of(node, value, depth, path, target)
|
|
264
|
+
node.children.each_with_index do |alternative, index|
|
|
265
|
+
emit "#{index.zero? ? "if" : "elsif"} #{JSON_TESTS.fetch(JSON_TYPES.fetch(alternative.type)).call(value)}"
|
|
266
|
+
indented { compile_value(alternative, value, depth, path, target) }
|
|
267
|
+
end
|
|
268
|
+
emit "else"
|
|
269
|
+
indented { emit fail_call(:invalid_type, path, :one_of) }
|
|
270
|
+
emit "end"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Each key is cast in place by the key node, so an error names the raw key.
|
|
274
|
+
def compile_map(node, value, depth, path, target)
|
|
275
|
+
inner = depth + 1
|
|
276
|
+
raw_key = "q#{inner}"
|
|
277
|
+
key = "k#{inner}"
|
|
278
|
+
item = "e#{inner}"
|
|
279
|
+
key_path = [*path, raw_key.to_sym]
|
|
280
|
+
emit "#{value} = #{RUNTIME}.parse_json(#{value}, #{path_literal(path)}, :map) if #{value}.is_a?(String)" if node.json
|
|
281
|
+
emit "h#{inner} = #{RUNTIME}.unwrap_hash(#{value}) || #{fail_call(:invalid_type, path, :map)}"
|
|
282
|
+
emit "#{fail_call(:too_long, path)} if h#{inner}.size > #{node.max_length}" if node.max_length
|
|
283
|
+
emit "r#{inner} = {}"
|
|
284
|
+
emit "h#{inner}.each do |#{raw_key}, #{item}|"
|
|
285
|
+
indented do
|
|
286
|
+
emit "#{raw_key} = #{raw_key}.to_s"
|
|
287
|
+
emit "#{key} = #{raw_key}"
|
|
288
|
+
compile_value(node.map_key, key, inner, key_path, key)
|
|
289
|
+
emit "if #{nil_test(node.item, item)}"
|
|
290
|
+
indented do
|
|
291
|
+
if node.item.allow_nil
|
|
292
|
+
emit "r#{inner}[#{key}] = nil"
|
|
293
|
+
else
|
|
294
|
+
emit fail_call(:invalid_type, key_path, node.item.type)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
emit "else"
|
|
298
|
+
indented { compile_value(node.item, item, inner, key_path, "r#{inner}[#{key}]") }
|
|
299
|
+
emit "end"
|
|
300
|
+
end
|
|
301
|
+
emit "end"
|
|
302
|
+
emit "#{target} = r#{inner}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A hash whose every key was matched holds nothing unknown, so the check
|
|
306
|
+
# and its allocations are skipped on the common path.
|
|
307
|
+
def compile_unknown_check(hash, nodes, path, root:)
|
|
308
|
+
return unless counting?
|
|
309
|
+
|
|
310
|
+
declared = constant("KEYS", Set.new(nodes.map(&:key)).freeze)
|
|
311
|
+
request = root ? "request" : "nil"
|
|
312
|
+
emit "#{RUNTIME}.check_unknown(#{hash}, #{declared}, #{schema.unknown_mode.inspect}, #{path_expression(path)}, self, #{request}) if #{hash}.size > n#{hash[1..]}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def counting?
|
|
316
|
+
schema.unknown_mode != :ignore
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# A path inside an array carries index locals, so it is built when used;
|
|
320
|
+
# a static one is a constant.
|
|
321
|
+
def path_expression(path)
|
|
322
|
+
path.any?(Symbol) ? path_literal(path) : constant("PATH", path.freeze)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def default_expression(default)
|
|
326
|
+
case default
|
|
327
|
+
when Proc then "instance_exec(&#{constant("PROC", default)})"
|
|
328
|
+
when Integer, Float, String, Symbol, true, false, nil then default.inspect
|
|
329
|
+
else constant("DEFAULT", default)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def fail_call(code, path, type = nil)
|
|
334
|
+
arguments = [code.inspect, path_literal(path), type&.inspect].compact.join(", ")
|
|
335
|
+
"#{RUNTIME}.fail(#{arguments})"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def path_literal(path)
|
|
339
|
+
"[#{path.map { |segment| segment.is_a?(Symbol) ? segment.to_s : segment.inspect }.join(", ")}]"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def constant(prefix, value)
|
|
343
|
+
@counters[prefix] += 1
|
|
344
|
+
name = "#{prefix}_#{@counters[prefix]}"
|
|
345
|
+
@constants[name] = value
|
|
346
|
+
name
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def emit(line)
|
|
350
|
+
@lines << (" " * @indent) + line
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def indented(levels = 1)
|
|
354
|
+
@indent += levels
|
|
355
|
+
yield
|
|
356
|
+
ensure
|
|
357
|
+
@indent -= levels
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# Adds `typed_params` to a controller (or a concern). The declared method is
|
|
5
|
+
# a stub until its first call, which compiles the schema and replaces it.
|
|
6
|
+
module Controller
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def typed_params
|
|
12
|
+
Inline.new(self)
|
|
13
|
+
end
|
|
14
|
+
private :typed_params
|
|
15
|
+
|
|
16
|
+
module ClassMethods
|
|
17
|
+
def typed_params(name, **options, &block)
|
|
18
|
+
schema = Schema.new(owner: self, name:, options:, block:)
|
|
19
|
+
ActiveTypedParams.register(schema)
|
|
20
|
+
stub = Module.new
|
|
21
|
+
stub.define_method(name) { schema.call(self) }
|
|
22
|
+
stub.send(:private, name)
|
|
23
|
+
include stub
|
|
24
|
+
|
|
25
|
+
schema
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
# A declaration without a name: what a type method returns when called
|
|
5
|
+
# without one, what a literal resolves to, and what inference produces.
|
|
6
|
+
# `required` is nil when the declaration inherits its presence.
|
|
7
|
+
#
|
|
8
|
+
# Inline reads look their schema up by the declarations they are given on
|
|
9
|
+
# every request, so unlike a Data, the hash is computed once when built.
|
|
10
|
+
class Declaration
|
|
11
|
+
attr_reader :type, :required, :options, :source, :children, :item, :block, :hash
|
|
12
|
+
|
|
13
|
+
def self.build(type, required: nil, options: {}, source: nil, children: nil, item: nil, block: nil)
|
|
14
|
+
new(type, required, options, source, children, item, block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(type, required, options, source, children, item, block)
|
|
18
|
+
@type = type
|
|
19
|
+
@required = required
|
|
20
|
+
@options = options
|
|
21
|
+
@source = source
|
|
22
|
+
@children = children
|
|
23
|
+
@item = item
|
|
24
|
+
@block = block
|
|
25
|
+
@hash = [type, required, options, source, children, item, block].hash
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def eql?(other)
|
|
30
|
+
equal?(other) || (other.instance_of?(Declaration) && hash == other.hash && type == other.type &&
|
|
31
|
+
required == other.required && options == other.options && source == other.source &&
|
|
32
|
+
children == other.children && item == other.item && block == other.block)
|
|
33
|
+
end
|
|
34
|
+
alias_method :==, :eql?
|
|
35
|
+
|
|
36
|
+
def with(type: self.type, required: self.required, options: self.options, source: self.source, children: self.children, item: self.item, block: self.block)
|
|
37
|
+
Declaration.new(type, required, options, source, children, item, block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A signed blob id replaces the file and null removes it; the attachment
|
|
41
|
+
# object a form posts back for an unchanged file is dropped.
|
|
42
|
+
def self.direct_upload(**options)
|
|
43
|
+
build(:string, options: {invalid: :drop, **options})
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.from_literal(value)
|
|
47
|
+
case value
|
|
48
|
+
when Declaration then value
|
|
49
|
+
when Symbol
|
|
50
|
+
raise SchemaError, "#{value.inspect} is not a type usable in a literal" unless LITERAL_NAMES.include?(value)
|
|
51
|
+
|
|
52
|
+
case value
|
|
53
|
+
when :text then build(:string, options: Inference.text_options)
|
|
54
|
+
when :direct_upload then direct_upload
|
|
55
|
+
else build(value)
|
|
56
|
+
end
|
|
57
|
+
when Array then from_list(value)
|
|
58
|
+
when Hash then value.empty? ? build(:hash, options: {open: true}, children: []) : from_list([value])
|
|
59
|
+
when Class, Module
|
|
60
|
+
raise SchemaError, "no enum adapter handles #{value.inspect}" unless EnumAdapter.lookup(value)
|
|
61
|
+
|
|
62
|
+
build(:enum, source: value)
|
|
63
|
+
else
|
|
64
|
+
raise SchemaError, "#{value.inspect} is not a type symbol, a declaration, a list, {} or an enum class"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.from_list(list)
|
|
69
|
+
raise SchemaError, "an empty list declares no keys; use {} for a hash of anything" if list.empty?
|
|
70
|
+
|
|
71
|
+
pairs = list.last.is_a?(Hash) ? list.last : {}
|
|
72
|
+
symbols = pairs.empty? ? list : list[0...-1]
|
|
73
|
+
children = symbols.map { |name|
|
|
74
|
+
raise SchemaError, "#{name.inspect} in a hash literal must be a symbol or key: type pair" unless name.is_a?(Symbol)
|
|
75
|
+
|
|
76
|
+
[name, build(:string)]
|
|
77
|
+
}
|
|
78
|
+
children += pairs.map { |name, value| [name, from_literal(value)] }
|
|
79
|
+
build(:hash, children:)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def optional(allow_nil: false)
|
|
83
|
+
with(required: false, options: allow_nil ? options.merge(allow_nil: true) : options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def merge_options(extra)
|
|
87
|
+
with(options: options.merge(extra))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
module EnumAdapter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def resolve(source)
|
|
8
|
+
map = lookup(source)
|
|
9
|
+
raise SchemaError, "no enum adapter handles #{source.inspect}" unless map
|
|
10
|
+
|
|
11
|
+
map.each_with_object({}) { |(raw, cast), result|
|
|
12
|
+
result[raw] = cast
|
|
13
|
+
result[raw.to_s] = cast
|
|
14
|
+
}.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def lookup(source)
|
|
18
|
+
ActiveTypedParams.config.enum_adapters.lazy.map { |adapter| adapter.call(source) }.find(&:itself) || builtin(source)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def builtin(source)
|
|
22
|
+
case source
|
|
23
|
+
when Array, Set then source.to_h { |value| [value, value] }
|
|
24
|
+
when Hash then source
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTypedParams
|
|
4
|
+
class SchemaError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a request parameter does not match its declaration. Subclasses
|
|
7
|
+
# ActionController::BadRequest when ActionPack is loaded, so Rails and
|
|
8
|
+
# existing `rescue_from` handlers treat it as a client error.
|
|
9
|
+
class InvalidParameter < (defined?(ActionController::BadRequest) ? ActionController::BadRequest : StandardError)
|
|
10
|
+
TYPE_WORDS = {
|
|
11
|
+
string: "a string",
|
|
12
|
+
integer: "an integer",
|
|
13
|
+
id: "an id",
|
|
14
|
+
decimal: "a decimal",
|
|
15
|
+
float: "a number",
|
|
16
|
+
boolean: "a boolean",
|
|
17
|
+
date: "a date (YYYY-MM-DD)",
|
|
18
|
+
time: "a time (ISO 8601)",
|
|
19
|
+
enum: "one of the allowed values",
|
|
20
|
+
file: "a file",
|
|
21
|
+
hash: "an object",
|
|
22
|
+
map: "an object",
|
|
23
|
+
array: "an array",
|
|
24
|
+
any: "a value",
|
|
25
|
+
one_of: "one of the allowed types",
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :path, :code
|
|
29
|
+
|
|
30
|
+
def self.for(code, path, type = nil)
|
|
31
|
+
(UnprocessableParameter::CODES.include?(code) ? UnprocessableParameter : self).new(code, path, type)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def status
|
|
35
|
+
:bad_request
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(code, path, type = nil)
|
|
39
|
+
@code = code
|
|
40
|
+
@path = path.freeze
|
|
41
|
+
super("#{path.empty? ? "params" : path.join(".")} #{reason(type)}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def reason(type)
|
|
47
|
+
case code
|
|
48
|
+
when :missing then "is required"
|
|
49
|
+
when :blank then "can't be blank"
|
|
50
|
+
when :invalid_type then "must be #{TYPE_WORDS.fetch(type)}"
|
|
51
|
+
when :not_allowed then "is not an allowed value"
|
|
52
|
+
when :too_long then "is too long"
|
|
53
|
+
when :unknown then "is not a known parameter"
|
|
54
|
+
else raise ArgumentError, "unknown error code #{code.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Raised for a well-formed value that breaks a rule a person can trip in a
|
|
60
|
+
# form, such as a cap or an allowlist, so it answers 422 instead of 400. A
|
|
61
|
+
# wrong type, a missing key or an unknown one stays a bad request.
|
|
62
|
+
class UnprocessableParameter < InvalidParameter
|
|
63
|
+
CODES = %i[too_long blank not_allowed].freeze
|
|
64
|
+
|
|
65
|
+
def status
|
|
66
|
+
:unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Controllers without their own rescue_from answer through Rails' mapping,
|
|
71
|
+
# which matches the exact class name, so a BadRequest subclass would be a 500.
|
|
72
|
+
if defined?(ActionDispatch::ExceptionWrapper)
|
|
73
|
+
ActionDispatch::ExceptionWrapper.rescue_responses.merge!(
|
|
74
|
+
"ActiveTypedParams::InvalidParameter" => :bad_request,
|
|
75
|
+
"ActiveTypedParams::UnprocessableParameter" => :unprocessable_entity,
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|