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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ # Declares parameters from an ActiveInteraction's filters or a model's
5
+ # columns, so the expected values are written once.
6
+ module Inference
7
+ ID_BYTES = [4, 8].freeze
8
+
9
+ module_function
10
+
11
+ # Filters whose values arrive in requests. Records and plain objects are
12
+ # passed by the controller, so they are skipped, as is a filter declared
13
+ # with `from_params: false`; an object filter whose class an enum adapter
14
+ # handles becomes an enum.
15
+ #
16
+ # Every input is optional and accepts null, at any depth, because the
17
+ # interaction reports a missing or null input as a form error and fills in
18
+ # the default for a null one; rejecting either here would preempt that. For
19
+ # the same reason a string the interaction validates the length of is not
20
+ # capped, and a date or time the filter parses itself stays a string.
21
+ def from_filter(filter, interaction = nil)
22
+ return if filter.options[:from_params] == false
23
+
24
+ declaration = from_custom_filter(filter) || from_builtin_filter(filter, interaction)
25
+ declaration&.optional(allow_nil: true)
26
+ end
27
+
28
+ def from_builtin_filter(filter, interaction)
29
+ case filter.class.slug
30
+ when :string, :symbol then uncapped(Declaration.build(:string), interaction, filter.name)
31
+ when :integer, :float, :decimal, :boolean, :file then Declaration.build(filter.class.slug)
32
+ when :date, :time then Declaration.build(filter.options.key?(:format) ? :string : filter.class.slug)
33
+ when :date_time then Declaration.build(:string)
34
+ when :hash then from_hash_filter(filter)
35
+ when :array then from_array_filter(filter)
36
+ when :object then from_object_filter(filter)
37
+ end
38
+ end
39
+
40
+ def from_custom_filter(filter)
41
+ ActiveTypedParams.config.filter_inferrers.lazy.map { |inferrer| inferrer.call(filter) }.find(&:itself)
42
+ end
43
+
44
+ def from_hash_filter(filter)
45
+ children = filter.filters.filter_map { |name, child| [name, from_filter(child)] if from_filter(child) }
46
+ options = filter.options.fetch(:strip, true) ? {} : {open: true}
47
+ Declaration.build(:hash, children:, options:)
48
+ end
49
+
50
+ def from_array_filter(filter)
51
+ item_filter = filter.filters[:"0"]
52
+ item = item_filter ? from_filter(item_filter) : Declaration.build(:any).optional(allow_nil: true)
53
+ Declaration.build(:array, item:) if item
54
+ end
55
+
56
+ def from_object_filter(filter)
57
+ klass = Object.const_get(filter.options.fetch(:class, filter.name).to_s.camelize)
58
+ Declaration.build(:enum, source: klass) if EnumAdapter.lookup(klass)
59
+ end
60
+
61
+ # Presence follows the schema. Nullability follows the column, unless the
62
+ # model validates presence and so reports a null itself; likewise a
63
+ # string the model validates the length of is not capped here.
64
+ def from_attribute(model, name)
65
+ name = name.to_s
66
+ attribute = model.respond_to?(:attribute_aliases) ? model.attribute_aliases.fetch(name, name) : name
67
+ attachment = from_attachment(model, attribute)
68
+ return attachment if attachment
69
+ return from_attribute_container(model, attribute) unless model.respond_to?(:columns_hash)
70
+
71
+ column = model.columns_hash[attribute] || raise(SchemaError, "#{model} has no column #{name.inspect}")
72
+ declaration = from_attribute_type(model, attribute) || from_column(model, attribute, column)
73
+ uncapped(declaration, model, attribute).merge_options(allow_nil: column.null || validates?(model, attribute, :presence))
74
+ end
75
+
76
+ # StoreModel and ActiveModel::Attributes classes declare types but no columns.
77
+ def from_attribute_container(model, name)
78
+ type = model.attribute_types[name] || raise(SchemaError, "#{model} has no attribute #{name.inspect}")
79
+ uncapped(from_type(type), model, name).merge_options(allow_nil: true)
80
+ end
81
+
82
+ def from_attachment(model, name)
83
+ reflection = model.reflect_on_attachment(name) if model.respond_to?(:reflect_on_attachment)
84
+ return if reflection.nil?
85
+
86
+ if reflection.macro == :has_many_attached
87
+ Declaration.build(:array, item: Declaration.build(:string), options: {invalid: :drop, allow_nil: true})
88
+ else
89
+ Declaration.direct_upload(allow_nil: true)
90
+ end
91
+ end
92
+
93
+ def from_attribute_type(model, name)
94
+ type = model.attribute_types[name]
95
+ return Declaration.build(:any) if defined?(ActiveRecord::Type::Serialized) && type.is_a?(ActiveRecord::Type::Serialized)
96
+
97
+ type && from_type(type, custom_only: true)
98
+ end
99
+
100
+ def from_type(type, custom_only: false)
101
+ custom = ActiveTypedParams.config.type_inferrers.lazy.map { |inferrer| inferrer.call(type) }.find(&:itself) || from_store_model(type)
102
+ return custom if custom || custom_only
103
+
104
+ case type.type
105
+ when :string then Declaration.build(:string)
106
+ when :text then Declaration.build(:string, options: text_options)
107
+ when :integer, :float, :decimal, :boolean, :date then Declaration.build(type.type)
108
+ when :datetime, :time then Declaration.build(:time)
109
+ else Declaration.build(:any)
110
+ end
111
+ end
112
+
113
+ # A StoreModel attribute is a hash of its model's attributes, or an array
114
+ # of them. StoreModel keeps the model class private.
115
+ def from_store_model(type)
116
+ return unless defined?(StoreModel::Types::One)
117
+
118
+ case type
119
+ when StoreModel::Types::One then store_model_hash(type.instance_variable_get(:@model_klass))
120
+ when StoreModel::Types::Many then Declaration.build(:array, item: store_model_hash(type.instance_variable_get(:@model_klass)))
121
+ end
122
+ end
123
+
124
+ def store_model_hash(model)
125
+ children = model.attribute_types.map { |name, type| [name, from_type(type).optional(allow_nil: true)] }
126
+ Declaration.build(:hash, children:)
127
+ end
128
+
129
+ def from_column(model, name, column)
130
+ element = from_scalar_column(model, name, column)
131
+ (column.respond_to?(:array) && column.array) ? Declaration.build(:array, item: element) : element
132
+ end
133
+
134
+ def from_scalar_column(model, name, column)
135
+ enum = model.defined_enums[name] if model.respond_to?(:defined_enums)
136
+ return Declaration.build(:string, options: {in: enum.keys}) if enum
137
+
138
+ case column.type
139
+ when :string then Declaration.build(:string, options: {max_length: column.limit}.compact)
140
+ when :text then Declaration.build(:string, options: text_options)
141
+ when :enum then Declaration.build(:string)
142
+ when :integer
143
+ if name.end_with?("_id")
144
+ Declaration.build(:id, options: {bytes: ID_BYTES.include?(column.limit) ? column.limit : 8})
145
+ else
146
+ Declaration.build(:integer)
147
+ end
148
+ when :decimal, :float, :boolean, :date then Declaration.build(column.type)
149
+ when :datetime, :timestamp, :timestamptz, :time then Declaration.build(:time)
150
+ when :json, :jsonb then Declaration.build(:any)
151
+ else raise SchemaError, "cannot infer a parameter type for the #{column.type.inspect} column #{name.inspect}"
152
+ end
153
+ end
154
+
155
+ def text_options
156
+ {max_length: ActiveTypedParams.config.text_max_length}
157
+ end
158
+
159
+ # A length validator on an array counts its items, so only a string is left
160
+ # for the owner to cap.
161
+ def uncapped(declaration, owner, name)
162
+ return declaration unless declaration.type == :string && validates?(owner, name, :length)
163
+
164
+ declaration.merge_options(text_options)
165
+ end
166
+
167
+ # The cap the model's attribute of the same name would get, or none when
168
+ # the model has no such attribute or no parameter type for it.
169
+ def model_cap(model, name)
170
+ from_attribute(model, name).options.slice(:max_length)
171
+ rescue SchemaError
172
+ {}
173
+ end
174
+
175
+ def validates?(owner, name, kind)
176
+ owner.respond_to?(:validators_on) && owner.validators_on(name.to_sym).any? { |validator| validator.kind == kind }
177
+ end
178
+
179
+ # `key: {option: value}` pairs add options to an inferred key, such as a
180
+ # condition or a presence flip, while its type still comes from the
181
+ # source; options named like a child of an inferred hash adjust that
182
+ # child. Unlike in `param`, a Hash here never declares nested keys:
183
+ # `key: type`, such as `key: hash(...)`, replaces the inferred type in place.
184
+ module BuilderMethods
185
+ # `model:` names the record the inputs are saved to, so string inputs take
186
+ # the caps of its columns of the same name.
187
+ def inputs_from(interaction, only: nil, except: nil, model: nil, **overrides)
188
+ filters = interaction.filters
189
+ available = filters.keys - ActiveTypedParams.config.ignored_filters.map(&:to_sym)
190
+ names = only ? restrict(available, only, "only") : available
191
+ names -= restrict(filters.keys, except, "except") if except
192
+ named = (Array(only).map(&:to_sym) | overrides.keys.map(&:to_sym))
193
+ names = filters.keys & (names | restrict(available, overrides.keys, "inputs_from"))
194
+ names.each do |name|
195
+ declaration = Inference.from_filter(filters[name], interaction)
196
+ raise SchemaError, "#{interaction}'s #{name.inspect} input cannot come from a request" if declaration.nil? && named.include?(name)
197
+
198
+ if model && declaration&.type == :string && !declaration.options.key?(:max_length)
199
+ declaration = declaration.merge_options(Inference.model_cap(model, name))
200
+ end
201
+
202
+ apply_declaration(name, override(declaration, overrides[name])) if declaration
203
+ end
204
+ end
205
+
206
+ # Infers the attribute named by the key it is given to, as in
207
+ # `address: attribute_from(User)`.
208
+ def attribute_from(model)
209
+ described(Declaration.build(:attribute, source: model))
210
+ end
211
+
212
+ # Lists the attributes of a model; a StoreModel or ActiveModel class
213
+ # without names declares every attribute but those in `except:`.
214
+ def attributes_from(model, *names, except: nil, **overrides)
215
+ if names.empty? && !model.respond_to?(:columns_hash)
216
+ names = model.attribute_types.keys.map(&:to_sym)
217
+ names -= restrict(names, except, "except") if except
218
+ elsif except
219
+ raise SchemaError, "attributes_from #{model} takes except: only without names, for a StoreModel or ActiveModel class"
220
+ end
221
+ names |= overrides.keys
222
+ raise SchemaError, "attributes_from #{model} needs at least one attribute" if names.empty?
223
+
224
+ names.each { |name| apply_declaration(name, override(Inference.from_attribute(model, name), overrides[name])) }
225
+ end
226
+
227
+ private
228
+
229
+ def restrict(names, subset, option)
230
+ subset = Array(subset).map(&:to_sym)
231
+ unknown = subset - names
232
+ raise SchemaError, "#{option}: names filters that do not exist: #{unknown.inspect}" if unknown.any?
233
+
234
+ names & subset
235
+ end
236
+
237
+ def override(declaration, options)
238
+ return declaration if options.nil?
239
+ unless options.is_a?(Hash)
240
+ consume(options)
241
+ return Declaration.from_literal(options)
242
+ end
243
+
244
+ declaration, options = override_children(declaration, options)
245
+ required = presence_option(options)
246
+ declaration = declaration.with(required:, options: declaration.options.except(:allow_nil)) unless required.nil?
247
+ declaration.merge_options(options.except(*Builder::PRESENCE_OPTIONS))
248
+ end
249
+
250
+ def override_children(declaration, options)
251
+ return [declaration, options] unless declaration.type == :hash && declaration.children&.any?
252
+
253
+ child_names = declaration.children.map { |name, _| name.to_sym }
254
+ nested, options = options.partition { |key, _| child_names.include?(key.to_sym) }.map(&:to_h)
255
+ return [declaration, options] if nested.empty?
256
+
257
+ children = declaration.children.map { |name, child| [name, nested.key?(name.to_sym) ? override(child, nested[name.to_sym]) : child] }
258
+ [declaration.with(children:), options]
259
+ end
260
+ end
261
+ end
262
+
263
+ Builder.include Inference::BuilderMethods
264
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ # Each distinct declaration compiles once per process, keyed by the literal
5
+ # itself. A proc would be a new object on every request and grow the cache
6
+ # without bound, so a declaration holding one is rejected before it is cached.
7
+ class Inline
8
+ def self.reject_procs(nodes, label)
9
+ nodes.each do |node|
10
+ if [node.in, node.default, node.condition&.last].any?(Proc)
11
+ raise SchemaError, "#{label} cannot hold a proc at #{node.key.inspect}; declare a schema instead"
12
+ end
13
+
14
+ reject_procs(node.children, label) if node.children
15
+ reject_procs([node.item], label) if node.item
16
+ end
17
+ end
18
+
19
+ def initialize(context)
20
+ @context = context
21
+ end
22
+
23
+ def require(*names, **pairs)
24
+ run(true, names, pairs)
25
+ end
26
+
27
+ def optional(*names, **pairs)
28
+ run(false, names, pairs)
29
+ end
30
+
31
+ # The keys that were sent, as a Hash even for one key, so an update does
32
+ # not clear an attribute the request left out.
33
+ def slice(*names, **pairs)
34
+ run(false, names, pairs, unwrap: false)
35
+ end
36
+
37
+ # Type helpers describe a value for `require`, `optional` and `slice`, as
38
+ # in `typed_params.optional(ids: typed_params.array(:id))`. They run on
39
+ # every request, before the cache lookup, so they build the declaration
40
+ # directly; its options are validated when the schema is first built.
41
+ LITERAL_TYPES.each do |type|
42
+ define_method(type) { |**options| describe(type, options) }
43
+ end
44
+
45
+ def text(**options)
46
+ describe(:string, Inference.text_options.merge(options))
47
+ end
48
+
49
+ def enum(source, **options)
50
+ describe(:enum, options, source:)
51
+ end
52
+
53
+ def array(item, **options)
54
+ describe(:array, options, item: Declaration.from_literal(item))
55
+ end
56
+
57
+ def map(value, **options)
58
+ describe(:map, options, item: Declaration.from_literal(value))
59
+ end
60
+
61
+ def hash(*fields, **options)
62
+ options, pairs = options.partition { |key, _| Builder::OPTIONS[:hash].include?(key) }.map(&:to_h)
63
+ list = pairs.empty? ? fields : [*fields, pairs]
64
+ describe(:hash, options, children: list.empty? && options[:open] ? [] : Declaration.from_list(list).children)
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :context
70
+
71
+ def describe(type, options, **parts)
72
+ required = options.key?(:optional) ? !options[:optional] : options[:required]
73
+ Declaration.build(type, required:, options: options.except(*Builder::PRESENCE_OPTIONS), **parts)
74
+ end
75
+
76
+ def run(required, names, pairs, unwrap: true)
77
+ schema = ActiveTypedParams.inline_schema([required, names, pairs]) { build(required, names, pairs) }
78
+ request = context.request if context.respond_to?(:request, true)
79
+ result = schema.call(context, context.params, request)
80
+ (unwrap && schema.nodes.size == 1) ? result[schema.nodes.first.result_key] : result
81
+ end
82
+
83
+ def build(required, names, pairs)
84
+ builder = Builder.new
85
+ required ? builder.required(*names, **pairs) : builder.optional(*names, **pairs)
86
+ label = "typed_params.#{required ? "require" : "optional"}"
87
+ nodes = builder.nodes
88
+ raise SchemaError, "#{label} declares no keys" if nodes.empty?
89
+
90
+ Inline.reject_procs(nodes, label)
91
+ Schema.new(name: :inline, nodes:, module_name: "Inline__#{Digest::SHA256.hexdigest(nodes.inspect)[0, 12]}")
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ # Marks a node without a `default:`, so that `default: nil` stays distinct.
5
+ NO_DEFAULT = Object.new.freeze
6
+
7
+ TYPES = %i[string integer id decimal float boolean date time enum file hash array map any one_of].freeze
8
+ SCALAR_TYPES = (TYPES - %i[hash array map any one_of]).freeze
9
+ # The JSON type a `one_of` picks each alternative by.
10
+ JSON_TYPES = {
11
+ string: :string, date: :string, time: :string, enum: :string,
12
+ integer: :number, id: :number, decimal: :number, float: :number,
13
+ boolean: :boolean, array: :array, hash: :object, map: :object
14
+ }.freeze
15
+ LITERAL_TYPES = (SCALAR_TYPES - [:enum] + [:any]).freeze
16
+ # `text` is a string without the default cap, for free text of any length.
17
+ LITERAL_NAMES = (LITERAL_TYPES + [:text, :direct_upload]).freeze
18
+
19
+ Node = Data.define(
20
+ :key, :type, :required, :allow_nil, :allow_blank, :default, :in, :max_length,
21
+ :condition, :as, :json, :open, :bytes, :enum_map, :invalid, :children, :item, :map_key
22
+ ) do
23
+ def self.build(key:, type:, required:, **options)
24
+ new(
25
+ key: key&.to_s,
26
+ type:,
27
+ required:,
28
+ allow_nil: options.fetch(:allow_nil, false),
29
+ allow_blank: options.fetch(:allow_blank, false),
30
+ default: options.fetch(:default, NO_DEFAULT),
31
+ in: options.fetch(:in, nil),
32
+ max_length: options.fetch(:max_length, nil),
33
+ condition: options.fetch(:condition, nil),
34
+ as: options.fetch(:as, nil),
35
+ json: options.fetch(:json, false),
36
+ open: options.fetch(:open, false),
37
+ bytes: options.fetch(:bytes, nil),
38
+ enum_map: options.fetch(:enum_map, nil),
39
+ invalid: options.fetch(:invalid, :raise),
40
+ children: options.fetch(:children, nil),
41
+ item: options.fetch(:item, nil),
42
+ map_key: options.fetch(:map_key, nil),
43
+ )
44
+ end
45
+
46
+ def default?
47
+ !default.equal?(NO_DEFAULT)
48
+ end
49
+
50
+ def result_key
51
+ (as || key).to_sym
52
+ end
53
+
54
+ def hash?
55
+ type == :hash
56
+ end
57
+
58
+ def array?
59
+ type == :array
60
+ end
61
+
62
+ def map?
63
+ type == :map
64
+ end
65
+
66
+ def child_keys
67
+ children.map(&:key)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ # Makes `typed_params` available in every controller, compiles every schema
5
+ # at boot when eager loading, and starts over when code reloads.
6
+ class Railtie < Rails::Railtie
7
+ initializer "active_typed_params.controller" do
8
+ ActiveSupport.on_load(:action_controller) { include ActiveTypedParams::Controller }
9
+ end
10
+
11
+ initializer "active_typed_params.reloader" do |app|
12
+ app.reloader.before_class_unload { ActiveTypedParams.unload! }
13
+ end
14
+
15
+ config.after_initialize do |app|
16
+ ActiveTypedParams.compile_all! if app.config.eager_load
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveTypedParams
4
+ # Helpers the compiled methods call. Each one is either a type conversion
5
+ # (returning nil when the value is invalid) or a boundary with the host
6
+ # framework.
7
+ module Runtime
8
+ INTEGER = /\A-?\d+\z/
9
+ DECIMAL = /\A-?\d+(?:\.\d+)?\z/
10
+ FLOAT = /\A-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\z/
11
+ DATE = /\A\d{4}-\d{2}-\d{2}\z/
12
+ TIME = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/
13
+ BOOLEANS = {"true" => true, "false" => false, "1" => true, "0" => false}.freeze
14
+ FRAMEWORK_KEYS = Set.new(%w[controller action format authenticity_token _method utf8 commit]).freeze
15
+
16
+ module_function
17
+
18
+ # The string-keyed hash behind ActionController::Parameters. Reading that
19
+ # internal (here, in `unwrap_hash` and in `plain`) avoids the deep copy
20
+ # `to_unsafe_h` makes; `spec/active_typed_params/runtime_spec.rb` pins it.
21
+ def raw(params)
22
+ case params
23
+ when ActionController::Parameters then params.instance_variable_get(:@parameters)
24
+ when ActiveSupport::HashWithIndifferentAccess then params
25
+ when Hash then ActiveSupport::HashWithIndifferentAccess.new(params)
26
+ else raise ArgumentError, "expected a Hash or ActionController::Parameters, got #{params.class}"
27
+ end
28
+ end
29
+
30
+ # What a `source:` returns: a hash, or nil or an empty string for none.
31
+ def source_hash(value)
32
+ return ActiveSupport::HashWithIndifferentAccess.new if value.nil? || value == ""
33
+
34
+ Runtime.raw(unwrap_hash(value) || fail(:invalid_type, [], :hash))
35
+ end
36
+
37
+ def unwrap_hash(value)
38
+ case value
39
+ when Hash then value
40
+ when ActionController::Parameters then value.instance_variable_get(:@parameters)
41
+ end
42
+ end
43
+
44
+ def fail(code, path, type = nil)
45
+ raise InvalidParameter.for(code, path, type)
46
+ end
47
+
48
+ def integer(value)
49
+ Integer(value, 10) if value.is_a?(String) && INTEGER.match?(value)
50
+ end
51
+
52
+ def decimal(value)
53
+ case value
54
+ when Integer then BigDecimal(value)
55
+ when BigDecimal then value
56
+ when Float then BigDecimal(value, Float::DIG) if value.finite?
57
+ when String then BigDecimal(value) if DECIMAL.match?(value)
58
+ end
59
+ end
60
+
61
+ def float(value)
62
+ parsed = case value
63
+ when Float then value
64
+ when Integer then value.to_f
65
+ when String then Float(value) if FLOAT.match?(value)
66
+ end
67
+ parsed if parsed&.finite?
68
+ end
69
+
70
+ def boolean(value)
71
+ if value == true || value == false
72
+ value
73
+ elsif value.is_a?(String)
74
+ BOOLEANS[value]
75
+ end
76
+ end
77
+
78
+ def date(value)
79
+ return unless value.is_a?(String) && DATE.match?(value)
80
+
81
+ Date.iso8601(value)
82
+ rescue Date::Error
83
+ nil
84
+ end
85
+
86
+ # A date alone is rejected, since Time.zone would read it as midnight in
87
+ # the server's zone rather than in the zone the client meant.
88
+ def time(value)
89
+ return unless value.is_a?(String) && TIME.match?(value)
90
+
91
+ if Time.respond_to?(:zone) && Time.zone
92
+ Time.zone.iso8601(value)
93
+ else
94
+ Time.iso8601(value)
95
+ end
96
+ rescue ArgumentError
97
+ nil
98
+ end
99
+
100
+ def file?(value)
101
+ ActiveTypedParams.config.file_classes.any? { |klass| value.is_a?(klass) }
102
+ end
103
+
104
+ def parse_json(value, path, type)
105
+ JSON.parse(value)
106
+ rescue JSON::ParserError
107
+ fail(:invalid_type, path, type)
108
+ end
109
+
110
+ # Keys are symbols at every level, like the keys a schema declares.
111
+ def plain(value)
112
+ case value
113
+ when Hash then value.each_with_object({}) { |(key, child), result| result[key.to_sym] = plain(child) }
114
+ when Array then value.map { |child| plain(child) }
115
+ when ActionController::Parameters then plain(value.instance_variable_get(:@parameters))
116
+ else value
117
+ end
118
+ end
119
+
120
+ def deep_freeze(value)
121
+ case value
122
+ when Hash then value.each_value { |child| deep_freeze(child) }.freeze
123
+ when Array then value.each { |child| deep_freeze(child) }.freeze
124
+ else value.freeze
125
+ end
126
+ end
127
+
128
+ def allowed?(allowed, value)
129
+ allowed.is_a?(Range) ? allowed.cover?(value) : allowed.include?(value)
130
+ end
131
+
132
+ # Undeclared keys are reported with `unknown: :report`, or rejected with
133
+ # `unknown: :reject`. Route and framework keys are never unknown at the
134
+ # root, and neither is the copy ParamsWrapper makes of a JSON body.
135
+ def check_unknown(hash, declared, mode, path, context, request)
136
+ unknown = nil
137
+ path_parameters = request&.path_parameters
138
+ wrapped = wrapped_keys(hash, declared, context) if request
139
+ hash.each_key do |key|
140
+ next if declared.include?(key)
141
+ next if request && (FRAMEWORK_KEYS.include?(key) || path_parameters&.key?(key.to_sym) || wrapped&.include?(key))
142
+
143
+ fail(:unknown, [*path, key]) if mode == :reject
144
+ (unknown ||= []) << [*path, key].join(".")
145
+ end
146
+ report_unknown(unknown, context) if unknown
147
+ end
148
+
149
+ # The wrapper key, plus the root keys it copied when the schema reads the
150
+ # wrapped hash instead of the root.
151
+ def wrapped_keys(hash, declared, context)
152
+ return unless context.respond_to?(:_wrapper_key, true)
153
+
154
+ wrapper = context.send(:_wrapper_key)&.to_s
155
+ return if wrapper.nil?
156
+
157
+ wrapped = unwrap_hash(hash[wrapper]) if declared.include?(wrapper)
158
+ wrapped ? [wrapper, *wrapped.keys] : [wrapper]
159
+ end
160
+
161
+ def report_unknown(keys, context)
162
+ ActiveSupport::Notifications.instrument("unknown_parameters.typed_params", keys:, **context_payload(context))
163
+ end
164
+
165
+ def report_invalid(error, context)
166
+ ActiveSupport::Notifications.instrument(
167
+ "invalid_parameters.typed_params",
168
+ path: error.path.join("."), code: error.code, **context_payload(context),
169
+ )
170
+ end
171
+
172
+ def context_payload(context)
173
+ return {} if context.nil? || context.instance_of?(Object)
174
+
175
+ {
176
+ controller: context.class.name,
177
+ action: (context.action_name if context.respond_to?(:action_name)),
178
+ request: (context.request if context.respond_to?(:request, true)),
179
+ }
180
+ end
181
+ end
182
+ end