Module: Otto::Core::Router

Included in:
Otto
Defined in:
lib/otto/core/router.rb

Overview

Router module providing route loading and request dispatching functionality

Instance Method Summary collapse

Instance Method Details

#determine_locale(env) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/otto/core/router.rb', line 174

def determine_locale(env)
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
  accept_langs = option[:locale] if accept_langs.to_s.empty?
  locales      = []
  unless accept_langs.empty?
    locales = accept_langs.split(',').map do |l|
      l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
      l.split(';q=')
    end.sort_by do |_locale, qvalue|
      qvalue.to_f
    end.collect do |locale, _qvalue|
      locale
    end.reverse
  end
  Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
  locales.empty? ? nil : locales
end

#handle_request(env) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/otto/core/router.rb', line 99

def handle_request(env)
  locale             = determine_locale env
  env['rack.locale'] = locale
  env['otto.locale_config'] = @locale_config.to_h if @locale_config
  @static_route    ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
  path_info          = Rack::Utils.unescape(env['PATH_INFO'])
  path_info          = '/' if path_info.to_s.empty?

  begin
    # Shared with Otto::CaddyTLS::LocalhostGuard so the guard and the
    # router cannot normalize a path differently (which would be a guard
    # bypass). See Otto::Utils.normalize_path.
    path_info_clean = Otto::Utils.normalize_path(env['PATH_INFO'])
  rescue ArgumentError => e
    # Log the error but don't expose details
    Otto.logger.error '[Otto.handle_request] Path encoding error'
    Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
    # Set a default value or use the original path_info
    path_info_clean = path_info
  end

  base_path      = File.split(path_info).first
  # Files in the root directory can refer to themselves
  base_path      = path_info if base_path == '/'
  http_verb      = env['REQUEST_METHOD'].upcase.to_sym
  literal_routes = routes_literal[http_verb] || {}
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD

  # Dynamic-route and static-file dispatch match against the SAME
  # normalized path the literal table and the LocalhostGuard use, so all
  # dispatch paths share one normalization (issue #187). Without this,
  # dynamic routes matched the raw (unescape-only) path: they were
  # stricter about trailing slashes than literal routes (equivalent URLs
  # matched or missed depending on route kind), and invalid-UTF-8 bytes
  # scrubbed for the guard and literal matching survived into the dynamic
  # matcher and safe_file? — the guard-bypass class normalize_path exists
  # to close. normalize_path collapses root to '' after stripping the
  # trailing slash; the regex matcher and safe_file? need a leading slash
  # to be structural (a catch-all `/*` still matches `/`), so restore '/'
  # for them. Literal lookup keeps '' — it already keys root that way.
  dispatch_path = path_info_clean.empty? ? '/' : path_info_clean

  if static_route && http_verb == :GET && routes_static[:GET].key?(base_path)
    Otto.structured_log(:debug, 'Route matched',
      Otto::LoggingHelpers.request_context(env).merge(
        type: 'static_cached',
        base_path: base_path
      ))
    static_route.call(env)
  elsif literal_routes.has_key?(path_info_clean)
    route = literal_routes[path_info_clean]
    Otto.structured_log(:debug, 'Route matched',
      Otto::LoggingHelpers.request_context(env).merge(
        type: 'literal',
        handler: route.route_definition.definition,
        auth_strategy: route.route_definition.auth_requirement || 'none'
      ))
    # Fire route matched hooks before dispatch; raises propagate to handle_error
    unless @route_matched_callbacks.empty?
      @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
    end
    route.call(env)
  elsif static_route && http_verb == :GET && safe_file?(dispatch_path)
    Otto.structured_log(:debug, 'Route matched',
      Otto::LoggingHelpers.request_context(env).merge(
        type: 'static_new',
        base_path: base_path
      ))
    routes_static[:GET][base_path] = base_path
    static_route.call(env)
  else
    match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
  end
end

#load(path) ⇒ Object

Raises:

  • (ArgumentError)


11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/otto/core/router.rb', line 11

def load(path)
  path = File.expand_path(path)
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)

  raw = File.readlines(path).grep(/^\w/).collect(&:strip)
  raw.each do |entry|
    # Enhanced parsing: split only on first two whitespace boundaries
    # This preserves parameters in the definition part
    parts = entry.split(/\s+/, 3)
    if parts.size < 3
      # A missing/blank handler must not make the route vanish silently
      # (issue #191): warn unconditionally, not gated behind Otto.debug.
      Otto.structured_log(:warn, 'Malformed route line skipped',
        { line: entry, expected: 'VERB /path Handler [options]' })
      next
    end

    verb = parts[0]
    path = parts[1]
    definition = parts[2]

    # Check for MCP routes
    if Otto::MCP::RouteParser.is_mcp_route?(definition)
      handle_mcp_route(verb, path, definition)
      next
    elsif Otto::MCP::RouteParser.is_tool_route?(definition)
      handle_tool_route(verb, path, definition)
      next
    end

    route      = Otto::Route.new verb, path, definition
    route.otto = self
    path_clean = path.gsub(%r{/$}, '')

    # A definition string is not unique (the same handler can be mounted
    # at several verb/path pairs), so @route_definitions keeps the
    # first-loaded route per definition — deterministic, instead of the
    # last-loaded route silently winning — and @routes_by_definition
    # keeps them all for uri() disambiguation (issue #190).
    if (existing = @route_definitions[route.definition])
      # Mounting one handler at several paths is a fully supported
      # pattern (issue #190) — uri() disambiguates by params, so this
      # is informational, not a problem. Debug-gated like other
      # routing diagnostics rather than warning on every boot for
      # valid configs (e.g. `/users/:id` and `/me` aliases).
      Otto.structured_log(:debug, 'Duplicate route definition',
        {
          definition: route.definition,
          kept: "#{existing.verb} #{existing.path}",
          also: "#{route.verb} #{route.path}",
          hint: 'uri() picks the route whose path params match the given params',
        })
    else
      @route_definitions[route.definition] = route
    end
    (@routes_by_definition[route.definition] ||= []) << route
    if Otto.debug
      Otto.structured_log(:debug, 'Route loaded',
        {
             pattern: route.pattern.source,
          verb: route.verb,
          definition: route.definition,
          type: 'pattern',
        })
    end
    @routes[route.verb] ||= []
    @routes[route.verb] << route
    @routes_literal[route.verb]           ||= {}
    @routes_literal[route.verb][path_clean] = route
  rescue Otto::RouteDefinitionError
    # A malformed security-gating option (auth/role/csrf) fails fast at
    # boot rather than serving the route without its intended protection
    # (issue #191). Deliberately not swallowed like other per-line errors.
    raise
  rescue StandardError => e
    Otto.structured_log(:error, 'Route load failed',
      {
               path: path,
        verb: verb,
        definition: definition,
        error: e.message,
        error_class: e.class.name,
      })
    Otto.logger.debug e.backtrace.join("\n") if Otto.debug
  end
  self
end