durable_parameters 0.2.3

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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +853 -0
  4. data/Rakefile +29 -0
  5. data/app/params/account_params.rb.example +38 -0
  6. data/app/params/application_params.rb +16 -0
  7. data/lib/durable_parameters/adapters/hanami.rb +138 -0
  8. data/lib/durable_parameters/adapters/rage.rb +124 -0
  9. data/lib/durable_parameters/adapters/rails.rb +280 -0
  10. data/lib/durable_parameters/adapters/sinatra.rb +91 -0
  11. data/lib/durable_parameters/core/application_params.rb +334 -0
  12. data/lib/durable_parameters/core/configuration.rb +83 -0
  13. data/lib/durable_parameters/core/forbidden_attributes_protection.rb +48 -0
  14. data/lib/durable_parameters/core/parameters.rb +643 -0
  15. data/lib/durable_parameters/core/params_registry.rb +110 -0
  16. data/lib/durable_parameters/core.rb +15 -0
  17. data/lib/durable_parameters/log_subscriber.rb +34 -0
  18. data/lib/durable_parameters/railtie.rb +65 -0
  19. data/lib/durable_parameters/version.rb +7 -0
  20. data/lib/durable_parameters.rb +41 -0
  21. data/lib/generators/rails/USAGE +12 -0
  22. data/lib/generators/rails/durable_parameters_controller_generator.rb +17 -0
  23. data/lib/generators/rails/templates/controller.rb +94 -0
  24. data/lib/legacy/action_controller/application_params.rb +235 -0
  25. data/lib/legacy/action_controller/parameters.rb +524 -0
  26. data/lib/legacy/action_controller/params_registry.rb +108 -0
  27. data/lib/legacy/active_model/forbidden_attributes_protection.rb +40 -0
  28. data/test/action_controller_required_params_test.rb +36 -0
  29. data/test/action_controller_tainted_params_test.rb +29 -0
  30. data/test/active_model_mass_assignment_taint_protection_test.rb +25 -0
  31. data/test/application_params_array_test.rb +245 -0
  32. data/test/application_params_edge_cases_test.rb +361 -0
  33. data/test/application_params_test.rb +893 -0
  34. data/test/controller_generator_test.rb +31 -0
  35. data/test/core_parameters_test.rb +2376 -0
  36. data/test/durable_parameters_test.rb +115 -0
  37. data/test/enhanced_error_messages_test.rb +120 -0
  38. data/test/gemfiles/Gemfile.rails-3.0.x +14 -0
  39. data/test/gemfiles/Gemfile.rails-3.1.x +14 -0
  40. data/test/gemfiles/Gemfile.rails-3.2.x +14 -0
  41. data/test/log_on_unpermitted_params_test.rb +49 -0
  42. data/test/metadata_validation_test.rb +294 -0
  43. data/test/multi_parameter_attributes_test.rb +38 -0
  44. data/test/parameters_core_methods_test.rb +503 -0
  45. data/test/parameters_integration_test.rb +553 -0
  46. data/test/parameters_permit_test.rb +491 -0
  47. data/test/parameters_require_test.rb +9 -0
  48. data/test/parameters_taint_test.rb +98 -0
  49. data/test/params_registry_concurrency_test.rb +422 -0
  50. data/test/params_registry_test.rb +112 -0
  51. data/test/permit_by_model_test.rb +227 -0
  52. data/test/raise_on_unpermitted_params_test.rb +32 -0
  53. data/test/test_helper.rb +38 -0
  54. data/test/transform_params_edge_cases_test.rb +526 -0
  55. data/test/transformation_test.rb +360 -0
  56. metadata +223 -0
@@ -0,0 +1,643 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'bigdecimal'
5
+ require 'stringio'
6
+
7
+ module StrongParameters
8
+ module Core
9
+ # Exception raised when a required parameter is missing or empty.
10
+ #
11
+ # This exception provides helpful context about what went wrong and suggests
12
+ # how to fix it, making debugging easier for developers.
13
+ #
14
+ # @example Basic usage
15
+ # params = Parameters.new({})
16
+ # params.require(:user)
17
+ # # => ParameterMissing: param is missing or the value is empty: user
18
+ # #
19
+ # # Expected to find parameter 'user' in the request, but it was missing.
20
+ # #
21
+ # # Make sure your request includes this parameter. For example:
22
+ # # POST /users with { "user": { ... } }
23
+ class ParameterMissing < IndexError
24
+ # @return [Symbol, String] the name of the missing parameter
25
+ attr_reader :param
26
+
27
+ # Initialize a new ParameterMissing exception
28
+ #
29
+ # @param param [Symbol, String] the name of the missing parameter
30
+ # @param context [Hash] optional context for better error messages
31
+ # @option context [Array<String>] :available_keys keys that were present
32
+ def initialize(param, context = {})
33
+ @param = param
34
+ message = build_message(param, context)
35
+ super(message)
36
+ end
37
+
38
+ private
39
+
40
+ def build_message(param, context)
41
+ msg = "param is missing or the value is empty: #{param}"
42
+
43
+ if context[:available_keys]&.any?
44
+ msg += "\n\n"
45
+ msg += "Available keys: #{context[:available_keys].join(', ')}"
46
+ msg += "\n"
47
+
48
+ # Suggest similar keys if available
49
+ similar = find_similar_keys(param.to_s, context[:available_keys])
50
+ if similar.any?
51
+ msg += "\nDid you mean? #{similar.join(', ')}"
52
+ end
53
+ end
54
+
55
+ msg
56
+ end
57
+
58
+ def find_similar_keys(param, available_keys)
59
+ return [] unless available_keys
60
+
61
+ param_str = param.to_s.downcase
62
+ available_keys.select do |key|
63
+ key_str = key.to_s.downcase
64
+ # Simple similarity check: starts with same letter or contains param
65
+ key_str[0] == param_str[0] || key_str.include?(param_str) || param_str.include?(key_str)
66
+ end.take(3)
67
+ end
68
+ end
69
+
70
+ # Exception raised when unpermitted parameters are detected and configured to raise.
71
+ #
72
+ # @example
73
+ # params.permit(:name)
74
+ # # With params containing :admin => true
75
+ # # => StrongParameters::Core::UnpermittedParameters: found unpermitted parameters: admin
76
+ class UnpermittedParameters < IndexError
77
+ # @return [Array<String>] the names of the unpermitted parameters
78
+ attr_reader :params
79
+
80
+ # Initialize a new UnpermittedParameters exception
81
+ #
82
+ # @param params [Array<String>] the names of the unpermitted parameters
83
+ def initialize(params)
84
+ @params = params
85
+ super("found unpermitted parameters: #{params.join(', ')}")
86
+ end
87
+ end
88
+
89
+ # Core Parameters implementation - framework-agnostic strong parameters.
90
+ #
91
+ # This class provides a whitelist-based approach to mass assignment protection,
92
+ # requiring explicit permission for parameters before they can be used.
93
+ #
94
+ # @example Basic usage
95
+ # params = StrongParameters::Core::Parameters.new(user: { name: 'John', admin: true })
96
+ # params.require(:user).permit(:name)
97
+ # # => <StrongParameters::Core::Parameters {"name"=>"John"} permitted: true>
98
+ class Parameters < Hash
99
+ # @return [Boolean] whether this Parameters object is permitted
100
+ attr_accessor :permitted
101
+ alias permitted? permitted
102
+
103
+ # @return [Symbol, String, nil] the key used in the last require() call
104
+ attr_accessor :required_key
105
+
106
+ class << self
107
+ # @return [Symbol, nil] action to take on unpermitted parameters (:log, :raise, or nil/false)
108
+ attr_accessor :action_on_unpermitted_parameters
109
+
110
+ # @return [Proc, nil] notification handler for unpermitted parameters
111
+ attr_accessor :unpermitted_notification_handler
112
+ end
113
+
114
+ # Parameters that are never considered unpermitted.
115
+ # These are added by frameworks and are of no concern for security.
116
+ NEVER_UNPERMITTED_PARAMS = %w[controller action].freeze
117
+
118
+ # Initialize a new Parameters object.
119
+ #
120
+ # @param attributes [Hash, nil] the initial attributes
121
+ def initialize(attributes = nil)
122
+ super()
123
+ @permitted = false
124
+ @required_key = nil
125
+ update(deep_normalize_keys(attributes)) if attributes
126
+ end
127
+
128
+ # Mark this Parameters object and all nested Parameters as permitted.
129
+ #
130
+ # Use with extreme caution as this allows all current and future attributes
131
+ # to be mass-assigned. Should only be used when you fully trust the source
132
+ # of the parameters.
133
+ #
134
+ # @return [Parameters] self
135
+ # @example
136
+ # params.require(:log_entry).permit!
137
+ def permit!
138
+ each_pair do |key, value|
139
+ value = convert_hashes_to_parameters(key, value)
140
+ wrap_array(value).each do |item|
141
+ item.permit! if item.respond_to?(:permit!)
142
+ end
143
+ end
144
+
145
+ @permitted = true
146
+ self
147
+ end
148
+
149
+ # Ensure that a parameter is present and not empty.
150
+ #
151
+ # If the parameter is missing or empty, raises ParameterMissing exception
152
+ # with helpful context about available keys and suggestions.
153
+ # If the parameter is present and is a Parameters object, tracks the key
154
+ # for later use by transform_params.
155
+ #
156
+ # @param key [Symbol, String] the parameter key to require
157
+ # @return [Object] the parameter value
158
+ # @raise [ParameterMissing] if the parameter is missing or empty
159
+ #
160
+ # @example Basic usage
161
+ # params.require(:user) # => returns params[:user] or raises ParameterMissing
162
+ #
163
+ # @example Chaining with permit
164
+ # params.require(:user).permit(:name, :email)
165
+ #
166
+ # @example Error with helpful context
167
+ # params = Parameters.new(usr: {name: 'John'})
168
+ # params.require(:user)
169
+ # # => ParameterMissing: param is missing or the value is empty: user
170
+ # #
171
+ # # Available keys: usr
172
+ # # Did you mean? usr
173
+ def require(key)
174
+ key = normalize_key(key)
175
+ value = self[key]
176
+ value = nil if value.respond_to?(:empty?) && value.empty?
177
+
178
+ if value.nil?
179
+ # Provide helpful context in error message
180
+ context = { available_keys: keys }
181
+ raise ParameterMissing.new(key, context)
182
+ end
183
+
184
+ # Track the required key so transform_params can infer the params class
185
+ if value.is_a?(Parameters) && value.required_key.nil?
186
+ value.required_key = @required_key || key.to_sym
187
+ end
188
+ value
189
+ end
190
+
191
+ alias required require
192
+
193
+ # Create a new Parameters object with only the specified keys permitted.
194
+ #
195
+ # Filters can be symbols/strings for scalar values, or hashes for nested
196
+ # parameters. Only explicitly permitted parameters will be included in
197
+ # the returned object.
198
+ #
199
+ # @param filters [Array<Symbol, String, Hash>] the keys to permit
200
+ # @return [Parameters] a new Parameters object containing only permitted keys
201
+ # @example Permit scalar values
202
+ # params.permit(:name, :age)
203
+ # @example Permit nested parameters
204
+ # params.permit(:name, emails: [], friends: [:name, { family: [:name] }])
205
+ def permit(*filters)
206
+ params = self.class.new
207
+
208
+ filters.flatten.each do |filter|
209
+ case filter
210
+ when Symbol, String
211
+ permitted_scalar_filter(params, filter)
212
+ when Hash
213
+ hash_filter(params, filter)
214
+ end
215
+ end
216
+
217
+ unpermitted_parameters!(params) if self.class.action_on_unpermitted_parameters
218
+
219
+ params.permit!
220
+ end
221
+
222
+ # Transform and permit parameters using a declarative params class.
223
+ #
224
+ # This method provides a declarative approach to parameter filtering using
225
+ # params classes. It automatically looks up the appropriate params class
226
+ # based on the required_key if not explicitly provided.
227
+ #
228
+ # Transformations defined in the params class are applied before filtering.
229
+ # This allows you to normalize, validate, or modify parameter values based
230
+ # on metadata like the current user or action.
231
+ #
232
+ # @param params_class [Class, Symbol, nil] optional params class or :__infer__ (default)
233
+ # - Class: Use this specific params class (e.g., UserParams)
234
+ # - :__infer__: Infer from required_key (e.g., :user -> UserParams)
235
+ # - nil: Skip lookup and return empty permitted params
236
+ # @param options [Hash] metadata and configuration options
237
+ # @option options [Symbol, String] :action Action name for action-specific filtering
238
+ # @option options [Array<Symbol>] :additional_attrs Additional attributes to permit
239
+ # @option options [Object] :current_user Current user (always allowed, no declaration needed)
240
+ # @return [Parameters] permitted parameters with only allowed attributes
241
+ # @raise [ArgumentError] if undeclared metadata keys are passed
242
+ #
243
+ # @example Basic usage with inference
244
+ # params.require(:user).transform_params
245
+ # # Looks up UserParams automatically
246
+ #
247
+ # @example Explicit params class
248
+ # params.require(:user).transform_params(AdminUserParams)
249
+ #
250
+ # @example With action-specific filtering
251
+ # params.require(:post).transform_params(action: :create)
252
+ #
253
+ # @example With metadata for transformations
254
+ # params.require(:user).transform_params(current_user: current_user)
255
+ # # Transformations can access current_user
256
+ #
257
+ # @example With additional attributes
258
+ # params.require(:user).transform_params(additional_attrs: [:temp_token])
259
+ def transform_params(params_class = :__infer__, **options)
260
+ # Extract known options (these don't need to be declared as metadata)
261
+ action = options[:action]
262
+ additional_attrs = options[:additional_attrs] || []
263
+
264
+ # Infer params class from required_key if not explicitly provided
265
+ if params_class == :__infer__
266
+ if @required_key
267
+ params_class = ParamsRegistry.lookup(@required_key)
268
+ else
269
+ # If no required_key and no explicit params_class, return empty permitted params
270
+ return self.class.new.permit!
271
+ end
272
+ end
273
+
274
+ # Handle case where params_class is nil (explicitly passed or not registered)
275
+ if params_class.nil?
276
+ return self.class.new.permit!
277
+ end
278
+
279
+ # Validate metadata keys (excluding known options)
280
+ metadata_keys = options.keys - [:action, :additional_attrs]
281
+ validate_metadata_keys!(params_class, metadata_keys)
282
+
283
+ # Apply transformations first (before filtering)
284
+ # Pass all options as metadata to the transformations
285
+ transformed_hash = params_class.apply_transformations(self, options)
286
+
287
+ # Create a new Parameters object from the transformed hash
288
+ transformed_params = self.class.new(transformed_hash)
289
+
290
+ # Get permitted attributes and apply them
291
+ permitted_attrs = params_class.permitted_attributes(action: action) || []
292
+ permitted_attrs += additional_attrs
293
+ transformed_params.permit(*permitted_attrs)
294
+ end
295
+
296
+ # Legacy alias for backwards compatibility.
297
+ #
298
+ # @deprecated Use {#transform_params} instead
299
+ # @param model_name [Symbol, String] the model name to look up
300
+ # @param action [Symbol, String, nil] optional action name
301
+ # @param additional_attrs [Array<Symbol>] additional attributes to permit
302
+ # @return [Parameters] permitted parameters
303
+ def permit_by_model(model_name, action: nil, additional_attrs: [])
304
+ params_class = ParamsRegistry.lookup(model_name)
305
+ transform_params(params_class, action: action, additional_attrs: additional_attrs)
306
+ end
307
+
308
+ # Access a parameter value by key, converting hashes to Parameters objects.
309
+ #
310
+ # @param key [Symbol, String] the parameter key
311
+ # @return [Object] the parameter value
312
+ def [](key)
313
+ convert_hashes_to_parameters(normalize_key(key), super(normalize_key(key)))
314
+ end
315
+
316
+ # Set a parameter value by key.
317
+ #
318
+ # @param key [Symbol, String] the parameter key
319
+ # @param value [Object] the parameter value
320
+ def []=(key, value)
321
+ super(normalize_key(key), value)
322
+ end
323
+
324
+ # Fetch a parameter value by key, raising ParameterMissing if not found.
325
+ #
326
+ # @param key [Symbol, String] the parameter key
327
+ # @param args additional arguments passed to Hash#fetch
328
+ # @return [Object] the parameter value
329
+ # @raise [ParameterMissing] if the key is not found
330
+ def fetch(key, *args)
331
+ key = normalize_key(key)
332
+ convert_hashes_to_parameters(key, super(key, *args), false)
333
+ rescue KeyError, IndexError
334
+ raise ParameterMissing.new(key)
335
+ end
336
+
337
+ # Check if a key exists in the parameters.
338
+ #
339
+ # @param key [Symbol, String] the parameter key
340
+ # @return [Boolean] true if the key exists
341
+ def has_key?(key)
342
+ super(normalize_key(key))
343
+ end
344
+
345
+ alias key? has_key?
346
+ alias include? has_key?
347
+
348
+ # Delete a key from the parameters.
349
+ #
350
+ # @param key [Symbol, String] the parameter key
351
+ # @return [Object] the deleted value
352
+ def delete(key)
353
+ super(normalize_key(key))
354
+ end
355
+
356
+ # Create a new Parameters object containing only the specified keys.
357
+ #
358
+ # @param keys [Array<Symbol, String>] the keys to include
359
+ # @return [Parameters] a new Parameters object with only the specified keys
360
+ # @example
361
+ # params.slice(:name, :email)
362
+ def slice(*keys)
363
+ normalized_keys = keys.map { |k| normalize_key(k) }
364
+ sliced = select { |k, v| normalized_keys.include?(k) }
365
+
366
+ self.class.new(sliced).tap do |new_instance|
367
+ new_instance.instance_variable_set(:@permitted, @permitted)
368
+ new_instance.instance_variable_set(:@required_key, @required_key)
369
+ end
370
+ end
371
+
372
+ # Create a new Parameters object excluding the specified keys.
373
+ #
374
+ # @param keys [Array<Symbol, String>] the keys to exclude
375
+ # @return [Parameters] a new Parameters object without the specified keys
376
+ # @example
377
+ # params.except(:password, :password_confirmation)
378
+ def except(*keys)
379
+ # Return a new Parameters instance excluding the specified keys
380
+ excepted = dup
381
+ keys.each { |key| excepted.delete(key) }
382
+ excepted
383
+ end
384
+
385
+ # Create a duplicate of this Parameters object.
386
+ #
387
+ # @return [Parameters] a new Parameters object with the same data and state
388
+ def dup
389
+ self.class.new(self).tap do |duplicate|
390
+ duplicate.instance_variable_set(:@permitted, @permitted)
391
+ duplicate.instance_variable_set(:@required_key, @required_key)
392
+ end
393
+ end
394
+
395
+ # Convert to a regular Hash.
396
+ #
397
+ # @return [Hash] a regular hash with string keys
398
+ def to_h
399
+ each_with_object({}) do |(key, value), hash|
400
+ hash[key.to_s] = value.is_a?(Parameters) ? value.to_h : value
401
+ end
402
+ end
403
+
404
+ # Convert to an unsafe hash (without permission checking).
405
+ #
406
+ # @return [Hash] a regular hash
407
+ def to_unsafe_h
408
+ to_h
409
+ end
410
+
411
+ alias to_hash to_h
412
+
413
+ protected
414
+
415
+ def convert_value(value)
416
+ if value.is_a?(Hash) && !value.is_a?(Parameters)
417
+ self.class.new(value)
418
+ elsif value.is_a?(Array)
419
+ value.map { |e| convert_value(e) }
420
+ else
421
+ value
422
+ end
423
+ end
424
+
425
+ private
426
+
427
+ def normalize_key(key)
428
+ key.to_s
429
+ end
430
+
431
+ def deep_normalize_keys(hash)
432
+ return hash unless hash.is_a?(Hash)
433
+
434
+ hash.each_with_object({}) do |(key, value), result|
435
+ normalized_key = normalize_key(key)
436
+ result[normalized_key] = if value.is_a?(Hash)
437
+ deep_normalize_keys(value)
438
+ elsif value.is_a?(Array)
439
+ value.map { |item| item.is_a?(Hash) ? deep_normalize_keys(item) : item }
440
+ else
441
+ value
442
+ end
443
+ end
444
+ end
445
+
446
+ def convert_hashes_to_parameters(key, value, assign_if_converted = true)
447
+ converted = convert_value_to_parameters(value)
448
+ self[key] = converted if assign_if_converted && !converted.equal?(value)
449
+ converted
450
+ end
451
+
452
+ def convert_value_to_parameters(value)
453
+ if value.is_a?(Array)
454
+ value.map { |item| convert_value_to_parameters(item) }
455
+ elsif value.is_a?(Parameters) || !value.is_a?(Hash)
456
+ value
457
+ else
458
+ self.class.new(value)
459
+ end
460
+ end
461
+
462
+ def wrap_array(value)
463
+ value.is_a?(Array) ? value : [value]
464
+ end
465
+
466
+ #
467
+ # --- Filtering ----------------------------------------------------------
468
+ #
469
+
470
+ # Whitelist of permitted scalar types for parameter filtering.
471
+ #
472
+ # These types are considered safe for mass assignment and include types
473
+ # commonly used in XML and JSON requests. String is first to optimize
474
+ # the common case through short-circuit evaluation.
475
+ #
476
+ # **Permitted Types:**
477
+ # - String, Symbol - text values
478
+ # - NilClass - null values
479
+ # - Numeric (Integer, Float, BigDecimal, etc.) - numeric values
480
+ # - TrueClass, FalseClass - boolean values
481
+ # - Date, Time, DateTime - temporal values
482
+ # - StringIO, IO - file-like objects
483
+ #
484
+ # @note DateTime inherits from Date, so it's implicitly included.
485
+ # @note If you modify this list, please update the README documentation.
486
+ PERMITTED_SCALAR_TYPES = [
487
+ String,
488
+ Symbol,
489
+ NilClass,
490
+ Numeric,
491
+ TrueClass,
492
+ FalseClass,
493
+ Date,
494
+ Time,
495
+ # DateTimes are Dates, we document the type but avoid the redundant check.
496
+ StringIO,
497
+ IO
498
+ ].freeze
499
+
500
+ # Check if a value is a permitted scalar type.
501
+ #
502
+ # @param value [Object] the value to check
503
+ # @return [Boolean] true if value is a permitted scalar type
504
+ def permitted_scalar?(value)
505
+ PERMITTED_SCALAR_TYPES.any? { |type| value.is_a?(type) }
506
+ end
507
+
508
+ # Check if a value is an array containing only permitted scalars.
509
+ #
510
+ # @param value [Object] the value to check
511
+ # @return [Boolean, nil] true if array of permitted scalars, nil otherwise
512
+ def array_of_permitted_scalars?(value)
513
+ return unless value.is_a?(Array)
514
+
515
+ value.all? { |element| permitted_scalar?(element) }
516
+ end
517
+
518
+ def permitted_scalar_filter(params, key)
519
+ key = normalize_key(key)
520
+ if has_key?(key) && permitted_scalar?(self[key])
521
+ params[key] = self[key]
522
+ end
523
+
524
+ keys.grep(/\A#{Regexp.escape(key)}\(\d+[if]?\)\z/).each do |matched_key|
525
+ if permitted_scalar?(self[matched_key])
526
+ params[matched_key] = self[matched_key]
527
+ end
528
+ end
529
+ end
530
+
531
+ def array_of_permitted_scalars_filter(params, key, hash = self)
532
+ key = normalize_key(key)
533
+ if hash.has_key?(key) && array_of_permitted_scalars?(hash[key])
534
+ params[key] = hash[key]
535
+ end
536
+ end
537
+
538
+ def hash_filter(params, filter)
539
+ # Normalize filter keys
540
+ normalized_filter = filter.each_with_object({}) do |(k, v), result|
541
+ result[normalize_key(k)] = v
542
+ end
543
+
544
+ # Slicing filters out non-declared keys.
545
+ slice(*normalized_filter.keys).each do |key, value|
546
+ next unless value
547
+
548
+ if normalized_filter[key] == []
549
+ # Declaration {:comment_ids => []}.
550
+ array_of_permitted_scalars_filter(params, key)
551
+ else
552
+ # Declaration {:user => :name} or {:user => [:name, :age, {:address => ...}]}.
553
+ params[key] = each_element(value) do |element, index|
554
+ if element.is_a?(Hash)
555
+ element = self.class.new(element) unless element.respond_to?(:permit)
556
+ element.permit(*wrap_array(normalized_filter[key]))
557
+ elsif normalized_filter[key].is_a?(Hash) && normalized_filter[key][index] == []
558
+ array_of_permitted_scalars_filter(params, index, value)
559
+ end
560
+ end
561
+ end
562
+ end
563
+ end
564
+
565
+ def each_element(value)
566
+ if value.is_a?(Array)
567
+ value.map { |el| yield el }.compact
568
+ # fields_for on an array of records uses numeric hash keys.
569
+ elsif fields_for_style?(value)
570
+ hash = value.class.new
571
+ value.each { |k, v| hash[k] = yield(v, k) }
572
+ hash
573
+ else
574
+ yield value
575
+ end
576
+ end
577
+
578
+ def fields_for_style?(object)
579
+ object.is_a?(Hash) && !object.empty? && object.all? { |k, v| k =~ /\A-?\d+\z/ && v.is_a?(Hash) }
580
+ end
581
+
582
+ def unpermitted_parameters!(params)
583
+ return unless self.class.action_on_unpermitted_parameters
584
+
585
+ unpermitted_keys = unpermitted_keys(params)
586
+
587
+ if unpermitted_keys.any?
588
+ case self.class.action_on_unpermitted_parameters
589
+ when :log
590
+ notify_unpermitted(unpermitted_keys)
591
+ when :raise
592
+ raise UnpermittedParameters.new(unpermitted_keys)
593
+ end
594
+ end
595
+ end
596
+
597
+ def unpermitted_keys(params)
598
+ keys - params.keys - NEVER_UNPERMITTED_PARAMS
599
+ end
600
+
601
+ def notify_unpermitted(keys)
602
+ if self.class.unpermitted_notification_handler
603
+ begin
604
+ self.class.unpermitted_notification_handler.call(keys)
605
+ rescue => e
606
+ # Log the error but don't prevent parameter processing
607
+ # In a real application, you might want to log this
608
+ end
609
+ end
610
+ end
611
+
612
+ # Validate that all provided metadata keys are allowed by the params class.
613
+ #
614
+ # @param params_class [Class] the params class to validate against
615
+ # @param metadata_keys [Array<Symbol>] the metadata keys to validate
616
+ # @raise [ArgumentError] if any metadata keys are not declared
617
+ # @return [void]
618
+ def validate_metadata_keys!(params_class, metadata_keys)
619
+ return if metadata_keys.empty?
620
+
621
+ disallowed_keys = metadata_keys.reject { |key| key == :current_user || params_class.metadata_allowed?(key) }
622
+
623
+ return unless disallowed_keys.any?
624
+
625
+ # Build a helpful error message
626
+ keys_list = disallowed_keys.map(&:inspect).join(', ')
627
+ class_name = params_class.name
628
+
629
+ raise ArgumentError, <<~ERROR.strip
630
+ Metadata key(s) #{keys_list} not allowed for #{class_name}.
631
+
632
+ To fix this, declare them in your params class:
633
+
634
+ class #{class_name} < ApplicationParams
635
+ metadata #{disallowed_keys.map(&:inspect).join(', ')}
636
+ end
637
+
638
+ Note: :current_user is always implicitly allowed and doesn't need to be declared.
639
+ ERROR
640
+ end
641
+ end
642
+ end
643
+ end