lennarb 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ module Lennarb
2
+ module Middleware
3
+ # Request logger for the Lennarb framework
4
+ # Logs HTTP request details with color formatting
5
+ #
6
+ # @example Output:
7
+ # GET /users/123 (30ms)
8
+ # Status: 200 OK
9
+ # Params: {"id"=>"123", "password"=>"[FILTERED]"}
10
+ #
11
+ class RequestLogger
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ # Get logger from application configuration
17
+ def logger = Lennarb::App.app.config.logger
18
+
19
+ # Process the request and log information
20
+ #
21
+ # @param [Hash] env Rack environment
22
+ # @return [Array] Rack response [status, headers, body]
23
+ def call(env)
24
+ request = Lennarb::Request.new(env)
25
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+
27
+ status, headers, body = @app.call(env)
28
+
29
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
30
+
31
+ log_request(request, status, headers, duration)
32
+
33
+ [status, headers, body]
34
+ end
35
+
36
+ private
37
+
38
+ # Format duration in a human-readable way
39
+ #
40
+ # @param [Float] seconds Duration in seconds
41
+ # @return [String] Formatted duration
42
+ def format_duration(seconds)
43
+ if seconds < 1
44
+ "#{(seconds * 1000).round}ms"
45
+ elsif seconds < 60
46
+ format("%.2fs", seconds)
47
+ else
48
+ minutes = (seconds / 60).to_i
49
+ seconds = (seconds % 60).round
50
+ "#{minutes}m #{seconds}s"
51
+ end
52
+ end
53
+
54
+ # Log the complete request
55
+ def log_request(request, status, headers, duration)
56
+ logger.info { request_line(request, duration, status) }
57
+
58
+ logger.info { status_line(status) }
59
+
60
+ if request.params.any?
61
+ logger.info { params_line(request.params) }
62
+ end
63
+
64
+ if headers["Location"]
65
+ logger.info { redirect_line(headers["Location"]) }
66
+ end
67
+ end
68
+
69
+ # Format the request line
70
+ def request_line(request, duration, status)
71
+ method = request.request_method
72
+ path = filter_path(request.path)
73
+ duration_text = "(#{format_duration(duration)})"
74
+
75
+ "#{method} #{path} #{duration_text}".colorize(status_to_color(status)).bold
76
+ end
77
+
78
+ # Format the status line
79
+ def status_line(status)
80
+ status_text = "#{status} #{Rack::Utils::HTTP_STATUS_CODES[status]}"
81
+ "Status: #{status_text}".colorize(status_to_color(status)).bold
82
+ end
83
+
84
+ # Format the parameters line
85
+ def params_line(params)
86
+ filtered = filter_params(params)
87
+ "Params: #{filtered.inspect}"
88
+ end
89
+
90
+ # Format the redirect line
91
+ def redirect_line(location)
92
+ "Redirect: #{location}".colorize(:yellow)
93
+ end
94
+
95
+ # Filter the request path
96
+ def filter_path(path)
97
+ path
98
+ end
99
+
100
+ # Filter request parameters
101
+ def filter_params(params)
102
+ ParameterFilter.new.filter(params)
103
+ end
104
+
105
+ # Determine color based on HTTP status
106
+ def status_to_color(status)
107
+ case status
108
+ when 200..299 then :green
109
+ when 300..399 then :yellow
110
+ when 400..499 then :magenta
111
+ when 500..599 then :red
112
+ else :white
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,56 @@
1
+ module Lennarb
2
+ # Basic middleware stack implementation.
3
+ #
4
+ class MiddlewareStack
5
+ attr_reader :app
6
+
7
+ # The app's middleware stack.
8
+ #
9
+ # @param [Rack::Builder] app
10
+ #
11
+ def initialize(app = nil)
12
+ @app = app
13
+ @store = []
14
+ end
15
+
16
+ # Insert a middleware in the stack.
17
+ #
18
+ # @param [Class] middleware
19
+ # @param [Array] args
20
+ # @param [Proc] block
21
+ #
22
+ # @retrn [void]
23
+ #
24
+ def use(middleware, *args, &block)
25
+ @store << [middleware, args, block]
26
+ end
27
+
28
+ # Add a middleware to the beginning of the stack.
29
+ #
30
+ # @param [Class] middleware
31
+ # @param [Array] args
32
+ # @param [Proc] block
33
+ #
34
+ # @retrn [void]
35
+ #
36
+ def unshift(middleware, *args, &block)
37
+ @store.unshift([middleware, args, block])
38
+ end
39
+
40
+ # Clear the middleware stack.
41
+ #
42
+ # @retrn [void]
43
+ #
44
+ def clear
45
+ @store.clear
46
+ end
47
+
48
+ # Convert the middleware stack to an array.
49
+ #
50
+ # @retrn [Array]
51
+ #
52
+ def to_a
53
+ @store
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,76 @@
1
+ module Lennarb
2
+ # Filtra parâmetros sensíveis de logs e exceções.
3
+ # Útil para evitar o vazamento de informações confidenciais.
4
+ #
5
+ # Por padrão, as seguintes chaves de parâmetros são filtradas:
6
+ #
7
+ # - `passw`
8
+ # - `email`
9
+ # - `secret`
10
+ # - `token`
11
+ # - `_key`
12
+ # - `crypt`
13
+ # - `salt`
14
+ # - `certificate`
15
+ # - `otp`
16
+ # - `ssn`
17
+ # - `cvv`
18
+ # - `cvc`
19
+ # - `signature`
20
+ #
21
+ # @example
22
+ # filter = Lennarb::ParameterFilter.new
23
+ # filter.filter({ password: "secret", user: { email: "test@example.com" } })
24
+ # # => { password: "[filtered]", user: { email: "[filtered]" } }
25
+ #
26
+ class ParameterFilter
27
+ # @api private
28
+ DEFAULT_MASK = "[FILTERED]"
29
+
30
+ # @api private
31
+ DEFAULT_FILTERS = %w[
32
+ passw email secret token _key crypt salt certificate otp ssn cvv cvc
33
+ signature
34
+ ].freeze
35
+
36
+ # Inicializa um novo filtro de parâmetros
37
+ #
38
+ # @param [Array<String, Regexp>] filters Lista de padrões para filtrar
39
+ def initialize(filters = DEFAULT_FILTERS)
40
+ @filter = Regexp.union(filters.map(&:to_s))
41
+ end
42
+
43
+ # Filtra os parâmetros conforme o filtro configurado
44
+ #
45
+ # @param [Hash, Array] params Parâmetros a serem filtrados
46
+ # @param [String] mask Valor que substituirá os parâmetros filtrados
47
+ # @return [Hash, Array] Parâmetros filtrados
48
+ def filter(params, mask: DEFAULT_MASK)
49
+ filter_object(params.dup, mask)
50
+ end
51
+
52
+ private
53
+
54
+ # Filtra recursivamente um objeto (hash ou array)
55
+ #
56
+ # @param [Object] object Objeto a ser filtrado
57
+ # @param [String] mask Valor que substituirá os parâmetros filtrados
58
+ # @return [Object] Objeto filtrado
59
+ def filter_object(object, mask)
60
+ case object
61
+ when Hash
62
+ object.each do |key, value|
63
+ object[key] = if key.to_s.match?(@filter)
64
+ mask
65
+ else
66
+ filter_object(value, mask)
67
+ end
68
+ end
69
+ when Array
70
+ object = object.map { filter_object(it, mask) }
71
+ end
72
+
73
+ object
74
+ end
75
+ end
76
+ end
@@ -1,86 +1,264 @@
1
- class Lennarb
1
+ module Lennarb
2
+ # Request object
3
+ #
2
4
  class Request < Rack::Request
3
5
  # The environment variables of the request
4
6
  #
5
- # @returns [Hash]
6
- #
7
+ # @return [Hash]
7
8
  attr_reader :env
8
9
 
9
10
  # Initialize the request object
10
11
  #
11
- # @parameter [Hash] env
12
- # @parameter [Hash] route_params
12
+ # @param [Hash] env
13
+ # @param [Hash] route_params
13
14
  #
14
- # @returns [Request]
15
+ # @return [Request]
15
16
  #
16
17
  def initialize(env, route_params = {})
17
18
  super(env)
18
- @route_params = route_params
19
+ @route_params = route_params || {}
19
20
  end
20
21
 
21
- # Get the request body
22
+ # Get the request parameters merged with route parameters
22
23
  #
23
- # @returns [String]
24
+ # @return [Hash]
24
25
  #
25
- def params = @params ||= super.merge(@route_params)&.transform_keys(&:to_sym)
26
+ def params
27
+ @params ||= super.merge(@route_params)&.transform_keys(&:to_sym)
28
+ end
26
29
 
27
- # Get the request path
30
+ # Get the request path without query string
28
31
  #
29
- # @returns [String]
32
+ # @return [String]
30
33
  #
31
- def path = @path ||= super.split("?").first
34
+ def path
35
+ @path ||= super.split("?").first
36
+ end
32
37
 
33
38
  # Read the body of the request
34
39
  #
35
- # @returns [String]
40
+ # @return [String]
36
41
  #
37
- def body = @body ||= super.read
42
+ def body
43
+ @body ||= super.read
44
+ end
38
45
 
39
46
  # Get the query parameters
40
47
  #
41
- # @returns [Hash]
48
+ # @return [Hash]
42
49
  #
43
50
  def query_params
44
- @query_params ||= Rack::Utils.parse_nested_query(query_string).transform_keys(&:to_sym)
51
+ @query_params ||= Rack::Utils.parse_nested_query(query_string || "").transform_keys(&:to_sym)
52
+ end
53
+
54
+ # Set a value in the environment
55
+ #
56
+ # @param [String] key
57
+ # @param [Object] value
58
+ # @return [Object] the value
59
+ #
60
+ def []=(key, value)
61
+ env[key] = value
62
+ end
63
+
64
+ # Get a value from the environment
65
+ #
66
+ # @param [String] key
67
+ # @return [Object]
68
+ #
69
+ def [](key)
70
+ env[key]
45
71
  end
46
72
 
47
73
  # Get the headers of the request
48
74
  #
75
+ # @return [Hash]
76
+ #
49
77
  def headers
50
- @headers ||= env.select { |key, _| key.start_with?("HTTP_") }
78
+ @headers ||= env.each_with_object({}) do |(key, value), result|
79
+ if key.start_with?("HTTP_")
80
+ header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
81
+ result[header_name] = value
82
+ end
83
+ end
51
84
  end
52
85
 
53
- def ip = ip_address
86
+ # Get the client IP address
87
+ #
88
+ # @return [String]
89
+ #
90
+ def ip
91
+ ip_address
92
+ end
54
93
 
55
- def secure? = scheme == "https"
94
+ # Check if the request is secure (HTTPS)
95
+ #
96
+ # @return [Boolean]
97
+ #
98
+ def secure?
99
+ scheme == "https"
100
+ end
56
101
 
57
- def user_agent = headers["HTTP_USER_AGENT"]
102
+ # Shorthand methods for common headers
58
103
 
59
- def accept = headers["HTTP_ACCEPT"]
104
+ # Get the user agent
105
+ #
106
+ # @return [String, nil]
107
+ #
108
+ def user_agent
109
+ env["HTTP_USER_AGENT"]
110
+ end
60
111
 
61
- def referer = headers["HTTP_REFERER"]
112
+ # Get the accept header
113
+ #
114
+ # @return [String, nil]
115
+ #
116
+ def accept
117
+ env["HTTP_ACCEPT"]
118
+ end
62
119
 
63
- def host = headers["HTTP_HOST"]
120
+ # Get the referer header
121
+ #
122
+ # @return [String, nil]
123
+ #
124
+ def referer
125
+ env["HTTP_REFERER"]
126
+ end
64
127
 
65
- def content_length = headers["HTTP_CONTENT_LENGTH"]
128
+ # Get the host header
129
+ #
130
+ # @return [String, nil]
131
+ #
132
+ def host
133
+ env["HTTP_HOST"]
134
+ end
66
135
 
67
- def content_type = headers["HTTP_CONTENT_TYPE"]
136
+ # Get the content length header
137
+ #
138
+ # @return [String, nil]
139
+ #
140
+ def content_length
141
+ env["HTTP_CONTENT_LENGTH"]
142
+ end
68
143
 
69
- def xhr? = headers["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest")&.zero?
144
+ # Get the content type header
145
+ #
146
+ # @return [String, nil]
147
+ #
148
+ def content_type
149
+ env["HTTP_CONTENT_TYPE"]
150
+ end
70
151
 
71
- def []=(key, value)
72
- env[key] = value
152
+ # Check if the request is an XHR request
153
+ #
154
+ # @return [Boolean]
155
+ #
156
+ def xhr?
157
+ env["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest")&.zero? || false
73
158
  end
74
159
 
75
- def [](key)
76
- env[key]
160
+ # Check if the request is a JSON request
161
+ #
162
+ # @return [Boolean]
163
+ #
164
+ def json?
165
+ content_type&.include?("application/json")
166
+ end
167
+
168
+ # Parse JSON body if content type is application/json
169
+ #
170
+ # @return [Hash, nil]
171
+ #
172
+ def json_body
173
+ return nil unless json?
174
+ @json_body ||= begin
175
+ JSON.parse(body, symbolize_names: true)
176
+ rescue JSON::ParserError
177
+ nil
178
+ end
179
+ end
180
+
181
+ # Check if the request is an AJAX request (alias for xhr?)
182
+ #
183
+ # @return [Boolean]
184
+ #
185
+ def ajax?
186
+ xhr?
187
+ end
188
+
189
+ # Get the requested format (.html, .json, etc)
190
+ #
191
+ # @return [Symbol, nil]
192
+ #
193
+ def format
194
+ @format ||= begin
195
+ path_info = env["PATH_INFO"]
196
+ return nil unless path_info.include?(".")
197
+
198
+ extension = File.extname(path_info).delete(".")
199
+ extension.empty? ? nil : extension.to_sym
200
+ end
201
+ end
202
+
203
+ # Check if the request is a GET request
204
+ #
205
+ # @return [Boolean]
206
+ #
207
+ def get?
208
+ request_method == "GET"
209
+ end
210
+
211
+ # Check if the request is a POST request
212
+ #
213
+ # @return [Boolean]
214
+ #
215
+ def post?
216
+ request_method == "POST"
217
+ end
218
+
219
+ # Check if the request is a PUT request
220
+ #
221
+ # @return [Boolean]
222
+ #
223
+ def put?
224
+ request_method == "PUT"
225
+ end
226
+
227
+ # Check if the request is a DELETE request
228
+ #
229
+ # @return [Boolean]
230
+ #
231
+ def delete?
232
+ request_method == "DELETE"
233
+ end
234
+
235
+ # Check if the request is a HEAD request
236
+ #
237
+ # @return [Boolean]
238
+ #
239
+ def head?
240
+ request_method == "HEAD"
241
+ end
242
+
243
+ # Check if the request is a PATCH request
244
+ #
245
+ # @return [Boolean]
246
+ #
247
+ def patch?
248
+ request_method == "PATCH"
77
249
  end
78
250
 
79
251
  private
80
252
 
253
+ # Get the client IP address
254
+ #
255
+ # @return [String]
256
+ #
81
257
  def ip_address
82
- forwarded_for = headers["HTTP_X_FORWARDED_FOR"]
83
- forwarded_for ? forwarded_for.split(",").first.strip : env["REMOTE_ADDR"]
258
+ forwarded_for = env["HTTP_X_FORWARDED_FOR"]
259
+ return forwarded_for.split(",").map(&:strip).first if forwarded_for
260
+
261
+ env["REMOTE_ADDR"]
84
262
  end
85
263
  end
86
264
  end
@@ -0,0 +1,61 @@
1
+ module Lennarb
2
+ # Handles requests and executes routes with helpers and hooks.
3
+ #
4
+ class RequestHandler
5
+ attr_reader :app
6
+
7
+ # Initialize the request handler
8
+ #
9
+ # @param [Lennarb::App] app The application instance
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ # Handle a request according to Rack interface
15
+ #
16
+ # @param [Hash] env The Rack environment
17
+ # @return [Array] Rack response [status, headers, body]
18
+ def call(env)
19
+ http_method = env[Rack::REQUEST_METHOD].to_sym
20
+ parts = env[Rack::PATH_INFO].split("/").reject(&:empty?)
21
+ block, params = app.routes.match_route(parts, http_method)
22
+
23
+ return [404, {"content-type" => CONTENT_TYPE[:TEXT]}, ["Not Found"]] unless block
24
+
25
+ req = Request.new(env, params || {})
26
+ res = Response.new
27
+
28
+ catch(:halt) do
29
+ context = create_context
30
+
31
+ Hooks.execute(context, app.class, :before, req, res)
32
+
33
+ context.instance_exec(req, res, params, &block)
34
+
35
+ Hooks.execute(context, app.class, :after, req, res)
36
+
37
+ res.finish
38
+ rescue Lennarb::Error => e
39
+ app.class.config.logger.error("Error: #{e.message}")
40
+ app.class.config.logger.error(e.backtrace.first)
41
+ [500, {"content-type" => "text/plain"}, ["Internal Server Error"]]
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Create a context object with app's helper methods
48
+ #
49
+ # @return [Object] A context object with helper methods
50
+ def create_context
51
+ context = Object.new
52
+
53
+ context.define_singleton_method(:app) { app }
54
+
55
+ helpers_module = Helpers.for(app.class)
56
+ context.extend(helpers_module) if helpers_module
57
+
58
+ context
59
+ end
60
+ end
61
+ end