otto 2.0.0.pre8 → 2.0.0.pre10
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/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/CHANGELOG.rst +73 -35
- data/CLAUDE.md +44 -0
- data/Gemfile.lock +7 -7
- data/README.md +20 -0
- data/docs/.gitignore +2 -0
- data/docs/modern-authentication-authorization-landscape.md +558 -0
- data/docs/multi-strategy-authentication-design.md +1401 -0
- data/lib/otto/core/error_handler.rb +104 -10
- data/lib/otto/core/freezable.rb +0 -2
- data/lib/otto/core/helper_registry.rb +135 -0
- data/lib/otto/core/lifecycle_hooks.rb +63 -0
- data/lib/otto/core/middleware_management.rb +70 -0
- data/lib/otto/core/middleware_stack.rb +12 -8
- data/lib/otto/core/router.rb +25 -31
- data/lib/otto/core.rb +3 -0
- data/lib/otto/errors.rb +92 -0
- data/lib/otto/helpers.rb +2 -2
- data/lib/otto/locale/middleware.rb +1 -1
- data/lib/otto/mcp/core.rb +33 -0
- data/lib/otto/mcp/protocol.rb +1 -1
- data/lib/otto/mcp/rate_limiting.rb +6 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp.rb +1 -0
- data/lib/otto/privacy/core.rb +82 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/{helpers/request.rb → request.rb} +17 -6
- data/lib/otto/{helpers/response.rb → response.rb} +20 -7
- data/lib/otto/response_handlers/json.rb +1 -3
- data/lib/otto/response_handlers/view.rb +1 -1
- data/lib/otto/route.rb +2 -4
- data/lib/otto/route_handlers/base.rb +88 -5
- data/lib/otto/route_handlers/class_method.rb +9 -67
- data/lib/otto/route_handlers/instance_method.rb +10 -57
- data/lib/otto/route_handlers/lambda.rb +2 -2
- data/lib/otto/route_handlers/logic_class.rb +85 -90
- data/lib/otto/security/authentication/auth_strategy.rb +2 -2
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +1 -1
- data/lib/otto/security/authentication/strategy_result.rb +9 -9
- data/lib/otto/security/authorization_error.rb +1 -1
- data/lib/otto/security/config.rb +3 -3
- data/lib/otto/security/core.rb +167 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +1 -1
- data/lib/otto/security/middleware/validation_middleware.rb +1 -1
- data/lib/otto/security/rate_limiter.rb +7 -3
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +29 -404
- metadata +12 -3
|
@@ -26,7 +26,7 @@ class Otto
|
|
|
26
26
|
log_context = base_context.merge(
|
|
27
27
|
error: error.message,
|
|
28
28
|
error_class: error.class.name,
|
|
29
|
-
error_id: error_id
|
|
29
|
+
error_id: error_id,
|
|
30
30
|
)
|
|
31
31
|
log_context[:handler] = env['otto.handler'] if env['otto.handler']
|
|
32
32
|
log_context[:duration] = env['otto.handler_duration'] if env['otto.handler_duration']
|
|
@@ -38,7 +38,7 @@ class Otto
|
|
|
38
38
|
|
|
39
39
|
# Parse request for content negotiation
|
|
40
40
|
begin
|
|
41
|
-
|
|
41
|
+
Otto::Request.new(env)
|
|
42
42
|
rescue StandardError
|
|
43
43
|
nil
|
|
44
44
|
end
|
|
@@ -69,13 +69,95 @@ class Otto
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Content negotiation for built-in error response
|
|
72
|
-
|
|
73
|
-
return json_error_response(error_id) if accept_header.include?('application/json')
|
|
72
|
+
return json_error_response(error_id) if wants_json_response?(env)
|
|
74
73
|
|
|
75
74
|
# Fallback to built-in error response
|
|
76
75
|
@server_error || secure_error_response(error_id)
|
|
77
76
|
end
|
|
78
77
|
|
|
78
|
+
# Register an error handler for expected business logic errors
|
|
79
|
+
#
|
|
80
|
+
# This allows you to handle known error conditions (like missing resources,
|
|
81
|
+
# expired data, rate limits) without logging them as unhandled 500 errors.
|
|
82
|
+
#
|
|
83
|
+
# @param error_class [Class, String] The exception class or class name to handle
|
|
84
|
+
# @param status [Integer] HTTP status code to return (default: 500)
|
|
85
|
+
# @param log_level [Symbol] Log level for expected errors (:info, :warn, :error)
|
|
86
|
+
# @param handler [Proc] Optional block to customize error response
|
|
87
|
+
#
|
|
88
|
+
# @example Basic usage with status code
|
|
89
|
+
# otto.register_error_handler(Onetime::MissingSecret, status: 404, log_level: :info)
|
|
90
|
+
# otto.register_error_handler(Onetime::SecretExpired, status: 410, log_level: :info)
|
|
91
|
+
#
|
|
92
|
+
# @example With custom response handler
|
|
93
|
+
# otto.register_error_handler(Onetime::RateLimited, status: 429, log_level: :warn) do |error, req|
|
|
94
|
+
# {
|
|
95
|
+
# error: 'Rate limit exceeded',
|
|
96
|
+
# retry_after: error.retry_after,
|
|
97
|
+
# message: error.message
|
|
98
|
+
# }
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
# @example Using string class names (for lazy loading)
|
|
102
|
+
# otto.register_error_handler('Onetime::MissingSecret', status: 404, log_level: :info)
|
|
103
|
+
#
|
|
104
|
+
def register_error_handler(error_class, status: 500, log_level: :info, &handler)
|
|
105
|
+
ensure_not_frozen!
|
|
106
|
+
|
|
107
|
+
# Normalize error class to string for consistent lookup
|
|
108
|
+
error_class_name = error_class.is_a?(String) ? error_class : error_class.name
|
|
109
|
+
|
|
110
|
+
@error_handlers[error_class_name] = {
|
|
111
|
+
status: status,
|
|
112
|
+
log_level: log_level,
|
|
113
|
+
handler: handler
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Register all Otto framework error classes with appropriate status codes
|
|
120
|
+
#
|
|
121
|
+
# This method auto-registers base HTTP error classes and all framework-specific
|
|
122
|
+
# error classes (Security, MCP) so that raising them automatically returns the
|
|
123
|
+
# correct HTTP status code instead of 500.
|
|
124
|
+
#
|
|
125
|
+
# Users can override these registrations by calling register_error_handler
|
|
126
|
+
# after Otto.new with custom status codes or log levels.
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
# @api private
|
|
130
|
+
def register_framework_errors
|
|
131
|
+
# Base HTTP errors (for direct use or subclassing by implementing projects)
|
|
132
|
+
register_error_from_class(Otto::NotFoundError)
|
|
133
|
+
register_error_from_class(Otto::BadRequestError)
|
|
134
|
+
register_error_from_class(Otto::UnauthorizedError)
|
|
135
|
+
register_error_from_class(Otto::ForbiddenError)
|
|
136
|
+
register_error_from_class(Otto::PayloadTooLargeError)
|
|
137
|
+
|
|
138
|
+
# Security module errors
|
|
139
|
+
register_error_from_class(Otto::Security::AuthorizationError)
|
|
140
|
+
register_error_from_class(Otto::Security::CSRFError)
|
|
141
|
+
register_error_from_class(Otto::Security::RequestTooLargeError)
|
|
142
|
+
register_error_from_class(Otto::Security::ValidationError)
|
|
143
|
+
|
|
144
|
+
# MCP module errors
|
|
145
|
+
register_error_from_class(Otto::MCP::ValidationError)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Register an error handler using the error class as the single source of truth
|
|
149
|
+
#
|
|
150
|
+
# @param error_class [Class] Error class that responds to default_status and default_log_level
|
|
151
|
+
# @return [void]
|
|
152
|
+
# @api private
|
|
153
|
+
def register_error_from_class(error_class)
|
|
154
|
+
register_error_handler(
|
|
155
|
+
error_class,
|
|
156
|
+
status: error_class.default_status,
|
|
157
|
+
log_level: error_class.default_log_level
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
79
161
|
private
|
|
80
162
|
|
|
81
163
|
# Handle expected business logic errors with custom status codes and logging
|
|
@@ -96,7 +178,7 @@ class Otto
|
|
|
96
178
|
error: error.message,
|
|
97
179
|
error_class: error.class.name,
|
|
98
180
|
error_id: error_id,
|
|
99
|
-
expected: true
|
|
181
|
+
expected: true # Mark as expected error
|
|
100
182
|
)
|
|
101
183
|
log_context[:handler] = env['otto.handler'] if env['otto.handler']
|
|
102
184
|
log_context[:duration] = env['otto.handler_duration'] if env['otto.handler_duration']
|
|
@@ -109,7 +191,7 @@ class Otto
|
|
|
109
191
|
response_body = if handler_config[:handler]
|
|
110
192
|
# Use custom handler block if provided
|
|
111
193
|
begin
|
|
112
|
-
req =
|
|
194
|
+
req = @request_class.new(env)
|
|
113
195
|
result = handler_config[:handler].call(error, req)
|
|
114
196
|
|
|
115
197
|
# Validate that custom handler returned a Hash
|
|
@@ -146,14 +228,13 @@ class Otto
|
|
|
146
228
|
response_body[:error_id] = error_id if Otto.env?(:dev, :development)
|
|
147
229
|
|
|
148
230
|
# Content negotiation
|
|
149
|
-
accept_header = env['HTTP_ACCEPT'].to_s
|
|
150
231
|
status = handler_config[:status] || 500
|
|
151
232
|
|
|
152
|
-
if
|
|
233
|
+
if wants_json_response?(env)
|
|
153
234
|
body = JSON.generate(response_body)
|
|
154
235
|
headers = {
|
|
155
236
|
'content-type' => 'application/json',
|
|
156
|
-
'content-length' => body.bytesize.to_s
|
|
237
|
+
'content-length' => body.bytesize.to_s,
|
|
157
238
|
}.merge(@security_config.security_headers)
|
|
158
239
|
|
|
159
240
|
[status, headers, [body]]
|
|
@@ -167,7 +248,7 @@ class Otto
|
|
|
167
248
|
|
|
168
249
|
headers = {
|
|
169
250
|
'content-type' => 'text/plain',
|
|
170
|
-
'content-length' => body.bytesize.to_s
|
|
251
|
+
'content-length' => body.bytesize.to_s,
|
|
171
252
|
}.merge(@security_config.security_headers)
|
|
172
253
|
|
|
173
254
|
[status, headers, [body]]
|
|
@@ -211,6 +292,19 @@ class Otto
|
|
|
211
292
|
|
|
212
293
|
[500, headers, [body]]
|
|
213
294
|
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
# Determine if the client wants a JSON response
|
|
299
|
+
# Route's response_type declaration takes precedence over Accept header
|
|
300
|
+
#
|
|
301
|
+
# @param env [Hash] Rack environment
|
|
302
|
+
# @return [Boolean] true if JSON response is preferred
|
|
303
|
+
def wants_json_response?(env)
|
|
304
|
+
route_definition = env['otto.route_definition']
|
|
305
|
+
(route_definition&.response_type == 'json') ||
|
|
306
|
+
env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
307
|
+
end
|
|
214
308
|
end
|
|
215
309
|
end
|
|
216
310
|
end
|
data/lib/otto/core/freezable.rb
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# lib/otto/core/helper_registry.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Core
|
|
7
|
+
# Helper registration module for extending Otto's Request and Response classes.
|
|
8
|
+
# Provides the public API for registering custom helper modules.
|
|
9
|
+
module HelperRegistry
|
|
10
|
+
# Register request helper modules
|
|
11
|
+
#
|
|
12
|
+
# Registered modules are included in Otto::Request at the class level,
|
|
13
|
+
# making custom helpers available alongside Otto's built-in helpers.
|
|
14
|
+
# Must be called before first request (before configuration freezing).
|
|
15
|
+
#
|
|
16
|
+
# This is the official integration point for application-specific helpers
|
|
17
|
+
# that work with Otto internals (strategy_result, privacy features, etc.).
|
|
18
|
+
#
|
|
19
|
+
# @param modules [Module, Array<Module>] Module(s) containing helper methods
|
|
20
|
+
# @example
|
|
21
|
+
# module Onetime::RequestHelpers
|
|
22
|
+
# def current_customer
|
|
23
|
+
# user.is_a?(Onetime::Customer) ? user : Onetime::Customer.anonymous
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# def organization
|
|
27
|
+
# @organization ||= strategy_result&.metadata.dig(:organization_context, :organization)
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# otto.register_request_helpers(Onetime::RequestHelpers)
|
|
32
|
+
#
|
|
33
|
+
# @raise [ArgumentError] if module is not a Module
|
|
34
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
35
|
+
def register_request_helpers(*modules)
|
|
36
|
+
begin
|
|
37
|
+
ensure_not_frozen!
|
|
38
|
+
rescue FrozenError
|
|
39
|
+
raise FrozenError, 'Cannot register request helpers after first request'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
modules.each do |mod|
|
|
43
|
+
unless mod.is_a?(Module)
|
|
44
|
+
raise ArgumentError, "Expected Module, got #{mod.class}"
|
|
45
|
+
end
|
|
46
|
+
@request_helper_modules << mod unless @request_helper_modules.include?(mod)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Re-finalize to include newly registered helpers
|
|
50
|
+
finalize_request_response_classes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Register response helper modules
|
|
54
|
+
#
|
|
55
|
+
# Registered modules are included in Otto::Response at the class level,
|
|
56
|
+
# making custom helpers available alongside Otto's built-in helpers.
|
|
57
|
+
# Must be called before first request (before configuration freezing).
|
|
58
|
+
#
|
|
59
|
+
# @param modules [Module, Array<Module>] Module(s) containing helper methods
|
|
60
|
+
# @example
|
|
61
|
+
# module Onetime::ResponseHelpers
|
|
62
|
+
# def json_success(data, status: 200)
|
|
63
|
+
# headers['content-type'] = 'application/json'
|
|
64
|
+
# self.status = status
|
|
65
|
+
# write JSON.generate({ success: true, data: data })
|
|
66
|
+
# end
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# otto.register_response_helpers(Onetime::ResponseHelpers)
|
|
70
|
+
#
|
|
71
|
+
# @raise [ArgumentError] if module is not a Module
|
|
72
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
73
|
+
def register_response_helpers(*modules)
|
|
74
|
+
begin
|
|
75
|
+
ensure_not_frozen!
|
|
76
|
+
rescue FrozenError
|
|
77
|
+
raise FrozenError, 'Cannot register response helpers after first request'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
modules.each do |mod|
|
|
81
|
+
unless mod.is_a?(Module)
|
|
82
|
+
raise ArgumentError, "Expected Module, got #{mod.class}"
|
|
83
|
+
end
|
|
84
|
+
@response_helper_modules << mod unless @response_helper_modules.include?(mod)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Re-finalize to include newly registered helpers
|
|
88
|
+
finalize_request_response_classes
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get registered request helper modules (for debugging)
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Module>] Array of registered request helper modules
|
|
94
|
+
# @api private
|
|
95
|
+
def registered_request_helpers
|
|
96
|
+
@request_helper_modules.dup
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get registered response helper modules (for debugging)
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Module>] Array of registered response helper modules
|
|
102
|
+
# @api private
|
|
103
|
+
def registered_response_helpers
|
|
104
|
+
@response_helper_modules.dup
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Finalize request and response classes with framework and custom helpers
|
|
110
|
+
#
|
|
111
|
+
# This method creates Otto's request and response classes by:
|
|
112
|
+
# 1. Subclassing Otto::Request/Response (which have framework helpers built-in)
|
|
113
|
+
# 2. Including any registered custom helper modules
|
|
114
|
+
#
|
|
115
|
+
# Called during initialization and can be called again if helpers are registered
|
|
116
|
+
# after initialization (before first request).
|
|
117
|
+
#
|
|
118
|
+
# @return [void]
|
|
119
|
+
# @api private
|
|
120
|
+
def finalize_request_response_classes
|
|
121
|
+
# Create request class with framework helpers
|
|
122
|
+
# Otto::Request has all framework helpers as instance methods
|
|
123
|
+
@request_class = Class.new(Otto::Request)
|
|
124
|
+
|
|
125
|
+
# Create response class with framework helpers
|
|
126
|
+
# Otto::Response has all framework helpers as instance methods
|
|
127
|
+
@response_class = Class.new(Otto::Response)
|
|
128
|
+
|
|
129
|
+
# Apply registered custom helpers (framework helpers always come first)
|
|
130
|
+
@request_helper_modules&.each { |mod| @request_class.include(mod) }
|
|
131
|
+
@response_helper_modules&.each { |mod| @response_class.include(mod) }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# lib/otto/core/lifecycle_hooks.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Core
|
|
7
|
+
# Lifecycle hooks module for registering callbacks at various points in request processing.
|
|
8
|
+
# Provides the public API for request completion callbacks.
|
|
9
|
+
module LifecycleHooks
|
|
10
|
+
# Register a callback to be executed after each request completes
|
|
11
|
+
#
|
|
12
|
+
# Instance-level request completion callbacks allow each Otto instance
|
|
13
|
+
# to have its own isolated set of callbacks, preventing duplicate
|
|
14
|
+
# invocations in multi-app architectures (e.g., Rack::URLMap).
|
|
15
|
+
#
|
|
16
|
+
# The callback receives three arguments:
|
|
17
|
+
# - request: Rack::Request object
|
|
18
|
+
# - response: Rack::Response object (wrapping the response tuple)
|
|
19
|
+
# - duration: Request processing duration in microseconds
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage
|
|
22
|
+
# otto = Otto.new(routes_file)
|
|
23
|
+
# otto.on_request_complete do |req, res, duration|
|
|
24
|
+
# logger.info "Request completed", path: req.path, duration: duration
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Multi-app architecture
|
|
28
|
+
# # App 1: Core Web Application
|
|
29
|
+
# core_router = Otto.new
|
|
30
|
+
# core_router.on_request_complete do |req, res, duration|
|
|
31
|
+
# logger.info "Core app request", path: req.path
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# # App 2: API Application
|
|
35
|
+
# api_router = Otto.new
|
|
36
|
+
# api_router.on_request_complete do |req, res, duration|
|
|
37
|
+
# logger.info "API request", path: req.path
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# # Each callback only fires for its respective Otto instance
|
|
41
|
+
#
|
|
42
|
+
# @yield [request, response, duration] Block to execute after each request
|
|
43
|
+
# @yieldparam request [Rack::Request] The request object
|
|
44
|
+
# @yieldparam response [Rack::Response] The response object
|
|
45
|
+
# @yieldparam duration [Integer] Duration in microseconds
|
|
46
|
+
# @return [self] Returns self for method chaining
|
|
47
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
48
|
+
def on_request_complete(&block)
|
|
49
|
+
ensure_not_frozen!
|
|
50
|
+
@request_complete_callbacks << block if block_given?
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get registered request completion callbacks (for internal use)
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
# @return [Array<Proc>] Array of registered callback blocks
|
|
58
|
+
def request_complete_callbacks
|
|
59
|
+
@request_complete_callbacks
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# lib/otto/core/middleware_management.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Core
|
|
7
|
+
# Middleware management module for building and configuring the Rack middleware stack.
|
|
8
|
+
# Provides the public API for adding middleware and building the application.
|
|
9
|
+
module MiddlewareManagement
|
|
10
|
+
# Builds the middleware application chain
|
|
11
|
+
# Called once at initialization and whenever middleware stack changes
|
|
12
|
+
#
|
|
13
|
+
# IMPORTANT: If you have routes with auth requirements, you MUST add session
|
|
14
|
+
# middleware to your middleware stack BEFORE Otto processes requests.
|
|
15
|
+
#
|
|
16
|
+
# Session middleware is required for RouteAuthWrapper to correctly persist
|
|
17
|
+
# session changes during authentication. Common options include:
|
|
18
|
+
# - Rack::Session::Cookie (requires rack-session gem)
|
|
19
|
+
# - Rack::Session::Pool
|
|
20
|
+
# - Rack::Session::Memcache
|
|
21
|
+
# - Any Rack-compatible session middleware
|
|
22
|
+
#
|
|
23
|
+
# Example:
|
|
24
|
+
# use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
|
|
25
|
+
# otto = Otto.new('routes.txt')
|
|
26
|
+
#
|
|
27
|
+
def build_app!
|
|
28
|
+
base_app = method(:handle_request)
|
|
29
|
+
@app = @middleware.wrap(base_app, @security_config)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Add middleware to the stack
|
|
33
|
+
#
|
|
34
|
+
# @param middleware [Class] Middleware class to add
|
|
35
|
+
# @param args Additional arguments passed to middleware constructor
|
|
36
|
+
def use(middleware, ...)
|
|
37
|
+
ensure_not_frozen!
|
|
38
|
+
@middleware.add(middleware, ...)
|
|
39
|
+
|
|
40
|
+
# NOTE: If build_app! is triggered during a request (via use() or
|
|
41
|
+
# middleware_stack=), the @app instance variable could be swapped
|
|
42
|
+
# mid-request in a multi-threaded environment.
|
|
43
|
+
|
|
44
|
+
build_app! if @app # Rebuild app if already initialized
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Compatibility method for existing tests
|
|
48
|
+
# @return [Array] List of middleware classes
|
|
49
|
+
def middleware_stack
|
|
50
|
+
@middleware.middleware_list
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Compatibility method for existing tests
|
|
54
|
+
# @param stack [Array] Array of middleware classes
|
|
55
|
+
def middleware_stack=(stack)
|
|
56
|
+
@middleware.clear!
|
|
57
|
+
Array(stack).each { |middleware| @middleware.add(middleware) }
|
|
58
|
+
build_app! if @app # Rebuild app if already initialized
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if a specific middleware is enabled
|
|
62
|
+
#
|
|
63
|
+
# @param middleware_class [Class] Middleware class to check
|
|
64
|
+
# @return [Boolean] true if middleware is in the stack
|
|
65
|
+
def middleware_enabled?(middleware_class)
|
|
66
|
+
@middleware.includes?(middleware_class)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -73,7 +73,7 @@ class Otto
|
|
|
73
73
|
when :last
|
|
74
74
|
@stack << entry
|
|
75
75
|
else
|
|
76
|
-
@stack << entry
|
|
76
|
+
@stack << entry # Default append
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
@middleware_set.add(middleware_class)
|
|
@@ -114,15 +114,21 @@ class Otto
|
|
|
114
114
|
|
|
115
115
|
# Check optimal order: rate_limit < auth < validation
|
|
116
116
|
if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
|
|
117
|
-
warnings <<
|
|
117
|
+
warnings << <<~MSG.chomp
|
|
118
|
+
[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware
|
|
119
|
+
MSG
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
if auth_pos && validation_pos && auth_pos > validation_pos
|
|
121
|
-
warnings <<
|
|
123
|
+
warnings << <<~MSG.chomp
|
|
124
|
+
[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware
|
|
125
|
+
MSG
|
|
122
126
|
end
|
|
123
127
|
|
|
124
128
|
if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
|
|
125
|
-
warnings <<
|
|
129
|
+
warnings << <<~MSG.chomp
|
|
130
|
+
[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware
|
|
131
|
+
MSG
|
|
126
132
|
end
|
|
127
133
|
|
|
128
134
|
warnings
|
|
@@ -198,8 +204,8 @@ class Otto
|
|
|
198
204
|
@stack.map do |entry|
|
|
199
205
|
{
|
|
200
206
|
middleware: entry[:middleware],
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
args: entry[:args],
|
|
208
|
+
options: entry[:options],
|
|
203
209
|
}
|
|
204
210
|
end
|
|
205
211
|
end
|
|
@@ -225,8 +231,6 @@ class Otto
|
|
|
225
231
|
@stack.reverse_each(&)
|
|
226
232
|
end
|
|
227
233
|
|
|
228
|
-
|
|
229
|
-
|
|
230
234
|
private
|
|
231
235
|
|
|
232
236
|
def middleware_needs_config?(middleware_class)
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -36,28 +36,28 @@ class Otto
|
|
|
36
36
|
route.otto = self
|
|
37
37
|
path_clean = path.gsub(%r{/$}, '')
|
|
38
38
|
@route_definitions[route.definition] = route
|
|
39
|
-
Otto.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
if Otto.debug
|
|
40
|
+
Otto.structured_log(:debug, 'Route loaded',
|
|
41
|
+
{
|
|
42
|
+
pattern: route.pattern.source,
|
|
43
|
+
verb: route.verb,
|
|
44
|
+
definition: route.definition,
|
|
45
|
+
type: 'pattern',
|
|
46
|
+
})
|
|
47
|
+
end
|
|
47
48
|
@routes[route.verb] ||= []
|
|
48
49
|
@routes[route.verb] << route
|
|
49
50
|
@routes_literal[route.verb] ||= {}
|
|
50
51
|
@routes_literal[route.verb][path_clean] = route
|
|
51
52
|
rescue StandardError => e
|
|
52
|
-
Otto.structured_log(:error,
|
|
53
|
+
Otto.structured_log(:error, 'Route load failed',
|
|
53
54
|
{
|
|
54
|
-
|
|
55
|
+
path: path,
|
|
55
56
|
verb: verb,
|
|
56
57
|
definition: definition,
|
|
57
58
|
error: e.message,
|
|
58
|
-
error_class: e.class.name
|
|
59
|
-
}
|
|
60
|
-
)
|
|
59
|
+
error_class: e.class.name,
|
|
60
|
+
})
|
|
61
61
|
Otto.logger.debug e.backtrace.join("\n") if Otto.debug
|
|
62
62
|
end
|
|
63
63
|
self
|
|
@@ -96,30 +96,27 @@ class Otto
|
|
|
96
96
|
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
|
97
97
|
|
|
98
98
|
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
|
99
|
-
Otto.structured_log(:debug,
|
|
99
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
100
100
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
101
101
|
type: 'static_cached',
|
|
102
102
|
base_path: base_path
|
|
103
|
-
)
|
|
104
|
-
)
|
|
103
|
+
))
|
|
105
104
|
static_route.call(env)
|
|
106
105
|
elsif literal_routes.has_key?(path_info_clean)
|
|
107
106
|
route = literal_routes[path_info_clean]
|
|
108
|
-
Otto.structured_log(:debug,
|
|
107
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
109
108
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
110
109
|
type: 'literal',
|
|
111
110
|
handler: route.route_definition.definition,
|
|
112
111
|
auth_strategy: route.route_definition.auth_requirement || 'none'
|
|
113
|
-
)
|
|
114
|
-
)
|
|
112
|
+
))
|
|
115
113
|
route.call(env)
|
|
116
114
|
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
|
117
|
-
Otto.structured_log(:debug,
|
|
115
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
118
116
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
119
117
|
type: 'static_new',
|
|
120
118
|
base_path: base_path
|
|
121
|
-
)
|
|
122
|
-
)
|
|
119
|
+
))
|
|
123
120
|
routes_static[:GET][base_path] = base_path
|
|
124
121
|
static_route.call(env)
|
|
125
122
|
else
|
|
@@ -165,14 +162,13 @@ class Otto
|
|
|
165
162
|
found_route = route
|
|
166
163
|
|
|
167
164
|
# Log successful route match
|
|
168
|
-
Otto.structured_log(:debug,
|
|
165
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
169
166
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
170
167
|
pattern: route.pattern.source,
|
|
171
168
|
handler: route.route_definition.definition,
|
|
172
169
|
auth_strategy: route.route_definition.auth_requirement || 'none',
|
|
173
170
|
route_params: extra_params
|
|
174
|
-
)
|
|
175
|
-
)
|
|
171
|
+
))
|
|
176
172
|
break
|
|
177
173
|
end
|
|
178
174
|
|
|
@@ -180,19 +176,17 @@ class Otto
|
|
|
180
176
|
if found_route
|
|
181
177
|
# Log 404 route usage if we fell back to it
|
|
182
178
|
if found_route == literal_routes['/404']
|
|
183
|
-
Otto.structured_log(:info,
|
|
179
|
+
Otto.structured_log(:info, 'Route not found',
|
|
184
180
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
185
181
|
fallback_to: '404_route'
|
|
186
|
-
)
|
|
187
|
-
)
|
|
182
|
+
))
|
|
188
183
|
end
|
|
189
184
|
found_route.call env, extra_params
|
|
190
185
|
else
|
|
191
|
-
Otto.structured_log(:info,
|
|
186
|
+
Otto.structured_log(:info, 'Route not found',
|
|
192
187
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
193
188
|
fallback_to: 'default_not_found'
|
|
194
|
-
)
|
|
195
|
-
)
|
|
189
|
+
))
|
|
196
190
|
@not_found || Otto::Static.not_found
|
|
197
191
|
end
|
|
198
192
|
end
|
data/lib/otto/core.rb
CHANGED
|
@@ -8,3 +8,6 @@ require_relative 'core/configuration'
|
|
|
8
8
|
require_relative 'core/error_handler'
|
|
9
9
|
require_relative 'core/uri_generator'
|
|
10
10
|
require_relative 'core/middleware_stack'
|
|
11
|
+
require_relative 'core/helper_registry'
|
|
12
|
+
require_relative 'core/middleware_management'
|
|
13
|
+
require_relative 'core/lifecycle_hooks'
|