otto 1.6.0 → 2.0.0.pre2

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -2
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +26 -344
  7. data/CHANGELOG.rst +131 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +11 -4
  10. data/Gemfile.lock +38 -42
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/README.md +120 -0
  14. data/changelog.d/scriv.ini +5 -0
  15. data/docs/.gitignore +2 -0
  16. data/docs/migrating/v2.0.0-pre1.md +276 -0
  17. data/docs/migrating/v2.0.0-pre2.md +345 -0
  18. data/examples/.gitignore +1 -0
  19. data/examples/advanced_routes/README.md +33 -0
  20. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  21. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  22. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  27. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  29. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  30. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  31. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  32. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  33. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  34. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  35. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  36. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  37. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  38. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  39. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  40. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  41. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  42. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  43. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  45. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  46. data/examples/advanced_routes/app.rb +33 -0
  47. data/examples/advanced_routes/config.rb +23 -0
  48. data/examples/advanced_routes/config.ru +7 -0
  49. data/examples/advanced_routes/puma.rb +20 -0
  50. data/examples/advanced_routes/routes +167 -0
  51. data/examples/advanced_routes/run.rb +39 -0
  52. data/examples/advanced_routes/test.rb +58 -0
  53. data/examples/authentication_strategies/README.md +32 -0
  54. data/examples/authentication_strategies/app/auth.rb +68 -0
  55. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  56. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  57. data/examples/authentication_strategies/config.ru +24 -0
  58. data/examples/authentication_strategies/routes +37 -0
  59. data/examples/basic/README.md +29 -0
  60. data/examples/basic/app.rb +7 -35
  61. data/examples/basic/routes +0 -9
  62. data/examples/mcp_demo/README.md +87 -0
  63. data/examples/mcp_demo/app.rb +29 -34
  64. data/examples/mcp_demo/config.ru +9 -60
  65. data/examples/security_features/README.md +46 -0
  66. data/examples/security_features/app.rb +23 -24
  67. data/examples/security_features/config.ru +8 -10
  68. data/lib/otto/core/configuration.rb +167 -0
  69. data/lib/otto/core/error_handler.rb +86 -0
  70. data/lib/otto/core/file_safety.rb +61 -0
  71. data/lib/otto/core/middleware_stack.rb +237 -0
  72. data/lib/otto/core/router.rb +184 -0
  73. data/lib/otto/core/uri_generator.rb +44 -0
  74. data/lib/otto/design_system.rb +7 -5
  75. data/lib/otto/env_keys.rb +114 -0
  76. data/lib/otto/helpers/base.rb +5 -21
  77. data/lib/otto/helpers/request.rb +10 -8
  78. data/lib/otto/helpers/response.rb +27 -4
  79. data/lib/otto/helpers/validation.rb +9 -7
  80. data/lib/otto/mcp/auth/token.rb +10 -9
  81. data/lib/otto/mcp/protocol.rb +24 -27
  82. data/lib/otto/mcp/rate_limiting.rb +8 -3
  83. data/lib/otto/mcp/registry.rb +7 -2
  84. data/lib/otto/mcp/route_parser.rb +10 -15
  85. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
  86. data/lib/otto/mcp/server.rb +45 -22
  87. data/lib/otto/response_handlers/auto.rb +39 -0
  88. data/lib/otto/response_handlers/base.rb +16 -0
  89. data/lib/otto/response_handlers/default.rb +16 -0
  90. data/lib/otto/response_handlers/factory.rb +39 -0
  91. data/lib/otto/response_handlers/json.rb +34 -0
  92. data/lib/otto/response_handlers/redirect.rb +25 -0
  93. data/lib/otto/response_handlers/view.rb +24 -0
  94. data/lib/otto/response_handlers.rb +9 -135
  95. data/lib/otto/route.rb +51 -55
  96. data/lib/otto/route_definition.rb +15 -18
  97. data/lib/otto/route_handlers/base.rb +121 -0
  98. data/lib/otto/route_handlers/class_method.rb +89 -0
  99. data/lib/otto/route_handlers/factory.rb +42 -0
  100. data/lib/otto/route_handlers/instance_method.rb +69 -0
  101. data/lib/otto/route_handlers/lambda.rb +59 -0
  102. data/lib/otto/route_handlers/logic_class.rb +93 -0
  103. data/lib/otto/route_handlers.rb +10 -405
  104. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  105. data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
  106. data/lib/otto/security/authentication/failure_result.rb +44 -0
  107. data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +337 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -23
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +54 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +141 -498
  128. data/otto.gemspec +4 -2
  129. metadata +99 -18
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/error_handler.rb
4
+
5
+ require 'securerandom'
6
+ require 'json'
7
+ require 'rack/request'
8
+
9
+ class Otto
10
+ module Core
11
+ # Error handling module providing secure error reporting and logging functionality
12
+ module ErrorHandler
13
+ def handle_error(error, env)
14
+ # Log error details internally but don't expose them
15
+ error_id = SecureRandom.hex(8)
16
+ Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
17
+ Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
18
+
19
+ # Parse request for content negotiation
20
+ begin
21
+ Rack::Request.new(env)
22
+ rescue StandardError
23
+ nil
24
+ end
25
+ literal_routes = @routes_literal[:GET] || {}
26
+
27
+ # Try custom 500 route first
28
+ if found_route = literal_routes['/500']
29
+ begin
30
+ env['otto.error_id'] = error_id
31
+ return found_route.call(env)
32
+ rescue StandardError => e
33
+ Otto.logger.error "[#{error_id}] Error in custom error handler: #{e.message}"
34
+ end
35
+ end
36
+
37
+ # Content negotiation for built-in error response
38
+ accept_header = env['HTTP_ACCEPT'].to_s
39
+ return json_error_response(error_id) if accept_header.include?('application/json')
40
+
41
+ # Fallback to built-in error response
42
+ @server_error || secure_error_response(error_id)
43
+ end
44
+
45
+ private
46
+
47
+ def secure_error_response(error_id)
48
+ body = if Otto.env?(:dev, :development)
49
+ "Server error (ID: #{error_id}). Check logs for details."
50
+ else
51
+ 'An error occurred. Please try again later.'
52
+ end
53
+
54
+ headers = {
55
+ 'content-type' => 'text/plain',
56
+ 'content-length' => body.bytesize.to_s,
57
+ }.merge(@security_config.security_headers)
58
+
59
+ [500, headers, [body]]
60
+ end
61
+
62
+ def json_error_response(error_id)
63
+ error_data = if Otto.env?(:dev, :development)
64
+ {
65
+ error: 'Internal Server Error',
66
+ message: 'Server error occurred. Check logs for details.',
67
+ error_id: error_id,
68
+ }
69
+ else
70
+ {
71
+ error: 'Internal Server Error',
72
+ message: 'An error occurred. Please try again later.',
73
+ }
74
+ end
75
+
76
+ body = JSON.generate(error_data)
77
+ headers = {
78
+ 'content-type' => 'application/json',
79
+ 'content-length' => body.bytesize.to_s,
80
+ }.merge(@security_config.security_headers)
81
+
82
+ [500, headers, [body]]
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/file_safety.rb
4
+
5
+ class Otto
6
+ module Core
7
+ # File safety module providing secure file access validation and path traversal protection
8
+ module FileSafety
9
+ def safe_file?(path)
10
+ return false if option[:public].nil? || option[:public].empty?
11
+ return false if path.nil? || path.empty?
12
+
13
+ # Normalize and resolve the public directory path
14
+ public_dir = File.expand_path(option[:public])
15
+ return false unless File.directory?(public_dir)
16
+
17
+ # Clean the requested path - remove null bytes and normalize
18
+ clean_path = path.delete("\0").strip
19
+ return false if clean_path.empty?
20
+
21
+ # Join and expand to get the full resolved path
22
+ requested_path = File.expand_path(File.join(public_dir, clean_path))
23
+
24
+ # Ensure the resolved path is within the public directory (prevents path traversal)
25
+ return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
26
+
27
+ # Check file exists, is readable, and is not a directory
28
+ File.exist?(requested_path) &&
29
+ File.readable?(requested_path) &&
30
+ !File.directory?(requested_path) &&
31
+ (File.owned?(requested_path) || File.grpowned?(requested_path))
32
+ end
33
+
34
+ def safe_dir?(path)
35
+ return false if path.nil? || path.empty?
36
+
37
+ # Clean and expand the path
38
+ clean_path = path.delete("\0").strip
39
+ return false if clean_path.empty?
40
+
41
+ expanded_path = File.expand_path(clean_path)
42
+
43
+ # Check directory exists, is readable, and has proper ownership
44
+ File.directory?(expanded_path) &&
45
+ File.readable?(expanded_path) &&
46
+ (File.owned?(expanded_path) || File.grpowned?(expanded_path))
47
+ end
48
+
49
+ def add_static_path(path)
50
+ return unless safe_file?(path)
51
+
52
+ base_path = File.split(path).first
53
+ # Files in the root directory can refer to themselves
54
+ base_path = path if base_path == '/'
55
+ File.join(option[:public], base_path)
56
+ Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
57
+ routes_static[:GET][base_path] = base_path
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/middleware_stack.rb
4
+
5
+ class Otto
6
+ module Core
7
+ # Enhanced middleware stack management for Otto framework.
8
+ # Provides better middleware registration, introspection capabilities,
9
+ # and improved execution chain management.
10
+ class MiddlewareStack
11
+ include Enumerable
12
+
13
+ def initialize
14
+ @stack = []
15
+ @middleware_set = Set.new
16
+ end
17
+
18
+ # Enhanced middleware registration with argument uniqueness and immutability check
19
+ def add(middleware_class, *args, **options)
20
+ # Prevent modifications to frozen configurations
21
+ raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
22
+
23
+ # Check if an identical middleware configuration already exists
24
+ existing_entry = @stack.find do |entry|
25
+ entry[:middleware] == middleware_class &&
26
+ entry[:args] == args &&
27
+ entry[:options] == options
28
+ end
29
+
30
+ # Only add if no identical middleware configuration exists
31
+ return if existing_entry
32
+
33
+ entry = { middleware: middleware_class, args: args, options: options }
34
+ @stack << entry
35
+ @middleware_set.add(middleware_class)
36
+ # Invalidate memoized middleware list
37
+ @memoized_middleware_list = nil
38
+ end
39
+
40
+ # Add middleware with position hint for optimal ordering
41
+ #
42
+ # @param middleware_class [Class] Middleware class
43
+ # @param args [Array] Middleware arguments
44
+ # @param position [Symbol, nil] Position hint (:first, :last, or nil for append)
45
+ # @option options [Symbol] :position Position hint (:first or :last)
46
+ def add_with_position(middleware_class, *args, position: nil, **options)
47
+ raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
48
+
49
+ # Check for identical configuration
50
+ existing_entry = @stack.find do |entry|
51
+ entry[:middleware] == middleware_class &&
52
+ entry[:args] == args &&
53
+ entry[:options] == options
54
+ end
55
+
56
+ return if existing_entry
57
+
58
+ entry = { middleware: middleware_class, args: args, options: options }
59
+
60
+ case position
61
+ when :first
62
+ @stack.unshift(entry)
63
+ when :last
64
+ @stack << entry
65
+ else
66
+ @stack << entry # Default append
67
+ end
68
+
69
+ @middleware_set.add(middleware_class)
70
+ @memoized_middleware_list = nil
71
+ end
72
+
73
+ # Validate MCP middleware ordering
74
+ #
75
+ # MCP middleware must be in security-optimal order:
76
+ # 1. RateLimitMiddleware (reject excessive requests early)
77
+ # 2. Auth middleware (validate credentials before parsing)
78
+ # 3. SchemaValidationMiddleware (expensive JSON schema validation last)
79
+ #
80
+ # @return [Array<String>] Warning messages if order is suboptimal
81
+ def validate_mcp_middleware_order
82
+ warnings = []
83
+
84
+ # PERFORMANCE NOTE: This implementation intentionally uses select + find_index
85
+ # rather than a single-pass approach. The filtered mcp_middlewares array is
86
+ # typically 0-3 items, making the performance difference unmeasurable.
87
+ # The current approach prioritizes readability over micro-optimization.
88
+ # Single-pass alternatives were considered but rejected as premature optimization.
89
+ mcp_middlewares = @stack.select do |entry|
90
+ [
91
+ Otto::MCP::RateLimitMiddleware,
92
+ Otto::MCP::Auth::TokenMiddleware,
93
+ Otto::MCP::SchemaValidationMiddleware,
94
+ ].include?(entry[:middleware])
95
+ end
96
+
97
+ return warnings if mcp_middlewares.size < 2
98
+
99
+ # Find positions
100
+ rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
101
+ auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
102
+ validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }
103
+
104
+ # Check optimal order: rate_limit < auth < validation
105
+ if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
106
+ warnings << '[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware for optimal performance'
107
+ end
108
+
109
+ if auth_pos && validation_pos && auth_pos > validation_pos
110
+ warnings << '[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware for optimal performance'
111
+ end
112
+
113
+ if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
114
+ warnings << '[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware for optimal performance'
115
+ end
116
+
117
+ warnings
118
+ end
119
+ alias use add
120
+ alias << add
121
+
122
+ # Remove middleware
123
+ def remove(middleware_class)
124
+ # Prevent modifications to frozen configurations
125
+ raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
126
+
127
+ matches = @stack.reject! { |entry| entry[:middleware] == middleware_class }
128
+
129
+ # Update middleware set if any matching entries were found
130
+ return unless matches
131
+
132
+ # Rebuild the set of unique middleware classes
133
+ @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
134
+ # Invalidate memoized middleware list
135
+ @memoized_middleware_list = nil
136
+ end
137
+
138
+ # Check if middleware is registered - now O(1) using Set
139
+ def includes?(middleware_class)
140
+ @middleware_set.include?(middleware_class)
141
+ end
142
+
143
+ # Clear all middleware
144
+ def clear!
145
+ # Prevent modifications to frozen configurations
146
+ raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
147
+
148
+ @stack.clear
149
+ @middleware_set.clear
150
+ # Invalidate memoized middleware list
151
+ @memoized_middleware_list = nil
152
+ end
153
+
154
+ # Enumerable support
155
+ def each(&)
156
+ @stack.each(&)
157
+ end
158
+
159
+ # Build Rack application with middleware chain
160
+ def build_app(base_app, security_config = nil)
161
+ @stack.reduce(base_app) do |app, entry|
162
+ middleware = entry[:middleware]
163
+ args = entry[:args]
164
+ options = entry[:options]
165
+
166
+ if middleware.respond_to?(:new)
167
+ # Inject security_config for security middleware, placing it before custom args
168
+ if security_config && middleware_needs_config?(middleware)
169
+ middleware.new(app, security_config, *args, **options)
170
+ else
171
+ middleware.new(app, *args, **options)
172
+ end
173
+ else
174
+ # Proc-based middleware
175
+ middleware.call(app)
176
+ end
177
+ end
178
+ end
179
+
180
+ # Cached middleware list to reduce array creation
181
+ def middleware_list
182
+ # Memoize the result to avoid repeated array creation
183
+ @memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
184
+ end
185
+
186
+ # Detailed introspection
187
+ def middleware_details
188
+ @stack.map do |entry|
189
+ {
190
+ middleware: entry[:middleware],
191
+ args: entry[:args],
192
+ options: entry[:options],
193
+ }
194
+ end
195
+ end
196
+
197
+ # Statistics
198
+ def size
199
+ @stack.size
200
+ end
201
+
202
+ def empty?
203
+ @stack.empty?
204
+ end
205
+
206
+ # Count occurrences of a specific middleware class
207
+ def count(middleware_class)
208
+ @stack.count { |entry| entry[:middleware] == middleware_class }
209
+ end
210
+
211
+ # NOTE: The includes? method is defined earlier for O(1) lookup using a Set
212
+
213
+ # Legacy compatibility methods for existing Otto interface
214
+ def reverse_each(&)
215
+ @stack.reverse_each(&)
216
+ end
217
+
218
+ private
219
+
220
+ def middleware_needs_config?(middleware_class)
221
+ # Include all Otto security middleware that can accept security_config
222
+ # Support both new namespaced classes and backward compatibility aliases
223
+ [
224
+ Otto::Security::Middleware::CSRFMiddleware,
225
+ Otto::Security::Middleware::ValidationMiddleware,
226
+ Otto::Security::Middleware::RateLimitMiddleware,
227
+ Otto::Security::Authentication::AuthenticationMiddleware,
228
+ # Backward compatibility aliases
229
+ Otto::Security::CSRFMiddleware,
230
+ Otto::Security::ValidationMiddleware,
231
+ Otto::Security::RateLimitMiddleware,
232
+ Otto::Security::AuthenticationMiddleware,
233
+ ].include?(middleware_class)
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/router.rb
4
+
5
+ require_relative '../mcp/route_parser'
6
+
7
+ class Otto
8
+ module Core
9
+ # Router module providing route loading and request dispatching functionality
10
+ module Router
11
+ def load(path)
12
+ path = File.expand_path(path)
13
+ raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
14
+
15
+ raw = File.readlines(path).grep(/^\w/).collect(&:strip)
16
+ raw.each do |entry|
17
+ # Enhanced parsing: split only on first two whitespace boundaries
18
+ # This preserves parameters in the definition part
19
+ parts = entry.split(/\s+/, 3)
20
+ next if parts.size < 3 # Skip malformed entries
21
+
22
+ verb = parts[0]
23
+ path = parts[1]
24
+ definition = parts[2]
25
+
26
+ # Check for MCP routes
27
+ if Otto::MCP::RouteParser.is_mcp_route?(definition)
28
+ handle_mcp_route(verb, path, definition)
29
+ next
30
+ elsif Otto::MCP::RouteParser.is_tool_route?(definition)
31
+ handle_tool_route(verb, path, definition)
32
+ next
33
+ end
34
+
35
+ route = Otto::Route.new verb, path, definition
36
+ route.otto = self
37
+ path_clean = path.gsub(%r{/$}, '')
38
+ @route_definitions[route.definition] = route
39
+ Otto.logger.debug "route: #{route.pattern}" if Otto.debug
40
+ @routes[route.verb] ||= []
41
+ @routes[route.verb] << route
42
+ @routes_literal[route.verb] ||= {}
43
+ @routes_literal[route.verb][path_clean] = route
44
+ rescue StandardError => e
45
+ Otto.logger.error "Error for route #{path}: #{e.message}"
46
+ Otto.logger.debug e.backtrace.join("\n") if Otto.debug
47
+ end
48
+ self
49
+ end
50
+
51
+ def handle_request(env)
52
+ locale = determine_locale env
53
+ env['rack.locale'] = locale
54
+ env['otto.locale_config'] = @locale_config if @locale_config
55
+ @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
56
+ path_info = Rack::Utils.unescape(env['PATH_INFO'])
57
+ path_info = '/' if path_info.to_s.empty?
58
+
59
+ begin
60
+ path_info_clean = path_info
61
+ .encode(
62
+ 'UTF-8', # Target encoding
63
+ invalid: :replace, # Replace invalid byte sequences
64
+ undef: :replace, # Replace characters undefined in UTF-8
65
+ replace: '' # Use empty string for replacement
66
+ )
67
+ .gsub(%r{/$}, '') # Remove trailing slash, if present
68
+ rescue ArgumentError => e
69
+ # Log the error but don't expose details
70
+ Otto.logger.error '[Otto.handle_request] Path encoding error'
71
+ Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
72
+ # Set a default value or use the original path_info
73
+ path_info_clean = path_info
74
+ end
75
+
76
+ base_path = File.split(path_info).first
77
+ # Files in the root directory can refer to themselves
78
+ base_path = path_info if base_path == '/'
79
+ http_verb = env['REQUEST_METHOD'].upcase.to_sym
80
+ literal_routes = routes_literal[http_verb] || {}
81
+ literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
82
+
83
+ if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
84
+ # Otto.logger.debug " request: #{path_info} (static)"
85
+ static_route.call(env)
86
+ elsif literal_routes.has_key?(path_info_clean)
87
+ route = literal_routes[path_info_clean]
88
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
89
+ route.call(env)
90
+ elsif static_route && http_verb == :GET && safe_file?(path_info)
91
+ Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
92
+ routes_static[:GET][base_path] = base_path
93
+ static_route.call(env)
94
+ else
95
+ match_dynamic_route(env, path_info, http_verb, literal_routes)
96
+ end
97
+ end
98
+
99
+ def determine_locale(env)
100
+ accept_langs = env['HTTP_ACCEPT_LANGUAGE']
101
+ accept_langs = option[:locale] if accept_langs.to_s.empty?
102
+ locales = []
103
+ unless accept_langs.empty?
104
+ locales = accept_langs.split(',').map do |l|
105
+ l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
106
+ l.split(';q=')
107
+ end.sort_by do |_locale, qvalue|
108
+ qvalue.to_f
109
+ end.collect do |locale, _qvalue|
110
+ locale
111
+ end.reverse
112
+ end
113
+ Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
114
+ locales.empty? ? nil : locales
115
+ end
116
+
117
+ private
118
+
119
+ def match_dynamic_route(env, path_info, http_verb, literal_routes)
120
+ extra_params = {}
121
+ found_route = nil
122
+ valid_routes = routes[http_verb] || []
123
+ valid_routes.push(*routes[:GET]) if http_verb == :HEAD
124
+
125
+ valid_routes.each do |route|
126
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
127
+ next unless (match = route.pattern.match(path_info))
128
+
129
+ values = match.captures.to_a
130
+ # The first capture returned is the entire matched string b/c
131
+ # we wrapped the entire regex in parens. We don't need it to
132
+ # the full match.
133
+ values.shift
134
+ extra_params = build_route_params(route, values)
135
+ found_route = route
136
+ break
137
+ end
138
+
139
+ found_route ||= literal_routes['/404']
140
+ if found_route
141
+ found_route.call env, extra_params
142
+ else
143
+ @not_found || Otto::Static.not_found
144
+ end
145
+ end
146
+
147
+ def build_route_params(route, values)
148
+ if route.keys.any?
149
+ route.keys.zip(values).each_with_object({}) do |(k, v), hash|
150
+ if k == 'splat'
151
+ (hash[k] ||= []) << v
152
+ else
153
+ hash[k] = v
154
+ end
155
+ end
156
+ elsif values.any?
157
+ { 'captures' => values }
158
+ else
159
+ {}
160
+ end
161
+ end
162
+
163
+ def handle_mcp_route(verb, path, definition)
164
+ raise '[MCP] MCP server not enabled' unless @mcp_server
165
+
166
+ route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
167
+ @mcp_server.register_mcp_route(route_info)
168
+ Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
169
+ rescue StandardError => e
170
+ Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
171
+ end
172
+
173
+ def handle_tool_route(verb, path, definition)
174
+ raise '[MCP] MCP server not enabled' unless @mcp_server
175
+
176
+ route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
177
+ @mcp_server.register_mcp_route(route_info)
178
+ Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
179
+ rescue StandardError => e
180
+ Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/core/uri_generator.rb
4
+
5
+ require 'uri'
6
+
7
+ class Otto
8
+ module Core
9
+ # URI generation module providing path and URL generation for route definitions
10
+ module UriGenerator
11
+ # Return the URI path for the given +route_definition+
12
+ # e.g.
13
+ #
14
+ # Otto.default.path 'YourClass.somemethod' #=> /some/path
15
+ #
16
+ def uri(route_definition, params = {})
17
+ # raise RuntimeError, "Not working"
18
+ route = @route_definitions[route_definition]
19
+ return if route.nil?
20
+
21
+ local_params = params.clone
22
+ local_path = route.path.clone
23
+
24
+ keys_to_remove = []
25
+ local_params.each_pair do |k, v|
26
+ next unless local_path.match(":#{k}")
27
+
28
+ local_path.gsub!(":#{k}", v.to_s)
29
+ keys_to_remove << k
30
+ end
31
+ keys_to_remove.each { |k| local_params.delete(k) }
32
+
33
+ uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
34
+ unless local_params.empty?
35
+ query_string = local_params.map do |k, v|
36
+ "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}"
37
+ end.join('&')
38
+ uri.query = query_string
39
+ end
40
+ uri.to_s
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/design_system.rb
2
4
 
3
5
  class Otto
@@ -112,11 +114,11 @@ class Otto
112
114
  return '' if text.nil?
113
115
 
114
116
  text.to_s
115
- .gsub('&', '&amp;')
116
- .gsub('<', '&lt;')
117
- .gsub('>', '&gt;')
118
- .gsub('"', '&quot;')
119
- .gsub("'", '&#x27;')
117
+ .gsub('&', '&amp;')
118
+ .gsub('<', '&lt;')
119
+ .gsub('>', '&gt;')
120
+ .gsub('"', '&quot;')
121
+ .gsub("'", '&#x27;')
120
122
  end
121
123
 
122
124
  def otto_styles