otto 1.3.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.
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
  #
@@ -115,6 +150,15 @@ class Otto
115
150
  res.extend Otto::ResponseHelpers
116
151
  res.request = req
117
152
 
153
+ # Make security config available to response helpers
154
+ if otto.respond_to?(:security_config) && otto.security_config
155
+ env['otto.security_config'] = otto.security_config
156
+ end
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
+
118
162
  # Process parameters through security layer
119
163
  req.params.merge! extra_params
120
164
  req.params.replace Otto::Static.indifferent_params(req.params)
@@ -137,53 +181,80 @@ class Otto
137
181
  # Add validation helpers
138
182
  res.extend Otto::Security::ValidationHelpers
139
183
 
140
- case kind
141
- when :instance
142
- inst = klass.new req, res
143
- inst.send(name)
144
- when :class
145
- 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)
146
189
  else
147
- 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
148
216
  end
149
- res.body = [res.body] unless res.body.respond_to?(:each)
150
- res.finish
151
217
  end
152
218
 
153
219
  private
154
220
 
155
- # Brazenly borrowed from Sinatra::Base:
156
- # https://github.com/sinatra/sinatra/blob/v1.2.6/lib/sinatra/base.rb#L1156
157
221
  def compile(path)
158
222
  keys = []
159
- if path.respond_to? :to_str
160
- special_chars = %w[. + ( ) $]
161
- pattern =
162
- path.to_str.gsub(/((:\w+)|[\*#{special_chars.join}])/) do |match|
163
- case match
164
- when '*'
165
- keys << 'splat'
166
- '(.*?)'
167
- when *special_chars
168
- Regexp.escape(match)
169
- else
170
- keys << ::Regexp.last_match(2)[1..-1]
171
- '([^/?#]+)'
172
- end
173
- end
174
- # Wrap the regex in parens so the regex works properly.
175
- # They can fail when there's an | for example (matching only the last one).
176
- # Note: this means we also need to remove the first matched value.
177
- [/\A(#{pattern})\z/, keys]
178
- elsif path.respond_to?(:keys) && path.respond_to?(:match)
179
- [path, path.keys]
180
- elsif path.respond_to?(:names) && path.respond_to?(:match)
181
- [path, path.names]
182
- elsif path.respond_to? :match
183
- [path, keys]
223
+
224
+ # Handle string paths first (most common case)
225
+ if path.respond_to?(:to_str)
226
+ compile_string_path(path, keys)
184
227
  else
185
- 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
186
238
  end
187
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
188
259
  end
189
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