grape_rails_logger 1.1.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.
@@ -0,0 +1,790 @@
1
+ module GrapeRailsLogger
2
+ # Subscriber that logs Grape API requests with structured data
3
+ #
4
+ # Automatically subscribed to "grape.request" notifications.
5
+ # Logs structured hash data compatible with JSON loggers.
6
+ #
7
+ # This class is designed to be exception-safe: any error in logging
8
+ # is caught and logged separately, never breaking the request flow.
9
+ class GrapeRequestLogSubscriber
10
+ # Custom error class to distinguish logging errors from other errors
11
+ class LoggingError < StandardError
12
+ attr_reader :original_error
13
+
14
+ def initialize(original_error)
15
+ @original_error = original_error
16
+ super("Logging failed: #{original_error.class}: #{original_error.message}")
17
+ end
18
+ end
19
+
20
+ FILTERED_PARAMS = %w[password secret token key].freeze
21
+ PARAM_EXCEPTIONS = %w[controller action format].freeze
22
+ AD_PARAMS = "action_dispatch.request.parameters"
23
+
24
+ def grape_request(event)
25
+ return unless event.is_a?(ActiveSupport::Notifications::Event)
26
+
27
+ env = event.payload[:env]
28
+ return unless env.is_a?(Hash)
29
+
30
+ # Get logger from event payload (passed from middleware)
31
+ logger = event.payload[:logger]
32
+
33
+ logged_successfully = false
34
+ begin
35
+ endpoint = env[Grape::Env::API_ENDPOINT] if env.is_a?(Hash)
36
+ request = (endpoint&.respond_to?(:request) && endpoint.request) ? endpoint.request : nil
37
+
38
+ data = build_log_data(event, request, env)
39
+ logged_successfully = log_data(data, event.payload[:exception_object], logger)
40
+ event.payload[:logged_successfully] = true if logged_successfully
41
+ rescue LoggingError
42
+ # If logging itself failed, don't try to log again (would cause infinite loop or duplicates)
43
+ # The error was already logged in safe_log's rescue block
44
+ # Silently skip to avoid duplicate logs
45
+ rescue => e
46
+ # Only call fallback if we haven't successfully logged yet
47
+ # This prevents duplicate logs when logging succeeds but something else fails
48
+ unless logged_successfully
49
+ log_fallback_subscriber_error(event, e, logger)
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def build_request(env)
57
+ ::Grape::Request.new(env)
58
+ rescue
59
+ nil
60
+ end
61
+
62
+ def build_log_data(event, request, env)
63
+ db_runtime = event.payload[:db_runtime] || 0
64
+ db_calls = event.payload[:db_calls] || 0
65
+ total_runtime = begin
66
+ event.duration || 0
67
+ rescue
68
+ 0
69
+ end
70
+
71
+ status = extract_status(event) || (event.payload[:exception_object] ? 500 : 200)
72
+ rails_request = rails_request_for(env)
73
+ method = request ? safe_string(request.request_method) : safe_string(env["REQUEST_METHOD"])
74
+ path = request ? safe_string(request.path) : safe_string(env["PATH_INFO"] || env["REQUEST_URI"])
75
+
76
+ {
77
+ method: method,
78
+ path: path,
79
+ format: extract_format(request, env),
80
+ controller: extract_controller(event),
81
+ source_location: extract_source_location(event),
82
+ action: extract_action(event) || "unknown",
83
+ status: status,
84
+ host: extract_host(rails_request, request),
85
+ remote_addr: extract_remote_addr(rails_request, request),
86
+ request_id: extract_request_id(rails_request, env),
87
+ duration: total_runtime.round(2),
88
+ db: db_runtime.round(2),
89
+ db_calls: db_calls,
90
+ params: filter_params(extract_params(request, env))
91
+ }
92
+ end
93
+
94
+ def rails_request_for(env)
95
+ return nil unless defined?(ActionDispatch::Request)
96
+
97
+ ActionDispatch::Request.new(env)
98
+ rescue
99
+ nil
100
+ end
101
+
102
+ def extract_host(rails_request, grape_request)
103
+ # Prefer Rails ActionDispatch::Request#host if available
104
+ if rails_request&.respond_to?(:host)
105
+ safe_string(rails_request.host)
106
+ else
107
+ safe_string(grape_request.host)
108
+ end
109
+ end
110
+
111
+ def extract_remote_addr(rails_request, grape_request)
112
+ # Prefer Rails ActionDispatch::Request#remote_ip if available
113
+ if rails_request&.respond_to?(:remote_ip)
114
+ safe_string(rails_request.remote_ip)
115
+ elsif rails_request&.respond_to?(:ip)
116
+ safe_string(rails_request.ip)
117
+ elsif grape_request&.respond_to?(:ip)
118
+ safe_string(grape_request.ip)
119
+ end
120
+ end
121
+
122
+ def extract_request_id(rails_request, env)
123
+ # Prefer Rails ActionDispatch::Request#request_id if available
124
+ if rails_request&.respond_to?(:request_id)
125
+ safe_string(rails_request.request_id)
126
+ else
127
+ safe_string(env["action_dispatch.request_id"])
128
+ end
129
+ end
130
+
131
+ def log_data(data, exception_object, logger = nil)
132
+ if exception_object
133
+ data[:exception] = build_exception_data(exception_object)
134
+ safe_log(:error, data, logger)
135
+ else
136
+ safe_log(:info, data, logger)
137
+ end
138
+ # Mark that we successfully logged to prevent fallback from duplicating
139
+ true
140
+ rescue => e
141
+ # If logging itself fails, don't log again in fallback
142
+ # Mark that we tried to log so fallback knows not to duplicate
143
+ raise LoggingError.new(e)
144
+ end
145
+
146
+ def build_exception_data(exception)
147
+ data = {
148
+ class: exception.class.name,
149
+ message: exception.message
150
+ }
151
+ # Use Rails.env for environment checks (Rails-native)
152
+ if defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
153
+ data[:backtrace] = exception.backtrace&.first(10)
154
+ end
155
+ data
156
+ rescue => e
157
+ {class: "Unknown", message: "Failed to extract exception data: #{e.message}"}
158
+ end
159
+
160
+ def safe_log(level, data, logger = nil)
161
+ # Get logger from parameter, config, or default to Rails.logger
162
+ effective_logger = logger || resolve_effective_logger
163
+ return unless effective_logger
164
+
165
+ # Get tag from config
166
+ tag = resolve_tag
167
+
168
+ if effective_logger.respond_to?(:tagged) && tag
169
+ effective_logger.tagged(tag) do
170
+ effective_logger.public_send(level, data)
171
+ end
172
+ else
173
+ effective_logger.public_send(level, data)
174
+ end
175
+ rescue => e
176
+ # Fallback to stderr if logging fails - never raise
177
+ # Only warn in development to avoid noise in production
178
+ if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
179
+ Rails.logger&.warn("GrapeRailsLogger log error: #{e.message}")
180
+ end
181
+ end
182
+
183
+ def resolve_tag
184
+ # First check Rails config if available
185
+ if defined?(Rails) && Rails.application && Rails.application.config.respond_to?(:grape_rails_logger)
186
+ config_tag = Rails.application.config.grape_rails_logger.tag
187
+ return config_tag if config_tag
188
+ end
189
+
190
+ # Fall back to module config
191
+ if defined?(GrapeRailsLogger) && GrapeRailsLogger.respond_to?(:effective_config)
192
+ config = GrapeRailsLogger.effective_config
193
+ return config.tag if config.respond_to?(:tag) && config.tag
194
+ end
195
+
196
+ # Default tag
197
+ "Grape"
198
+ end
199
+
200
+ def resolve_effective_logger
201
+ # First check Rails config if available
202
+ if defined?(Rails) && Rails.application && Rails.application.config.respond_to?(:grape_rails_logger)
203
+ config_logger = Rails.application.config.grape_rails_logger.logger
204
+ return config_logger if config_logger
205
+ end
206
+
207
+ # Fall back to Rails.logger if available
208
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
209
+ return Rails.logger
210
+ end
211
+
212
+ nil
213
+ end
214
+
215
+ def log_fallback_subscriber_error(event, error, logger = nil)
216
+ # Try to build a proper HTTP request log even when subscriber processing fails
217
+ # This should only be called if there's an error in the subscriber itself,
218
+ # not if there's a request exception (which is already logged by log_data)
219
+ env = event.payload[:env]
220
+ return unless env.is_a?(Hash)
221
+
222
+ # If the error is the same as the exception_object, it means we already logged it
223
+ # in the normal path, so skip duplicate logging
224
+ return if error == event.payload[:exception_object]
225
+
226
+ # If the error is a LoggingError, it means logging already failed and was handled
227
+ # Don't try to log again as it would cause duplicates
228
+ return if error.is_a?(LoggingError)
229
+
230
+ # If we successfully logged in the normal path, don't log again
231
+ # This prevents duplicates when logging succeeds but something else fails
232
+ return if event.payload[:logged_successfully] == true
233
+
234
+ # Double-check: if we can see the logged_successfully flag was set, definitely skip
235
+ # This is a safety check in case the flag was set but we're still being called
236
+ return if event.payload.key?(:logged_successfully) && event.payload[:logged_successfully]
237
+
238
+ # Get logger from parameter or event payload
239
+ # Make sure we use the tagged logger if available, not raw Rails.logger
240
+ effective_logger = logger || event.payload[:logger] || resolve_effective_logger
241
+
242
+ begin
243
+ # Extract status using the same priority as extract_status
244
+ # This ensures consistency - always use response status, not exception status
245
+ status = extract_status(event)
246
+
247
+ total_runtime = begin
248
+ event.duration || 0
249
+ rescue
250
+ 0
251
+ end
252
+
253
+ # Try to build request, but don't fail if it doesn't work
254
+ request = begin
255
+ build_request(env)
256
+ rescue
257
+ # If build_request fails, we'll extract from env directly
258
+ nil
259
+ end
260
+
261
+ # Build minimal log data - extract what we can even if request building fails
262
+ rails_request = begin
263
+ rails_request_for(env)
264
+ rescue
265
+ nil
266
+ end
267
+
268
+ # Safely extract all fields with fallbacks
269
+ method = begin
270
+ request ? safe_string(request.request_method) : safe_string(env["REQUEST_METHOD"])
271
+ rescue
272
+ safe_string(env["REQUEST_METHOD"])
273
+ end
274
+
275
+ path = begin
276
+ request ? safe_string(request.path) : safe_string(env["PATH_INFO"] || env["REQUEST_URI"])
277
+ rescue
278
+ safe_string(env["PATH_INFO"] || env["REQUEST_URI"])
279
+ end
280
+
281
+ format_val = begin
282
+ request ? extract_format(request, env) : extract_format_from_env(env)
283
+ rescue
284
+ extract_format_from_env(env)
285
+ end
286
+
287
+ host = begin
288
+ request ? extract_host(rails_request, request) : extract_host_from_env(rails_request, env)
289
+ rescue
290
+ extract_host_from_env(rails_request, env)
291
+ end
292
+
293
+ remote_addr = begin
294
+ request ? extract_remote_addr(rails_request, request) : extract_remote_addr_from_env(rails_request, env)
295
+ rescue
296
+ extract_remote_addr_from_env(rails_request, env)
297
+ end
298
+
299
+ request_id = begin
300
+ extract_request_id(rails_request, env)
301
+ rescue
302
+ safe_string(env["action_dispatch.request_id"])
303
+ end
304
+
305
+ # Extract db_runtime and db_calls from event payload if available
306
+ db_runtime = begin
307
+ (event.payload[:db_runtime] || 0).round(2)
308
+ rescue
309
+ 0
310
+ end
311
+ db_calls = begin
312
+ event.payload[:db_calls] || 0
313
+ rescue
314
+ 0
315
+ end
316
+
317
+ # Try to extract action and controller even in fallback mode
318
+ action = begin
319
+ extract_action(event) || "unknown"
320
+ rescue
321
+ "unknown"
322
+ end
323
+ controller = begin
324
+ extract_controller(event)
325
+ rescue
326
+ nil
327
+ end
328
+
329
+ data = {
330
+ method: method,
331
+ path: path,
332
+ format: format_val,
333
+ status: status,
334
+ host: host,
335
+ remote_addr: remote_addr,
336
+ request_id: request_id,
337
+ duration: total_runtime.round(2),
338
+ db: db_runtime,
339
+ db_calls: db_calls,
340
+ action: action,
341
+ controller: controller,
342
+ exception: {
343
+ class: error.class.name,
344
+ message: error.message
345
+ }
346
+ }
347
+
348
+ # Use the original exception if available, otherwise use the subscriber error
349
+ # Only include exception details if this is a subscriber error, not a request exception
350
+ # (request exceptions are already logged by the normal path)
351
+ if (original_exception = event.payload[:exception_object])
352
+ # If there's an original exception AND the error is the same, we already logged it
353
+ # Skip logging to avoid duplicates
354
+ if error == original_exception
355
+ return
356
+ end
357
+ # If they're different, include the original exception details
358
+ data[:exception][:class] = original_exception.class.name
359
+ data[:exception][:message] = original_exception.message
360
+ if defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
361
+ data[:exception][:backtrace] = original_exception.backtrace&.first(10)
362
+ end
363
+ elsif defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
364
+ data[:exception][:backtrace] = error.backtrace&.first(10)
365
+ end
366
+
367
+ safe_log(:error, data, effective_logger)
368
+ rescue
369
+ # If even fallback logging fails, don't try to log again
370
+ # Silently fail to avoid infinite loops or noise
371
+ end
372
+ end
373
+
374
+ def extract_format_from_env(env)
375
+ # First check Grape's api.format
376
+ env_fmt = env["api.format"]
377
+ if env_fmt
378
+ fmt = env_fmt.to_s.sub(/^\./, "").downcase
379
+ return fmt unless fmt.empty?
380
+ end
381
+
382
+ # Try to determine format from Content-Type using Grape's content type mappings
383
+ format_from_content_type = extract_format_from_content_type(env)
384
+ return format_from_content_type if format_from_content_type
385
+
386
+ # Fall back to rack.request.formats
387
+ rack_fmt = env["rack.request.formats"]&.first
388
+ if rack_fmt
389
+ fmt = rack_fmt.to_s.sub(/^\./, "").downcase
390
+ return fmt unless fmt.empty?
391
+ end
392
+
393
+ # Fall back to content type string parsing
394
+ content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"]
395
+ if content_type
396
+ fmt = content_type.to_s.sub(/^\./, "").downcase
397
+ return fmt unless fmt.empty?
398
+ end
399
+
400
+ "json"
401
+ end
402
+
403
+ def extract_host_from_env(rails_request, env)
404
+ if rails_request&.respond_to?(:host)
405
+ safe_string(rails_request.host)
406
+ else
407
+ safe_string(env["HTTP_HOST"] || env["SERVER_NAME"])
408
+ end
409
+ end
410
+
411
+ def extract_remote_addr_from_env(rails_request, env)
412
+ if rails_request&.respond_to?(:remote_ip)
413
+ safe_string(rails_request.remote_ip)
414
+ elsif rails_request&.respond_to?(:ip)
415
+ safe_string(rails_request.ip)
416
+ else
417
+ x_forwarded_for = env["HTTP_X_FORWARDED_FOR"]
418
+ if x_forwarded_for
419
+ first_ip = x_forwarded_for.split(",").first&.strip
420
+ return safe_string(first_ip) if first_ip
421
+ end
422
+ safe_string(env["REMOTE_ADDR"])
423
+ end
424
+ end
425
+
426
+ def safe_string(value)
427
+ return nil if value.nil?
428
+ value.to_s
429
+ rescue
430
+ nil
431
+ end
432
+
433
+ def extract_status(event)
434
+ # PRIORITY 1: Status already captured from response (most reliable)
435
+ # This is set in capture_response_metadata AFTER Grape's rescue_from handlers
436
+ # This is the SINGLE SOURCE OF TRUTH - the response from Error middleware
437
+ status = event.payload[:status]
438
+ return status if status.is_a?(Integer)
439
+
440
+ # PRIORITY 2: Extract from response array directly
441
+ # This is the actual Rack response that Error middleware returned
442
+ response = event.payload[:response]
443
+ if response.is_a?(Array) && response[0].is_a?(Integer)
444
+ return response[0]
445
+ end
446
+
447
+ # PRIORITY 3: Get from endpoint status (set by Error middleware's error_response)
448
+ # Error middleware sets env[Grape::Env::API_ENDPOINT].status(status) when processing errors
449
+ # This should have been captured in capture_response_metadata, but we check again here
450
+ # as a fallback in case capture_response_metadata didn't run or failed
451
+ env = event.payload[:env]
452
+ if env.is_a?(Hash) && env[Grape::Env::API_ENDPOINT]
453
+ endpoint = env[Grape::Env::API_ENDPOINT]
454
+ if endpoint.respond_to?(:status)
455
+ endpoint_status = begin
456
+ endpoint.status
457
+ rescue
458
+ # If status method raises, ignore it
459
+ nil
460
+ end
461
+
462
+ if endpoint_status.is_a?(Integer) && endpoint_status >= 400
463
+ # Only use endpoint status if it's an error status (4xx or 5xx)
464
+ # This ensures we're not using stale status from a previous request
465
+ return endpoint_status
466
+ end
467
+ end
468
+ end
469
+
470
+ # PRIORITY 4: Fall back to exception-based status (only if no response available)
471
+ # This should rarely happen - Grape's rescue_from should return a response
472
+ if (exception = event.payload[:exception_object])
473
+ status = StatusExtractor.extract_status_from_exception(exception)
474
+ return status if status.is_a?(Integer)
475
+ end
476
+
477
+ # Default fallback - if we have an exception, assume 500
478
+ # If no exception, assume 200 (success)
479
+ event.payload[:exception_object] ? 500 : 200
480
+ end
481
+
482
+ def extract_action(event)
483
+ endpoint = event.payload.dig(:env, "api.endpoint")
484
+ return "unknown" unless endpoint
485
+ return "unknown" unless endpoint.respond_to?(:options)
486
+ return "unknown" unless endpoint.options
487
+
488
+ method = endpoint.options[:method]&.first
489
+ path = endpoint.options[:path]&.first
490
+ return "unknown" unless method && path
491
+
492
+ return method.downcase.to_s if path == "/" || path.empty?
493
+
494
+ clean_path = path.to_s.delete_prefix("/").gsub(/[:\/]/, "_").squeeze("_").gsub(/^_+|_+$/, "")
495
+ "#{method.downcase}_#{clean_path}"
496
+ rescue
497
+ "unknown"
498
+ end
499
+
500
+ def extract_controller(event)
501
+ endpoint = event.payload.dig(:env, "api.endpoint")
502
+ return nil unless endpoint&.source&.source_location
503
+
504
+ file_path = endpoint.source.source_location.first
505
+ return nil unless file_path
506
+
507
+ rails_root = safe_rails_root
508
+ return nil unless rails_root
509
+
510
+ prefix = File.join(rails_root, "app/api/")
511
+ path_without_prefix = file_path.delete_prefix(prefix).sub(/\.rb\z/, "")
512
+
513
+ # Use ActiveSupport::Inflector.camelize (Rails-native) which converts
514
+ # file paths to class names (e.g., "users" -> "Users", "users/profile" -> "Users::Profile")
515
+ # This matches Rails generator conventions for converting file paths to class names
516
+ if defined?(ActiveSupport::Inflector)
517
+ ActiveSupport::Inflector.camelize(path_without_prefix)
518
+ else
519
+ # Fallback for non-Rails: split by /, capitalize each part, join with ::
520
+ path_without_prefix.split("/").map(&:capitalize).join("::")
521
+ end
522
+ rescue
523
+ nil
524
+ end
525
+
526
+ def extract_source_location(event)
527
+ endpoint = event.payload.dig(:env, "api.endpoint")
528
+ return nil unless endpoint&.source&.source_location
529
+
530
+ loc = endpoint.source.source_location.first
531
+ line = endpoint.source.source_location.last
532
+ return nil unless loc
533
+
534
+ rails_root = safe_rails_root
535
+ if rails_root && loc.start_with?(rails_root)
536
+ loc = loc.delete_prefix(rails_root + "/")
537
+ end
538
+
539
+ "#{loc}:#{line}"
540
+ rescue
541
+ nil
542
+ end
543
+
544
+ def safe_rails_root
545
+ return nil unless defined?(Rails)
546
+ return nil unless Rails.respond_to?(:root)
547
+
548
+ root = Rails.root
549
+ return nil if root.nil?
550
+
551
+ # Use Rails.root.to_s which handles Pathname correctly
552
+ root.to_s
553
+ rescue
554
+ nil
555
+ end
556
+
557
+ def extract_params(request, env = nil)
558
+ return {} unless env.is_a?(Hash)
559
+
560
+ endpoint = env[Grape::Env::API_ENDPOINT]
561
+ return {} unless endpoint&.respond_to?(:request) && endpoint.request
562
+
563
+ endpoint_params = endpoint.request.params
564
+ return {} if endpoint_params.nil? || endpoint_params.empty?
565
+
566
+ params_hash = if endpoint_params.respond_to?(:to_unsafe_h)
567
+ endpoint_params.to_unsafe_h
568
+ elsif endpoint_params.respond_to?(:to_h)
569
+ endpoint_params.to_h
570
+ else
571
+ endpoint_params.is_a?(Hash) ? endpoint_params : {}
572
+ end
573
+ return params_hash.except("route_info", :route_info) unless params_hash.empty?
574
+
575
+ # Fallback: Parse JSON body for non-standard JSON content types
576
+ req = endpoint.request
577
+ content_type = req.content_type || req.env["CONTENT_TYPE"] || ""
578
+ return {} if !((content_type.include?("json") || content_type.include?("application/vnd.api")) && req.env["rack.input"])
579
+
580
+ begin
581
+ original_pos = begin
582
+ req.env["rack.input"].pos
583
+ rescue
584
+ 0
585
+ end
586
+ begin
587
+ req.env["rack.input"].rewind
588
+ rescue
589
+ nil
590
+ end
591
+ body_content = begin
592
+ req.env["rack.input"].read
593
+ rescue
594
+ nil
595
+ end
596
+ req.env["rack.input"].pos = begin
597
+ original_pos
598
+ rescue
599
+ nil
600
+ end
601
+
602
+ if body_content && !body_content.empty?
603
+ parsed_json = begin
604
+ JSON.parse(body_content)
605
+ rescue
606
+ nil
607
+ end
608
+ return parsed_json.except("route_info", :route_info) if parsed_json.is_a?(Hash)
609
+ end
610
+ rescue
611
+ end
612
+
613
+ {}
614
+ rescue
615
+ {}
616
+ end
617
+
618
+ def extract_format(request, env = nil)
619
+ env ||= request.env if request.respond_to?(:env)
620
+
621
+ # First check Grape's api.format (most reliable for Grape APIs)
622
+ # This is set by Grape based on Content-Type and Accept headers
623
+ if env.is_a?(Hash)
624
+ env_fmt = env["api.format"]
625
+ if env_fmt
626
+ # Remove leading dot if present and convert to string
627
+ fmt = env_fmt.to_s.sub(/^\./, "").downcase
628
+ return fmt unless fmt.empty?
629
+ end
630
+ end
631
+
632
+ # Try Grape's request.format method
633
+ fmt = request.try(:format)
634
+ if fmt
635
+ fmt_str = fmt.to_s.sub(/^\./, "").downcase
636
+ return fmt_str unless fmt_str.empty?
637
+ end
638
+
639
+ # Try to determine format from Content-Type using Grape's content type mappings
640
+ # This is generic and works with any custom content types defined in the API
641
+ if env.is_a?(Hash)
642
+ format_from_content_type = extract_format_from_content_type(env)
643
+ return format_from_content_type if format_from_content_type
644
+ end
645
+
646
+ # Fall back to Rails ActionDispatch::Request#format
647
+ if defined?(ActionDispatch::Request) && env.is_a?(Hash)
648
+ begin
649
+ rails_request = ActionDispatch::Request.new(env)
650
+ if rails_request.respond_to?(:format) && rails_request.format
651
+ fmt_str = rails_request.format.to_sym.to_s.downcase
652
+ return fmt_str unless fmt_str.empty?
653
+ end
654
+ rescue
655
+ # Fall through to other detection methods
656
+ end
657
+ end
658
+
659
+ # Fall back to rack.request.formats
660
+ if env.is_a?(Hash)
661
+ rack_fmt = env["rack.request.formats"]&.first
662
+ if rack_fmt
663
+ fmt_str = rack_fmt.to_s.sub(/^\./, "").downcase
664
+ return fmt_str unless fmt_str.empty?
665
+ end
666
+ end
667
+
668
+ # Default to json if nothing found
669
+ "json"
670
+ end
671
+
672
+ def extract_format_from_content_type(env)
673
+ return nil unless env.is_a?(Hash)
674
+
675
+ content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"]
676
+ accept = env["HTTP_ACCEPT"]
677
+ return nil unless content_type || accept
678
+
679
+ endpoint = env["api.endpoint"]
680
+ return nil unless endpoint
681
+
682
+ api_class = endpoint.respond_to?(:options) ? endpoint.options[:for] : nil
683
+ api_class ||= endpoint.respond_to?(:namespace) ? endpoint.namespace&.options&.dig(:for) : nil
684
+ api_class ||= endpoint.respond_to?(:route) ? endpoint.route&.options&.dig(:for) : nil
685
+ return nil unless api_class&.respond_to?(:content_types, true)
686
+
687
+ begin
688
+ content_types = api_class.content_types
689
+ return nil unless content_types.is_a?(Hash)
690
+
691
+ content_types.each do |format_name, mime_type|
692
+ mime_types = mime_type.is_a?(Array) ? mime_type : [mime_type]
693
+ if content_type && mime_types.any? { |mime| content_type.include?(mime.to_s) }
694
+ return format_name.to_s.downcase
695
+ end
696
+ if accept && mime_types.any? { |mime| accept.include?(mime.to_s) }
697
+ return format_name.to_s.downcase
698
+ end
699
+ end
700
+ rescue
701
+ end
702
+
703
+ nil
704
+ end
705
+
706
+ def filter_params(params)
707
+ return {} unless params
708
+ return {} unless params.is_a?(Hash)
709
+
710
+ cleaned = if (filter = rails_parameter_filter)
711
+ # Create a deep copy since filter modifies in place (Rails 6+)
712
+ params_copy = params.respond_to?(:deep_dup) ? params.deep_dup : params.dup
713
+ filter.filter(params_copy)
714
+ else
715
+ filter_parameters_manually(params)
716
+ end
717
+
718
+ cleaned.is_a?(Hash) ? cleaned.reject { |key, _| PARAM_EXCEPTIONS.include?(key.to_s) } : {}
719
+ rescue
720
+ # Don't log - just fallback to manual filtering
721
+ # Logging happens only in subscriber
722
+ filter_parameters_manually(params).reject { |key, _| PARAM_EXCEPTIONS.include?(key.to_s) }
723
+ end
724
+
725
+ def rails_parameter_filter
726
+ # Use Rails.application.config.filter_parameters (Rails-native configuration)
727
+ return nil unless defined?(Rails) && defined?(Rails.application)
728
+ return nil unless Rails.application.config.respond_to?(:filter_parameters)
729
+
730
+ filter_parameters = Rails.application.config.filter_parameters
731
+ return nil if filter_parameters.nil? || filter_parameters.empty?
732
+
733
+ # Prefer ActiveSupport::ParameterFilter (Rails 6.1+)
734
+ # This is the Rails-native parameter filtering system
735
+ if defined?(ActiveSupport::ParameterFilter)
736
+ ActiveSupport::ParameterFilter.new(filter_parameters)
737
+ # Fall back to ActionDispatch::Http::ParameterFilter (Rails 6.0 and earlier)
738
+ elsif defined?(ActionDispatch::Http::ParameterFilter)
739
+ ActionDispatch::Http::ParameterFilter.new(filter_parameters)
740
+ end
741
+ rescue
742
+ # Log but don't fail - fall back to manual filtering
743
+ # Don't log - just return nil
744
+ # Logging happens only in subscriber
745
+ nil
746
+ end
747
+
748
+ def filter_parameters_manually(params, depth = 0)
749
+ return {} unless params
750
+ return {"[FILTERED]" => "[max_depth_exceeded]"} if depth > 10
751
+
752
+ return {} unless params.is_a?(Hash)
753
+
754
+ params.each_with_object({}) do |(key, value), result|
755
+ next if result.size >= 50 # Limit hash size
756
+
757
+ result[key] = if should_filter_key?(key)
758
+ # When key should be filtered, replace the value (not the key name)
759
+ "[FILTERED]"
760
+ else
761
+ case value
762
+ when Hash
763
+ filter_parameters_manually(value, depth + 1)
764
+ when Array
765
+ value.first(100).map { |v| v.is_a?(Hash) ? filter_parameters_manually(v, depth + 1) : filter_value(v) }
766
+ else
767
+ filter_value(value)
768
+ end
769
+ end
770
+ end
771
+ rescue
772
+ # Don't log - just return empty hash
773
+ # Logging happens only in subscriber
774
+ {}
775
+ end
776
+
777
+ def filter_value(value)
778
+ return value unless value.is_a?(String)
779
+ value_lower = value.downcase
780
+ return "[FILTERED]" if FILTERED_PARAMS.any? { |param| value_lower.include?(param.downcase) }
781
+
782
+ value
783
+ end
784
+
785
+ def should_filter_key?(key)
786
+ key_lower = key.to_s.downcase
787
+ FILTERED_PARAMS.any? { |param| key_lower.include?(param.downcase) }
788
+ end
789
+ end
790
+ end