otto 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 746b45b9ceea5aed255a2d39e578f65bbf4438791bf61aad37641cbd4313c365
4
- data.tar.gz: 8e61944be4c19656684c5630bf74024c3738d4ed6b8891686be4beb735aaaddd
3
+ metadata.gz: a50c097fe32fba3c0a6d84be84f95f68fffe385675f3fc353681b24f11aefa63
4
+ data.tar.gz: 9ad3e5c757531c10b1fbd60d304a07aa5ce84915491acb6c36b61656b8b6f6b3
5
5
  SHA512:
6
- metadata.gz: 0bd4f87e1d824c2dc6683f6909db32e0e0a460b65a8d1646a35f2ac464a450d8804f72cacad60eae066601867f84b1d97c8bbccb02211ed43e5e408d8b269894
7
- data.tar.gz: 999b4b96f74cace0b236dea4ef8f16607713a66960be3301b30b3f3e9e9e7f047b6381820cf701d5385afb10641537c2e1726993ff5c84ae75f6325eefe53cc8
6
+ metadata.gz: c1be28acdcec40db91e12c39926a5197326b0c46b5d269187f8cc3114cfbcae6ae025f1fa69c6c8820faa076c2067c3200838a15818291c729df40271cca3b00
7
+ data.tar.gz: bfa3af6f70fd650464b935b8b30c687b2393b9a687b68837ab21ffcb2ffb710acc1dea85942e043a8307a4b2d6360b4037846e710f2fc43e47a66100dddea807
data/.gitignore CHANGED
@@ -19,3 +19,4 @@ vendor
19
19
  *.gem
20
20
  .ruby-lsp
21
21
  .rspec_status
22
+ .mcp.json
data/Gemfile CHANGED
@@ -16,5 +16,5 @@ group 'development' do
16
16
  gem 'ruby-lsp', require: false
17
17
  gem 'stackprof', require: false
18
18
  gem 'syntax_tree', require: false
19
- gem 'tryouts', require: false
19
+ gem 'tryouts', '~> 3.3.1', require: false
20
20
  end
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (1.4.0)
4
+ otto (1.5.0)
5
5
  ostruct
6
6
  rack (~> 3.1, < 4.0)
7
7
  rack-parser (~> 0.7)
@@ -111,7 +111,7 @@ GEM
111
111
  stringio (3.1.7)
112
112
  syntax_tree (6.3.0)
113
113
  prettier_print (>= 1.2.0)
114
- tryouts (3.2.1)
114
+ tryouts (3.3.1)
115
115
  irb
116
116
  minitest (~> 5.0)
117
117
  pastel (~> 0.8)
@@ -142,7 +142,7 @@ DEPENDENCIES
142
142
  ruby-lsp
143
143
  stackprof
144
144
  syntax_tree
145
- tryouts
145
+ tryouts (~> 3.3.1)
146
146
 
147
147
  BUNDLED WITH
148
148
  2.6.9
data/docs/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
@@ -0,0 +1,141 @@
1
+ # lib/otto/response_handlers.rb
2
+
3
+ class Otto
4
+ module ResponseHandlers
5
+ # Base response handler class
6
+ class BaseHandler
7
+ def self.handle(result, response, context = {})
8
+ raise NotImplementedError, "Subclasses must implement handle method"
9
+ end
10
+
11
+ protected
12
+
13
+ def self.ensure_status_set(response, default_status = 200)
14
+ response.status = default_status unless response.status && response.status != 0
15
+ end
16
+ end
17
+
18
+ # Handler for JSON responses
19
+ class JSONHandler < BaseHandler
20
+ def self.handle(result, response, context = {})
21
+ response['Content-Type'] = 'application/json'
22
+
23
+ # Determine the data to serialize
24
+ data = if context[:logic_instance]&.respond_to?(:response_data)
25
+ context[:logic_instance].response_data
26
+ elsif result.is_a?(Hash)
27
+ result
28
+ elsif result.nil?
29
+ { success: true }
30
+ else
31
+ { success: true, data: result }
32
+ end
33
+
34
+ response.body = [JSON.generate(data)]
35
+ ensure_status_set(response, context[:status_code] || 200)
36
+ end
37
+ end
38
+
39
+ # Handler for redirect responses
40
+ class RedirectHandler < BaseHandler
41
+ def self.handle(result, response, context = {})
42
+ # Determine redirect path
43
+ path = if context[:redirect_path]
44
+ context[:redirect_path]
45
+ elsif context[:logic_instance]&.respond_to?(:redirect_path)
46
+ context[:logic_instance].redirect_path
47
+ elsif result.is_a?(String)
48
+ result
49
+ else
50
+ '/'
51
+ end
52
+
53
+ response.redirect(path)
54
+ end
55
+ end
56
+
57
+ # Handler for view/template responses
58
+ class ViewHandler < BaseHandler
59
+ def self.handle(result, response, context = {})
60
+ if context[:logic_instance]&.respond_to?(:view)
61
+ response.body = [context[:logic_instance].view.render]
62
+ response['Content-Type'] = 'text/html' unless response['Content-Type']
63
+ elsif result.respond_to?(:to_s)
64
+ response.body = [result.to_s]
65
+ response['Content-Type'] = 'text/html' unless response['Content-Type']
66
+ else
67
+ response.body = ['']
68
+ end
69
+
70
+ ensure_status_set(response, context[:status_code] || 200)
71
+ end
72
+ end
73
+
74
+ # Default handler that preserves existing Otto behavior
75
+ class DefaultHandler < BaseHandler
76
+ def self.handle(result, response, context = {})
77
+ # Otto's default behavior - let the route handler manage the response
78
+ # This handler does nothing, preserving existing behavior
79
+ ensure_status_set(response, 200)
80
+ end
81
+ end
82
+
83
+ # Auto-detection handler that chooses appropriate handler based on context
84
+ class AutoHandler < BaseHandler
85
+ def self.handle(result, response, context = {})
86
+ # Auto-detect based on result type and request context
87
+ handler_class = detect_handler_type(result, response, context)
88
+ handler_class.handle(result, response, context)
89
+ end
90
+
91
+ private
92
+
93
+ def self.detect_handler_type(result, response, context)
94
+ # Check if response type was already set by the handler
95
+ content_type = response['Content-Type']
96
+
97
+ if content_type&.include?('application/json')
98
+ JSONHandler
99
+ elsif (context[:logic_instance]&.respond_to?(:redirect_path) && context[:logic_instance]&.redirect_path) ||
100
+ (result.is_a?(String) && result.match?(%r{^/}))
101
+ # Logic instance has redirect path or result is a string path
102
+ RedirectHandler
103
+ elsif result.is_a?(Hash)
104
+ JSONHandler
105
+ elsif context[:logic_instance]&.respond_to?(:view)
106
+ ViewHandler
107
+ else
108
+ DefaultHandler
109
+ end
110
+ end
111
+ end
112
+
113
+ # Factory for creating response handlers
114
+ class HandlerFactory
115
+ # Map of response type names to handler classes
116
+ HANDLER_MAP = {
117
+ 'json' => JSONHandler,
118
+ 'redirect' => RedirectHandler,
119
+ 'view' => ViewHandler,
120
+ 'auto' => AutoHandler,
121
+ 'default' => DefaultHandler
122
+ }.freeze
123
+
124
+ def self.create_handler(response_type)
125
+ handler_class = HANDLER_MAP[response_type.to_s.downcase]
126
+
127
+ unless handler_class
128
+ Otto.logger.warn "Unknown response type: #{response_type}, falling back to default" if Otto.debug
129
+ handler_class = DefaultHandler
130
+ end
131
+
132
+ handler_class
133
+ end
134
+
135
+ def self.handle_response(result, response, response_type, context = {})
136
+ handler = create_handler(response_type)
137
+ handler.handle(result, response, context)
138
+ end
139
+ end
140
+ end
141
+ end
data/lib/otto/route.rb CHANGED
@@ -23,35 +23,70 @@ class Otto
23
23
  module ClassMethods
24
24
  attr_accessor :otto
25
25
  end
26
- attr_reader :verb, :path, :pattern, :method, :klass, :name, :definition, :keys, :kind
26
+ # @return [Otto::RouteDefinition] The immutable route definition
27
+ attr_reader :route_definition
28
+
29
+ # @return [Class] The resolved class object
30
+ attr_reader :klass
31
+
27
32
  attr_accessor :otto
28
33
 
29
34
  # Initialize a new route with security validations
30
35
  #
31
36
  # @param verb [String] HTTP verb (GET, POST, PUT, DELETE, etc.)
32
37
  # @param path [String] URL path pattern with optional parameters
33
- # @param definition [String] Class and method definition (Class.method or Class#method)
38
+ # @param definition [String] Class and method definition with optional key-value parameters
39
+ # Examples:
40
+ # "Class.method" (traditional)
41
+ # "Class#method" (traditional)
42
+ # "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
34
43
  # @raise [ArgumentError] if definition format is invalid or class name is unsafe
35
44
  def initialize(verb, path, definition)
36
- @verb = verb.to_s.upcase.to_sym
37
- @path = path
38
- @definition = definition
39
- @pattern, @keys = *compile(@path)
40
- if !@definition.index('.').nil?
41
- @klass, @name = @definition.split('.')
42
- @kind = :class
43
- elsif !@definition.index('#').nil?
44
- @klass, @name = @definition.split('#')
45
- @kind = :instance
46
- else
47
- raise ArgumentError, "Bad definition: #{@definition}"
48
- end
49
- @klass = safe_const_get(@klass)
50
- # @method = @klass.method(@name)
45
+ @pattern, @keys = *compile(path)
46
+
47
+ # Create immutable route definition
48
+ @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: @pattern, keys: @keys)
49
+
50
+ # Resolve the class
51
+ @klass = safe_const_get(@route_definition.klass_name)
52
+ end
53
+
54
+ # Delegate common methods to route_definition for backward compatibility
55
+ def verb
56
+ @route_definition.verb
57
+ end
58
+
59
+ def path
60
+ @route_definition.path
61
+ end
62
+
63
+ def definition
64
+ @route_definition.definition
65
+ end
66
+
67
+ def pattern
68
+ @route_definition.pattern
69
+ end
70
+
71
+ def keys
72
+ @route_definition.keys
73
+ end
74
+
75
+ def name
76
+ @route_definition.method_name
77
+ end
78
+
79
+ def kind
80
+ @route_definition.kind
81
+ end
82
+
83
+ def route_options
84
+ @route_definition.options
51
85
  end
52
86
 
53
87
  private
54
88
 
89
+
55
90
  # Safely resolve a class name using Object.const_get with security validations
56
91
  # This replaces the previous eval() usage to prevent code injection attacks.
57
92
  #
@@ -120,6 +155,10 @@ class Otto
120
155
  env['otto.security_config'] = otto.security_config
121
156
  end
122
157
 
158
+ # NEW: Make route definition and options available to middleware and handlers
159
+ env['otto.route_definition'] = @route_definition
160
+ env['otto.route_options'] = @route_definition.options
161
+
123
162
  # Process parameters through security layer
124
163
  req.params.merge! extra_params
125
164
  req.params.replace Otto::Static.indifferent_params(req.params)
@@ -142,53 +181,80 @@ class Otto
142
181
  # Add validation helpers
143
182
  res.extend Otto::Security::ValidationHelpers
144
183
 
145
- case kind
146
- when :instance
147
- inst = klass.new req, res
148
- inst.send(name)
149
- when :class
150
- klass.send(name, req, res)
184
+ # NEW: Use the pluggable route handler factory (Phase 4)
185
+ # This replaces the hardcoded execution pattern with a factory approach
186
+ if otto&.route_handler_factory
187
+ handler = otto.route_handler_factory.create_handler(@route_definition, otto)
188
+ return handler.call(env, extra_params)
151
189
  else
152
- raise "Unsupported kind for #{@definition}: #{kind}"
190
+ # Fallback to legacy behavior for backward compatibility
191
+ inst = nil
192
+ result = case kind
193
+ when :instance
194
+ inst = klass.new req, res
195
+ inst.send(name)
196
+ when :class
197
+ klass.send(name, req, res)
198
+ else
199
+ raise "Unsupported kind for #{definition}: #{kind}"
200
+ end
201
+
202
+ # Handle response based on route options
203
+ response_type = @route_definition.response_type
204
+ if response_type != 'default'
205
+ context = {
206
+ logic_instance: (kind == :instance ? inst : nil),
207
+ status_code: nil,
208
+ redirect_path: nil
209
+ }
210
+
211
+ Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
212
+ end
213
+
214
+ res.body = [res.body] unless res.body.respond_to?(:each)
215
+ res.finish
153
216
  end
154
- res.body = [res.body] unless res.body.respond_to?(:each)
155
- res.finish
156
217
  end
157
218
 
158
219
  private
159
220
 
160
- # Brazenly borrowed from Sinatra::Base:
161
- # https://github.com/sinatra/sinatra/blob/v1.2.6/lib/sinatra/base.rb#L1156
162
221
  def compile(path)
163
222
  keys = []
164
- if path.respond_to? :to_str
165
- special_chars = %w[. + ( ) $]
166
- pattern =
167
- path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
168
- case match
169
- when '*'
170
- keys << 'splat'
171
- '(.*?)'
172
- when *special_chars
173
- Regexp.escape(match)
174
- else
175
- keys << ::Regexp.last_match(2)[1..-1]
176
- '([^/?#]+)'
177
- end
178
- end
179
- # Wrap the regex in parens so the regex works properly.
180
- # They can fail when there's an | for example (matching only the last one).
181
- # Note: this means we also need to remove the first matched value.
182
- [/\A(#{pattern})\z/, keys]
183
- elsif path.respond_to?(:keys) && path.respond_to?(:match)
184
- [path, path.keys]
185
- elsif path.respond_to?(:names) && path.respond_to?(:match)
186
- [path, path.names]
187
- elsif path.respond_to? :match
188
- [path, keys]
223
+
224
+ # Handle string paths first (most common case)
225
+ if path.respond_to?(:to_str)
226
+ compile_string_path(path, keys)
189
227
  else
190
- raise TypeError, path
228
+ case path
229
+ in { keys: route_keys, match: _ }
230
+ [path, route_keys]
231
+ in { names: route_names, match: _ }
232
+ [path, route_names]
233
+ in { match: _ }
234
+ [path, keys]
235
+ else
236
+ raise TypeError, path
237
+ end
191
238
  end
192
239
  end
240
+
241
+ def compile_string_path(path, keys)
242
+ raise TypeError, path unless path.respond_to?(:to_str)
243
+
244
+ pattern = path.to_str.gsub(/((:\w+)|[.*+()$])/) do |match|
245
+ case match
246
+ when '*'
247
+ keys << 'splat'
248
+ '(.*?)'
249
+ when '.', '+', '(', ')', '$'
250
+ Regexp.escape(match)
251
+ else
252
+ keys << match[1..-1] # Remove the colon
253
+ '([^/?#]+)'
254
+ end
255
+ end
256
+
257
+ [/\A(#{pattern})\z/, keys]
258
+ end
193
259
  end
194
260
  end
@@ -0,0 +1,187 @@
1
+ # lib/otto/route_definition.rb
2
+
3
+ class Otto
4
+ # Immutable data class representing a complete route definition
5
+ # This encapsulates all aspects of a route: path, target, and options
6
+ class RouteDefinition
7
+ # @return [String] The HTTP verb (GET, POST, etc.)
8
+ attr_reader :verb
9
+
10
+ # @return [String] The URL path pattern
11
+ attr_reader :path
12
+
13
+ # @return [String] The original definition string
14
+ attr_reader :definition
15
+
16
+ # @return [String] The target class and method (e.g., "TestApp.index")
17
+ attr_reader :target
18
+
19
+ # @return [String] The class name portion
20
+ attr_reader :klass_name
21
+
22
+ # @return [String] The method name portion
23
+ attr_reader :method_name
24
+
25
+ # @return [Symbol] The invocation kind (:class, :instance, or :logic)
26
+ attr_reader :kind
27
+
28
+ # @return [Hash] The route options (auth, response, csrf, etc.)
29
+ attr_reader :options
30
+
31
+ # @return [Regexp] The compiled path pattern for matching
32
+ attr_reader :pattern
33
+
34
+ # @return [Array<String>] The parameter keys extracted from the path
35
+ attr_reader :keys
36
+
37
+ def initialize(verb, path, definition, pattern: nil, keys: nil)
38
+ @verb = verb.to_s.upcase.to_sym
39
+ @path = path
40
+ @definition = definition
41
+ @pattern = pattern
42
+ @keys = keys || []
43
+
44
+ # Parse the definition into target and options
45
+ parsed = parse_definition(definition)
46
+ @target = parsed[:target]
47
+ @options = parsed[:options].freeze
48
+
49
+ # Parse the target into class, method, and kind
50
+ target_parsed = parse_target(@target)
51
+ @klass_name = target_parsed[:klass_name]
52
+ @method_name = target_parsed[:method_name]
53
+ @kind = target_parsed[:kind]
54
+
55
+ # Freeze for immutability
56
+ freeze
57
+ end
58
+
59
+ # Check if route has specific option
60
+ # @param key [Symbol, String] Option key to check
61
+ # @return [Boolean]
62
+ def has_option?(key)
63
+ @options.key?(key.to_sym)
64
+ end
65
+
66
+ # Get option value with optional default
67
+ # @param key [Symbol, String] Option key
68
+ # @param default [Object] Default value if option not present
69
+ # @return [Object]
70
+ def option(key, default = nil)
71
+ @options.fetch(key.to_sym, default)
72
+ end
73
+
74
+ # Get authentication requirement
75
+ # @return [String, nil] The auth requirement or nil
76
+ def auth_requirement
77
+ option(:auth)
78
+ end
79
+
80
+ # Get response type
81
+ # @return [String] The response type (defaults to 'default')
82
+ def response_type
83
+ option(:response, 'default')
84
+ end
85
+
86
+ # Check if CSRF is exempt for this route
87
+ # @return [Boolean]
88
+ def csrf_exempt?
89
+ option(:csrf) == 'exempt'
90
+ end
91
+
92
+ # Check if this is a Logic class route (no . or # in target)
93
+ # @return [Boolean]
94
+ def logic_route?
95
+ kind == :logic
96
+ end
97
+
98
+ # Create a new RouteDefinition with modified options
99
+ # @param new_options [Hash] Options to merge/override
100
+ # @return [RouteDefinition] New immutable instance
101
+ def with_options(new_options)
102
+ merged_options = @options.merge(new_options)
103
+ new_definition = [@target, *merged_options.map { |k, v| "#{k}=#{v}" }].join(' ')
104
+
105
+ self.class.new(@verb, @path, new_definition, pattern: @pattern, keys: @keys)
106
+ end
107
+
108
+ # Convert to hash representation
109
+ # @return [Hash]
110
+ def to_h
111
+ {
112
+ verb: @verb,
113
+ path: @path,
114
+ definition: @definition,
115
+ target: @target,
116
+ klass_name: @klass_name,
117
+ method_name: @method_name,
118
+ kind: @kind,
119
+ options: @options,
120
+ pattern: @pattern,
121
+ keys: @keys
122
+ }
123
+ end
124
+
125
+ # String representation for debugging
126
+ # @return [String]
127
+ def to_s
128
+ "#{@verb} #{@path} #{@definition}"
129
+ end
130
+
131
+ # Detailed inspection
132
+ # @return [String]
133
+ def inspect
134
+ "#<Otto::RouteDefinition #{to_s} options=#{@options.inspect}>"
135
+ end
136
+
137
+ private
138
+
139
+ # Parse route definition into target and options
140
+ # @param definition [String] The route definition
141
+ # @return [Hash] Hash with :target and :options keys
142
+ def parse_definition(definition)
143
+ parts = definition.split(/\s+/)
144
+ target = parts.shift
145
+ options = {}
146
+
147
+ parts.each do |part|
148
+ key, value = part.split('=', 2)
149
+ if key && value
150
+ options[key.to_sym] = value
151
+ else
152
+ # Malformed parameter, log warning if debug enabled
153
+ Otto.logger.warn "Ignoring malformed route parameter: #{part}" if Otto.debug
154
+ end
155
+ end
156
+
157
+ { target: target, options: options }
158
+ end
159
+
160
+ # Parse target into class name, method name, and kind
161
+ # @param target [String] The target definition (e.g., "TestApp.index")
162
+ # @return [Hash] Hash with :klass_name, :method_name, and :kind
163
+ def parse_target(target)
164
+ if target.include?('.')
165
+ klass_name, method_name = target.split('.', 2)
166
+ { klass_name: klass_name, method_name: method_name, kind: :class }
167
+ elsif target.include?('#')
168
+ klass_name, method_name = target.split('#', 2)
169
+ { klass_name: klass_name, method_name: method_name, kind: :instance }
170
+ elsif target.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
171
+ # Namespaced class with implicit method name (class method with same name as class)
172
+ # E.g., "V2::Logic::Admin::Panel" -> Panel.Panel (class method)
173
+ # For single word classes like "Logic", it's truly a logic class
174
+ method_name = target.split('::').last
175
+ if target.include?('::')
176
+ # Namespaced class - treat as class method with implicit method name
177
+ { klass_name: target, method_name: method_name, kind: :class }
178
+ else
179
+ # Single word class - treat as logic class
180
+ { klass_name: target, method_name: method_name, kind: :logic }
181
+ end
182
+ else
183
+ raise ArgumentError, "Invalid target format: #{target}"
184
+ end
185
+ end
186
+ end
187
+ end