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.
- checksums.yaml +4 -4
- data/.yardopts +4 -0
- data/README.md +19 -12
- data/app/controllers/concerns/securial/identity.rb +91 -2
- data/app/controllers/securial/accounts_controller.rb +68 -5
- data/app/controllers/securial/application_controller.rb +34 -2
- data/app/controllers/securial/passwords_controller.rb +44 -4
- data/app/controllers/securial/role_assignments_controller.rb +55 -4
- data/app/controllers/securial/roles_controller.rb +54 -0
- data/app/controllers/securial/sessions_controller.rb +77 -3
- data/app/controllers/securial/status_controller.rb +24 -0
- data/app/controllers/securial/users_controller.rb +54 -0
- data/app/jobs/securial/application_job.rb +9 -0
- data/app/mailers/securial/application_mailer.rb +12 -0
- data/app/mailers/securial/securial_mailer.rb +30 -0
- data/app/models/concerns/securial/password_resettable.rb +70 -0
- data/app/models/securial/application_record.rb +19 -0
- data/app/models/securial/current.rb +13 -0
- data/app/models/securial/role.rb +17 -0
- data/app/models/securial/role_assignment.rb +16 -0
- data/app/models/securial/session.rb +79 -1
- data/app/models/securial/user.rb +34 -0
- data/lib/generators/factory_bot/model/model_generator.rb +1 -0
- data/lib/securial/auth/auth_encoder.rb +78 -0
- data/lib/securial/auth/session_creator.rb +49 -0
- data/lib/securial/auth/token_generator.rb +74 -0
- data/lib/securial/auth.rb +44 -6
- data/lib/securial/cli.rb +124 -0
- data/lib/securial/config/signature.rb +116 -5
- data/lib/securial/config/validation.rb +91 -0
- data/lib/securial/config.rb +49 -2
- data/lib/securial/engine.rb +41 -0
- data/lib/securial/error/auth.rb +52 -0
- data/lib/securial/error/base_securial_error.rb +51 -0
- data/lib/securial/error/config.rb +33 -0
- data/lib/securial/error.rb +33 -3
- data/lib/securial/helpers/key_transformer.rb +106 -0
- data/lib/securial/helpers/normalizing_helper.rb +69 -0
- data/lib/securial/helpers/regex_helper.rb +122 -0
- data/lib/securial/helpers/roles_helper.rb +71 -2
- data/lib/securial/helpers.rb +48 -4
- data/lib/securial/logger/broadcaster.rb +89 -1
- data/lib/securial/logger/builder.rb +54 -1
- data/lib/securial/logger/formatter.rb +73 -0
- data/lib/securial/logger.rb +42 -1
- data/lib/securial/middleware/request_tag_logger.rb +80 -0
- data/lib/securial/middleware/response_headers.rb +51 -3
- data/lib/securial/middleware/transform_request_keys.rb +143 -20
- data/lib/securial/middleware/transform_response_keys.rb +84 -4
- data/lib/securial/middleware.rb +40 -9
- data/lib/securial/security/request_rate_limiter.rb +47 -1
- data/lib/securial/security.rb +37 -6
- data/lib/securial/version.rb +8 -1
- data/lib/securial.rb +36 -0
- 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 =
|
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"
|
data/lib/securial/logger.rb
CHANGED
@@ -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
|
-
#
|
4
|
-
#
|
5
|
-
#
|
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
|
-
#
|
4
|
-
#
|
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
|
15
|
-
|
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
|