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