securial 1.0.1 → 1.0.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +4 -0
  3. data/README.md +19 -12
  4. data/app/controllers/concerns/securial/identity.rb +91 -2
  5. data/app/controllers/securial/accounts_controller.rb +68 -5
  6. data/app/controllers/securial/application_controller.rb +34 -2
  7. data/app/controllers/securial/passwords_controller.rb +44 -4
  8. data/app/controllers/securial/role_assignments_controller.rb +55 -4
  9. data/app/controllers/securial/roles_controller.rb +54 -0
  10. data/app/controllers/securial/sessions_controller.rb +77 -3
  11. data/app/controllers/securial/status_controller.rb +24 -0
  12. data/app/controllers/securial/users_controller.rb +54 -0
  13. data/app/jobs/securial/application_job.rb +9 -0
  14. data/app/mailers/securial/application_mailer.rb +12 -0
  15. data/app/mailers/securial/securial_mailer.rb +30 -0
  16. data/app/models/concerns/securial/password_resettable.rb +70 -0
  17. data/app/models/securial/application_record.rb +19 -0
  18. data/app/models/securial/current.rb +13 -0
  19. data/app/models/securial/role.rb +17 -0
  20. data/app/models/securial/role_assignment.rb +16 -0
  21. data/app/models/securial/session.rb +79 -1
  22. data/app/models/securial/user.rb +34 -0
  23. data/lib/generators/factory_bot/model/model_generator.rb +1 -0
  24. data/lib/securial/auth/auth_encoder.rb +78 -0
  25. data/lib/securial/auth/session_creator.rb +49 -0
  26. data/lib/securial/auth/token_generator.rb +74 -0
  27. data/lib/securial/auth.rb +44 -6
  28. data/lib/securial/cli.rb +124 -0
  29. data/lib/securial/config/signature.rb +116 -5
  30. data/lib/securial/config/validation.rb +91 -0
  31. data/lib/securial/config.rb +49 -2
  32. data/lib/securial/engine.rb +41 -0
  33. data/lib/securial/error/auth.rb +52 -0
  34. data/lib/securial/error/base_securial_error.rb +51 -0
  35. data/lib/securial/error/config.rb +33 -0
  36. data/lib/securial/error.rb +33 -3
  37. data/lib/securial/helpers/key_transformer.rb +106 -0
  38. data/lib/securial/helpers/normalizing_helper.rb +69 -0
  39. data/lib/securial/helpers/regex_helper.rb +122 -0
  40. data/lib/securial/helpers/roles_helper.rb +71 -2
  41. data/lib/securial/helpers.rb +48 -4
  42. data/lib/securial/logger/broadcaster.rb +89 -1
  43. data/lib/securial/logger/builder.rb +54 -1
  44. data/lib/securial/logger/formatter.rb +73 -0
  45. data/lib/securial/logger.rb +42 -1
  46. data/lib/securial/middleware/request_tag_logger.rb +80 -0
  47. data/lib/securial/middleware/response_headers.rb +51 -3
  48. data/lib/securial/middleware/transform_request_keys.rb +143 -20
  49. data/lib/securial/middleware/transform_response_keys.rb +84 -4
  50. data/lib/securial/middleware.rb +40 -9
  51. data/lib/securial/security/request_rate_limiter.rb +47 -1
  52. data/lib/securial/security.rb +37 -6
  53. data/lib/securial/version.rb +8 -1
  54. data/lib/securial.rb +36 -0
  55. metadata +21 -15
@@ -1,3 +1,19 @@
1
+ # @title Securial Logger Builder
2
+ #
3
+ # Logger construction utilities for the Securial framework.
4
+ #
5
+ # This file defines a builder class that constructs and configures loggers for the Securial
6
+ # framework based on application configuration. It supports multiple logging destinations
7
+ # (stdout and file) with appropriate formatters for each, and combines them using a
8
+ # broadcaster pattern for unified logging.
9
+ #
10
+ # @example Building a logger with defaults from configuration
11
+ # # Securial.configuration has been set up elsewhere
12
+ # logger = Securial::Logger::Builder.build
13
+ #
14
+ # # Log messages go to both configured destinations
15
+ # logger.info("User authentication successful")
16
+ #
1
17
  require "logger"
2
18
  require "active_support/logger"
3
19
  require "active_support/tagged_logging"
@@ -7,7 +23,22 @@ require "securial/logger/formatter"
7
23
 
8
24
  module Securial
9
25
  module Logger
26
+ # Builder for constructing Securial's logging system.
27
+ #
28
+ # This class provides factory methods to create properly configured logger instances
29
+ # based on the application's configuration settings. It supports multiple logging
30
+ # destinations and handles the setup of formatters, log levels, and tagging.
31
+ #
10
32
  class Builder
33
+ # Builds a complete logger system based on configuration settings.
34
+ #
35
+ # Creates file and/or stdout loggers as specified in configuration and
36
+ # combines them using a Broadcaster to provide unified logging to multiple
37
+ # destinations with appropriate formatting for each.
38
+ #
39
+ # @return [Securial::Logger::Broadcaster] A broadcaster containing all configured loggers
40
+ # @see Securial::Logger::Broadcaster
41
+ #
11
42
  def self.build
12
43
  loggers = []
13
44
  progname = "Securial"
@@ -23,6 +54,17 @@ module Securial
23
54
  Broadcaster.new(loggers)
24
55
  end
25
56
 
57
+ # Creates and configures a file logger.
58
+ #
59
+ # Sets up a logger that writes to a Rails environment-specific log file
60
+ # with plain text formatting and adds it to the provided loggers array.
61
+ #
62
+ # @param progname [String] The program name to include in log entries
63
+ # @param level [Integer, Symbol] The log level (e.g., :info, :debug)
64
+ # @param loggers [Array<Logger>] Array to which the new logger will be added
65
+ # @return [ActiveSupport::TaggedLogging] The configured file logger
66
+ # @see Securial::Logger::Formatter::PlainFormatter
67
+ #
26
68
  def self.create_file_logger(progname, level, loggers)
27
69
  file_logger = ::Logger.new(Rails.root.join("log", "securial-#{Rails.env}.log"))
28
70
  file_logger.level = level
@@ -32,12 +74,23 @@ module Securial
32
74
  loggers << tagged_file_logger
33
75
  end
34
76
 
77
+ # Creates and configures a stdout logger.
78
+ #
79
+ # Sets up a logger that writes to standard output with colorful formatting
80
+ # and adds it to the provided loggers array.
81
+ #
82
+ # @param progname [String] The program name to include in log entries
83
+ # @param level [Integer, Symbol] The log level (e.g., :info, :debug)
84
+ # @param loggers [Array<Logger>] Array to which the new logger will be added
85
+ # @return [ActiveSupport::TaggedLogging] The configured stdout logger
86
+ # @see Securial::Logger::Formatter::ColorfulFormatter
87
+ #
35
88
  def self.create_stdout_logger(progname, level, loggers)
36
89
  stdout_logger = ::Logger.new($stdout)
37
90
  stdout_logger.level = level
38
91
  stdout_logger.progname = progname
39
92
  stdout_logger.formatter = Formatter::ColorfulFormatter.new
40
- tagged_stdout_logger = ActiveSupport::TaggedLogging.new(stdout_logger)
93
+ tagged_stdout_logger = ActiveSupport::TaggedLogging.new(stdout_logger)
41
94
  loggers << tagged_stdout_logger
42
95
  end
43
96
  end
@@ -1,6 +1,33 @@
1
+ # @title Securial Logger Formatters
2
+ #
3
+ # Log formatting utilities for the Securial framework's logging system.
4
+ #
5
+ # This file defines formatter classes that determine how log messages are displayed,
6
+ # providing both colorful terminal-friendly output and plain text output options.
7
+ # These formatters are used by the Securial::Logger system to ensure consistent
8
+ # and readable log formats across different environments.
9
+ #
10
+ # @example Using a formatter with a standard Ruby logger
11
+ # require 'logger'
12
+ # logger = Logger.new(STDOUT)
13
+ # logger.formatter = Securial::Logger::Formatter::ColorfulFormatter.new
14
+ #
15
+ # logger.info("Application started")
16
+ # # Output: [2023-11-15 14:30:22] INFO -- Application started (in green color)
17
+ #
1
18
  module Securial
2
19
  module Logger
20
+ # Formatting utilities for Securial's logging system.
21
+ #
22
+ # This module contains formatter classes and constants that determine
23
+ # how log messages are presented. It provides both colored output for
24
+ # terminal environments and plain text output for file logging.
25
+ #
3
26
  module Formatter
27
+ # Terminal color codes for different log severity levels.
28
+ #
29
+ # @return [Hash{String => String}] Mapping of severity names to ANSI color codes
30
+ #
4
31
  COLORS = {
5
32
  "DEBUG" => "\e[36m", # cyan
6
33
  "INFO" => "\e[32m", # green
@@ -9,10 +36,38 @@ module Securial
9
36
  "FATAL" => "\e[35m", # magenta
10
37
  "UNKNOWN" => "\e[37m", # white
11
38
  }.freeze
39
+
40
+ # ANSI code to reset terminal colors.
41
+ #
42
+ # @return [String] Terminal color reset sequence
43
+ #
12
44
  CLEAR = "\e[0m"
45
+
46
+ # Width used for severity level padding in log output.
47
+ #
48
+ # @return [Integer] Number of characters to use for severity field
49
+ #
13
50
  SEVERITY_WIDTH = 5
14
51
 
52
+ # Formatter that adds color to log output for terminal display.
53
+ #
54
+ # This formatter colorizes log messages based on their severity level,
55
+ # making them easier to distinguish in terminal output. It follows the
56
+ # standard Ruby Logger formatter interface.
57
+ #
58
+ # @example
59
+ # logger = Logger.new(STDOUT)
60
+ # logger.formatter = Securial::Logger::Formatter::ColorfulFormatter.new
61
+ #
15
62
  class ColorfulFormatter
63
+ # Formats a log message with color based on severity.
64
+ #
65
+ # @param severity [String] Log severity level (DEBUG, INFO, etc.)
66
+ # @param timestamp [Time] Time when the log event occurred
67
+ # @param progname [String] Program name or context for the log message
68
+ # @param msg [String] The log message itself
69
+ # @return [String] Formatted log message with appropriate ANSI color codes
70
+ #
16
71
  def call(severity, timestamp, progname, msg)
17
72
  color = COLORS[severity] || CLEAR
18
73
  padded_severity = severity.ljust(SEVERITY_WIDTH)
@@ -22,7 +77,25 @@ module Securial
22
77
  end
23
78
  end
24
79
 
80
+ # Formatter that produces plain text log output without colors.
81
+ #
82
+ # This formatter is suitable for file logging or environments where
83
+ # terminal colors are not supported. It follows the standard Ruby
84
+ # Logger formatter interface.
85
+ #
86
+ # @example
87
+ # logger = Logger.new('application.log')
88
+ # logger.formatter = Securial::Logger::Formatter::PlainFormatter.new
89
+ #
25
90
  class PlainFormatter
91
+ # Formats a log message in plain text without color codes.
92
+ #
93
+ # @param severity [String] Log severity level (DEBUG, INFO, etc.)
94
+ # @param timestamp [Time] Time when the log event occurred
95
+ # @param progname [String] Program name or context for the log message
96
+ # @param msg [String] The log message itself
97
+ # @return [String] Formatted log message as plain text
98
+ #
26
99
  def call(severity, timestamp, progname, msg)
27
100
  padded_severity = severity.ljust(SEVERITY_WIDTH)
28
101
  formatted = "[#{timestamp.strftime("%Y-%m-%d %H:%M:%S")}] #{padded_severity} -- #{msg}\n"
@@ -1,13 +1,54 @@
1
+ # @title Securial Logger Configuration
2
+ #
3
+ # Defines the logging interface for the Securial framework.
4
+ #
5
+ # This file establishes the logging system for Securial, providing methods
6
+ # to access and configure the application's logger instance. By default,
7
+ # it initializes a logger using the Securial::Logger::Builder class, which
8
+ # configures appropriate log levels and formatters based on the current environment.
9
+ #
10
+ # @example Basic logging usage
11
+ # # Log messages at different levels
12
+ # Securial.logger.debug("Detailed debugging information")
13
+ # Securial.logger.info("General information about system operation")
14
+ # Securial.logger.warn("Warning about potential issue")
15
+ # Securial.logger.error("Error condition")
16
+ #
17
+ # @example Setting a custom logger
18
+ # # Configure a custom logger
19
+ # custom_logger = Logger.new(STDOUT)
20
+ # custom_logger.level = :info
21
+ # Securial.logger = custom_logger
22
+ #
1
23
  require_relative "logger/builder"
2
24
 
3
25
  module Securial
4
26
  extend self
5
27
  attr_accessor :logger
6
28
 
29
+ # Returns the logger instance used by Securial.
30
+ #
31
+ # If no logger has been set, initializes a new logger instance using
32
+ # the Securial::Logger::Builder class, which configures the logger
33
+ # based on the current environment settings.
34
+ #
35
+ # @return [Securial::Logger::Builder] the configured logger instance
36
+ # @see Securial::Logger::Builder
7
37
  def logger
8
- @logger ||= Logger::Builder.build
38
+ @logger ||= Securial::Logger::Builder.build
9
39
  end
10
40
 
41
+ # Sets the logger instance for Securial.
42
+ #
43
+ # This allows applications to provide their own custom logger
44
+ # implementation that may have specialized formatting or output
45
+ # destinations.
46
+ #
47
+ # @param logger [Logger] a Logger-compatible object that responds to standard
48
+ # logging methods (debug, info, warn, error, fatal)
49
+ # @return [Logger] the newly set logger instance
50
+ # @example
51
+ # Securial.logger = Rails.logger
11
52
  def logger=(logger)
12
53
  @logger = logger
13
54
  end
@@ -1,11 +1,62 @@
1
+ # @title Securial Request Tag Logger Middleware
2
+ #
3
+ # Rack middleware for adding request context to log messages.
4
+ #
5
+ # This middleware automatically tags all log messages within a request with
6
+ # contextual information like request ID, IP address, and user agent. This
7
+ # enables better tracking and debugging of requests across the application
8
+ # by providing consistent context in all log entries.
9
+ #
10
+ # @example Adding middleware to Rails application
11
+ # # config/application.rb
12
+ # config.middleware.use Securial::Middleware::RequestTagLogger
13
+ #
14
+ # @example Log output with request tags
15
+ # # Without middleware:
16
+ # [2024-06-27 10:30:15] INFO -- User authentication successful
17
+ #
18
+ # # With middleware:
19
+ # [2024-06-27 10:30:15] INFO -- [abc123-def456] [IP:192.168.1.100] [UA:Mozilla/5.0...] User authentication successful
20
+ #
1
21
  module Securial
2
22
  module Middleware
23
+ # Rack middleware that adds request context tags to log messages.
24
+ #
25
+ # This middleware intercepts requests and wraps the application call
26
+ # with tagged logging, ensuring all log messages generated during
27
+ # request processing include relevant request metadata for better
28
+ # traceability and debugging.
29
+ #
3
30
  class RequestTagLogger
31
+ # Initializes the middleware with the Rack application and logger.
32
+ #
33
+ # @param [#call] app The Rack application to wrap
34
+ # @param [Logger] logger The logger instance to use for tagging (defaults to Securial.logger)
35
+ #
36
+ # @example
37
+ # middleware = RequestTagLogger.new(app, Rails.logger)
38
+ #
4
39
  def initialize(app, logger = Securial.logger)
5
40
  @app = app
6
41
  @logger = logger
7
42
  end
8
43
 
44
+ # Processes the request with tagged logging context.
45
+ #
46
+ # Extracts request metadata from the Rack environment and applies
47
+ # them as tags to all log messages generated during the request
48
+ # processing. Tags are automatically removed after the request completes.
49
+ #
50
+ # @param [Hash] env The Rack environment hash
51
+ # @return [Array] The Rack response array [status, headers, body]
52
+ #
53
+ # @example Request processing with tags
54
+ # # All log messages during this request will include:
55
+ # # - Request ID (if available)
56
+ # # - IP address (if available)
57
+ # # - User agent (if available)
58
+ # response = middleware.call(env)
59
+ #
9
60
  def call(env)
10
61
  request_id = request_id_from_env(env)
11
62
  ip_address = ip_from_env(env)
@@ -23,14 +74,43 @@ module Securial
23
74
 
24
75
  private
25
76
 
77
+ # Extracts the request ID from the Rack environment.
78
+ #
79
+ # Looks for request ID in ActionDispatch's request_id or the
80
+ # X-Request-ID header, providing request traceability across
81
+ # multiple services and log aggregation systems.
82
+ #
83
+ # @param [Hash] env The Rack environment hash
84
+ # @return [String, nil] The request ID if found, nil otherwise
85
+ # @api private
86
+ #
26
87
  def request_id_from_env(env)
27
88
  env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
28
89
  end
29
90
 
91
+ # Extracts the client IP address from the Rack environment.
92
+ #
93
+ # Prioritizes ActionDispatch's processed remote IP (which handles
94
+ # proxy headers) over the raw REMOTE_ADDR to ensure accurate
95
+ # client identification behind load balancers and proxies.
96
+ #
97
+ # @param [Hash] env The Rack environment hash
98
+ # @return [String, nil] The client IP address if found, nil otherwise
99
+ # @api private
100
+ #
30
101
  def ip_from_env(env)
31
102
  env["action_dispatch.remote_ip"]&.to_s || env["REMOTE_ADDR"]
32
103
  end
33
104
 
105
+ # Extracts the user agent string from the Rack environment.
106
+ #
107
+ # Retrieves the HTTP User-Agent header to provide context about
108
+ # the client application or browser making the request.
109
+ #
110
+ # @param [Hash] env The Rack environment hash
111
+ # @return [String, nil] The user agent string if found, nil otherwise
112
+ # @api private
113
+ #
34
114
  def user_agent_from_env(env)
35
115
  env["HTTP_USER_AGENT"]
36
116
  end
@@ -1,17 +1,65 @@
1
+ # @title Securial Response Headers Middleware
2
+ #
3
+ # Rack middleware for adding Securial-specific headers to HTTP responses.
4
+ #
5
+ # This middleware automatically adds identification headers to all HTTP responses
6
+ # to indicate that the application is using Securial for authentication and to
7
+ # provide developer attribution. These headers can be useful for debugging,
8
+ # monitoring, and identifying Securial-powered applications.
9
+ #
10
+ # @example Adding middleware to Rails application
11
+ # # config/application.rb
12
+ # config.middleware.use Securial::Middleware::ResponseHeaders
13
+ #
14
+ # @example Response headers added by middleware
15
+ # # HTTP Response Headers:
16
+ # X-Securial-Mounted: true
17
+ # X-Securial-Developer: Aly Badawy - https://alybadawy.com | @alybadawy
18
+ #
1
19
  module Securial
2
20
  module Middleware
3
- # This middleware removes sensitive headers from the request environment.
4
- # It is designed to enhance security by ensuring that sensitive information
5
- # is not inadvertently logged or processed.
21
+ # Rack middleware that adds Securial identification headers to responses.
22
+ #
23
+ # This middleware enhances security transparency by clearly identifying
24
+ # when Securial is mounted in an application and provides developer
25
+ # attribution information in response headers.
26
+ #
6
27
  class ResponseHeaders
28
+ # Initializes the middleware with the Rack application.
29
+ #
30
+ # @param [#call] app The Rack application to wrap
31
+ #
32
+ # @example
33
+ # middleware = ResponseHeaders.new(app)
34
+ #
7
35
  def initialize(app)
8
36
  @app = app
9
37
  end
10
38
 
39
+ # Processes the request and adds Securial headers to the response.
40
+ #
41
+ # Calls the wrapped application and then adds identification headers
42
+ # to the response before returning it to the client. The headers
43
+ # provide clear indication of Securial usage and developer attribution.
44
+ #
45
+ # @param [Hash] env The Rack environment hash
46
+ # @return [Array] The Rack response array [status, headers, body] with added headers
47
+ #
48
+ # @example Headers added to response
49
+ # # Original response headers remain unchanged
50
+ # # Additional headers added:
51
+ # # X-Securial-Mounted: "true"
52
+ # # X-Securial-Developer: "Aly Badawy - https://alybadawy.com | @alybadawy"
53
+ #
11
54
  def call(env)
12
55
  status, headers, response = @app.call(env)
56
+
57
+ # Indicate that Securial is mounted and active
13
58
  headers["X-Securial-Mounted"] = "true"
59
+
60
+ # Provide developer attribution
14
61
  headers["X-Securial-Developer"] = "Aly Badawy - https://alybadawy.com | @alybadawy"
62
+
15
63
  [status, headers, response]
16
64
  end
17
65
  end
@@ -1,35 +1,158 @@
1
+ # @title Securial Transform Request Keys Middleware
2
+ #
3
+ # Rack middleware for transforming JSON request body keys to Ruby conventions.
4
+ #
5
+ # This middleware automatically converts incoming JSON request body keys from
6
+ # camelCase or PascalCase to snake_case, allowing Rails applications to receive
7
+ # JavaScript-style APIs while maintaining Ruby naming conventions internally.
8
+ # It only processes JSON requests and gracefully handles malformed JSON.
9
+ #
10
+ # @example Adding middleware to Rails application
11
+ # # config/application.rb
12
+ # config.middleware.use Securial::Middleware::TransformRequestKeys
13
+ #
14
+ # @example Request transformation
15
+ # # Incoming JSON request body:
16
+ # { "userName": "john", "emailAddress": "john@example.com" }
17
+ #
18
+ # # Transformed for Rails application:
19
+ # { "user_name": "john", "email_address": "john@example.com" }
20
+ #
21
+ require "json"
22
+ require "stringio"
23
+
1
24
  module Securial
2
25
  module Middleware
3
- # This middleware transforms request keys to a specified format.
4
- # It uses the KeyTransformer helper to apply the transformation.
26
+ # Rack middleware that transforms JSON request body keys to snake_case.
27
+ #
28
+ # This middleware enables Rails applications to accept camelCase JSON
29
+ # from frontend applications while automatically converting keys to
30
+ # Ruby's snake_case convention before they reach the application.
5
31
  #
6
- # It reads the request body if the content type is JSON and transforms
7
- # the keys to underscore format. If the body is not valid JSON, it does nothing.
8
32
  class TransformRequestKeys
33
+ # Initializes the middleware with the Rack application.
34
+ #
35
+ # @param [#call] app The Rack application to wrap
36
+ #
37
+ # @example
38
+ # middleware = TransformRequestKeys.new(app)
39
+ #
9
40
  def initialize(app)
10
41
  @app = app
11
42
  end
12
43
 
44
+ # Processes the request and transforms JSON body keys if applicable.
45
+ #
46
+ # Intercepts JSON requests, parses the body, transforms all keys to
47
+ # snake_case using the KeyTransformer helper, and replaces the request
48
+ # body with the transformed JSON. Non-JSON requests pass through unchanged.
49
+ #
50
+ # @param [Hash] env The Rack environment hash
51
+ # @return [Array] The Rack response array [status, headers, body]
52
+ #
53
+ # @example JSON transformation
54
+ # # Original request body: { "firstName": "John", "lastName": "Doe" }
55
+ # # Transformed body: { "first_name": "John", "last_name": "Doe" }
56
+ #
57
+ # @example Non-JSON requests
58
+ # # Form data, XML, and other content types pass through unchanged
59
+ #
60
+ # @example Malformed JSON handling
61
+ # # Invalid JSON is left unchanged and passed to the application
62
+ # # The application can handle the JSON parsing error appropriately
63
+ #
13
64
  def call(env)
14
- if env["CONTENT_TYPE"]&.include?("application/json")
15
- req = Rack::Request.new(env)
16
- if (req.body&.size || 0) > 0
17
- raw = req.body.read
18
- req.body.rewind
19
- begin
20
- parsed = JSON.parse(raw)
21
- transformed = Securial::Helpers::KeyTransformer.deep_transform_keys(parsed) do |key|
22
- Securial::Helpers::KeyTransformer.underscore(key)
23
- end
24
- env["rack.input"] = StringIO.new(JSON.dump(transformed))
25
- env["rack.input"].rewind
26
- rescue JSON::ParserError
27
- # no-op
28
- end
29
- end
65
+ if json_request?(env)
66
+ transform_json_body(env)
30
67
  end
68
+
31
69
  @app.call(env)
32
70
  end
71
+
72
+ private
73
+
74
+ # Checks if the request contains JSON content.
75
+ #
76
+ # @param [Hash] env The Rack environment hash
77
+ # @return [Boolean] true if the request has JSON content type
78
+ # @api private
79
+ #
80
+ def json_request?(env)
81
+ env["CONTENT_TYPE"]&.include?("application/json")
82
+ end
83
+
84
+ # Transforms JSON request body keys to snake_case.
85
+ #
86
+ # Reads the request body, parses it as JSON, transforms all keys
87
+ # to snake_case, and replaces the original body with the transformed
88
+ # JSON. Gracefully handles empty bodies and malformed JSON.
89
+ #
90
+ # @param [Hash] env The Rack environment hash
91
+ # @return [void]
92
+ # @api private
93
+ #
94
+ def transform_json_body(env)
95
+ req = Rack::Request.new(env)
96
+
97
+ return unless request_has_body?(req)
98
+
99
+ raw_body = read_request_body(req)
100
+
101
+ begin
102
+ parsed_json = JSON.parse(raw_body)
103
+ transformed_json = transform_keys_to_snake_case(parsed_json)
104
+ replace_request_body(env, transformed_json)
105
+ rescue JSON::ParserError
106
+ # Malformed JSON - leave unchanged for application to handle
107
+ end
108
+ end
109
+
110
+ # Checks if the request has a body to process.
111
+ #
112
+ # @param [Rack::Request] req The Rack request object
113
+ # @return [Boolean] true if the request has a non-empty body
114
+ # @api private
115
+ #
116
+ def request_has_body?(req)
117
+ (req.body&.size || 0) > 0
118
+ end
119
+
120
+ # Reads and rewinds the request body.
121
+ #
122
+ # @param [Rack::Request] req The Rack request object
123
+ # @return [String] The raw request body content
124
+ # @api private
125
+ #
126
+ def read_request_body(req)
127
+ raw = req.body.read
128
+ req.body.rewind
129
+ raw
130
+ end
131
+
132
+ # Transforms all keys in the JSON structure to snake_case.
133
+ #
134
+ # @param [Object] json_data The parsed JSON data structure
135
+ # @return [Object] The data structure with transformed keys
136
+ # @api private
137
+ #
138
+ def transform_keys_to_snake_case(json_data)
139
+ Securial::Helpers::KeyTransformer.deep_transform_keys(json_data) do |key|
140
+ Securial::Helpers::KeyTransformer.underscore(key)
141
+ end
142
+ end
143
+
144
+ # Replaces the request body with transformed JSON.
145
+ #
146
+ # @param [Hash] env The Rack environment hash
147
+ # @param [Object] transformed_data The transformed JSON data
148
+ # @return [void]
149
+ # @api private
150
+ #
151
+ def replace_request_body(env, transformed_data)
152
+ new_body = JSON.dump(transformed_data)
153
+ env["rack.input"] = StringIO.new(new_body)
154
+ env["rack.input"].rewind
155
+ end
33
156
  end
34
157
  end
35
158
  end