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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +15 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +107 -0
- data/.pre-push-config.yaml +88 -0
- data/.rubocop.yml +1 -1
- data/Gemfile +1 -3
- data/Gemfile.lock +5 -3
- data/README.md +58 -2
- data/docs/.gitignore +2 -0
- data/examples/helpers_demo/app.rb +244 -0
- data/examples/helpers_demo/config.ru +26 -0
- data/examples/helpers_demo/routes +7 -0
- data/lib/otto/helpers/base.rb +27 -0
- data/lib/otto/helpers/request.rb +223 -4
- data/lib/otto/helpers/response.rb +75 -0
- data/lib/otto/response_handlers.rb +141 -0
- data/lib/otto/route.rb +125 -54
- data/lib/otto/route_definition.rb +187 -0
- data/lib/otto/route_handlers.rb +383 -0
- data/lib/otto/security/authentication.rb +289 -0
- data/lib/otto/security/config.rb +99 -1
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +143 -3
- data/otto.gemspec +2 -2
- metadata +29 -2
@@ -0,0 +1,27 @@
|
|
1
|
+
# lib/otto/helpers/base.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
module BaseHelpers
|
5
|
+
# Build application path by joining path segments
|
6
|
+
#
|
7
|
+
# This method safely joins multiple path segments, handling
|
8
|
+
# duplicate slashes and ensuring proper path formatting.
|
9
|
+
# Includes the script name (mount point) as the first segment.
|
10
|
+
#
|
11
|
+
# @param paths [Array<String>] Path segments to join
|
12
|
+
# @return [String] Properly formatted path
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# app_path('api', 'v1', 'users')
|
16
|
+
# # => "/myapp/api/v1/users"
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# app_path(['admin', 'settings'])
|
20
|
+
# # => "/myapp/admin/settings"
|
21
|
+
def app_path(*paths)
|
22
|
+
paths = paths.flatten.compact
|
23
|
+
paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
|
24
|
+
paths.join('/').gsub('//', '/')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/otto/helpers/request.rb
CHANGED
@@ -1,7 +1,11 @@
|
|
1
1
|
# lib/otto/helpers/request.rb
|
2
2
|
|
3
|
+
require_relative 'base'
|
4
|
+
|
3
5
|
class Otto
|
4
6
|
module RequestHelpers
|
7
|
+
include Otto::BaseHelpers
|
8
|
+
|
5
9
|
def user_agent
|
6
10
|
env['HTTP_USER_AGENT']
|
7
11
|
end
|
@@ -70,7 +74,11 @@ class Otto
|
|
70
74
|
ip = client_ipaddress
|
71
75
|
return false unless ip
|
72
76
|
|
73
|
-
|
77
|
+
# Check both IP and server name for comprehensive localhost detection
|
78
|
+
server_name = env['SERVER_NAME']
|
79
|
+
local_server_names = ['localhost', '127.0.0.1', '0.0.0.0']
|
80
|
+
|
81
|
+
local_or_private_ip?(ip) && local_server_names.include?(server_name)
|
74
82
|
end
|
75
83
|
|
76
84
|
def secure?
|
@@ -107,8 +115,6 @@ class Otto
|
|
107
115
|
[prefix, http_host, request_path].join
|
108
116
|
end
|
109
117
|
|
110
|
-
private
|
111
|
-
|
112
118
|
def otto_security_config
|
113
119
|
# Try to get security config from various sources
|
114
120
|
if respond_to?(:otto) && otto.respond_to?(:security_config)
|
@@ -136,7 +142,7 @@ class Otto
|
|
136
142
|
|
137
143
|
# Validate each octet
|
138
144
|
octets = clean_ip.split('.')
|
139
|
-
return nil unless octets.all? { |octet| (0..255).
|
145
|
+
return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
|
140
146
|
|
141
147
|
clean_ip
|
142
148
|
end
|
@@ -166,5 +172,218 @@ class Otto
|
|
166
172
|
# Check for private IP ranges
|
167
173
|
private_ip?(ip)
|
168
174
|
end
|
175
|
+
|
176
|
+
# Collect and format HTTP header details from the request environment
|
177
|
+
#
|
178
|
+
# This method extracts and formats specific HTTP headers, including
|
179
|
+
# Cloudflare and proxy-related headers, for logging and debugging purposes.
|
180
|
+
#
|
181
|
+
# @param header_prefix [String, nil] Custom header prefix to include (e.g. 'X_SECRET_')
|
182
|
+
# @param additional_keys [Array<String>] Additional header keys to collect
|
183
|
+
# @return [String] Formatted header details as "key: value" pairs
|
184
|
+
#
|
185
|
+
# @example Basic usage
|
186
|
+
# collect_proxy_headers
|
187
|
+
# # => "X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1"
|
188
|
+
#
|
189
|
+
#
|
190
|
+
# @example With custom prefix
|
191
|
+
# collect_proxy_headers(header_prefix: 'X_CUSTOM_')
|
192
|
+
# # => "X-Forwarded-For: 203.0.113.195 X-Custom-Token: abc123"
|
193
|
+
def collect_proxy_headers(header_prefix: nil, additional_keys: [])
|
194
|
+
keys = %w[
|
195
|
+
HTTP_FLY_REQUEST_ID
|
196
|
+
HTTP_VIA
|
197
|
+
HTTP_X_FORWARDED_PROTO
|
198
|
+
HTTP_X_FORWARDED_FOR
|
199
|
+
HTTP_X_FORWARDED_HOST
|
200
|
+
HTTP_X_FORWARDED_PORT
|
201
|
+
HTTP_X_SCHEME
|
202
|
+
HTTP_X_REAL_IP
|
203
|
+
HTTP_CF_IPCOUNTRY
|
204
|
+
HTTP_CF_RAY
|
205
|
+
REMOTE_ADDR
|
206
|
+
]
|
207
|
+
|
208
|
+
# Add any header that begins with the specified prefix
|
209
|
+
if header_prefix
|
210
|
+
prefix_keys = env.keys.select { |key| key.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
|
211
|
+
keys.concat(prefix_keys)
|
212
|
+
end
|
213
|
+
|
214
|
+
# Add any additional keys requested
|
215
|
+
keys.concat(additional_keys) if additional_keys.any?
|
216
|
+
|
217
|
+
keys.sort.filter_map do |key|
|
218
|
+
value = env[key]
|
219
|
+
next unless value
|
220
|
+
|
221
|
+
# Normalize the header name to look like browser dev console
|
222
|
+
# e.g. Content-Type instead of HTTP_CONTENT_TYPE
|
223
|
+
pretty_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
|
224
|
+
"#{pretty_name}: #{value}"
|
225
|
+
end.join(' ')
|
226
|
+
end
|
227
|
+
|
228
|
+
# Format request details as a single string for logging
|
229
|
+
#
|
230
|
+
# This method combines IP address, HTTP method, path, query parameters,
|
231
|
+
# and proxy header details into a single formatted string suitable for logging.
|
232
|
+
#
|
233
|
+
# @param header_prefix [String, nil] Custom header prefix for proxy headers
|
234
|
+
# @return [String] Formatted request details
|
235
|
+
#
|
236
|
+
# @example
|
237
|
+
# format_request_details
|
238
|
+
# # => "192.0.2.1; GET /path?query=string; Proxy[X-Forwarded-For: 203.0.113.195 Remote-Addr: 192.0.2.1]"
|
239
|
+
#
|
240
|
+
def format_request_details(header_prefix: nil)
|
241
|
+
header_details = collect_proxy_headers(header_prefix: header_prefix)
|
242
|
+
|
243
|
+
details = [
|
244
|
+
client_ipaddress,
|
245
|
+
"#{request_method} #{env['PATH_INFO']}?#{env['QUERY_STRING']}",
|
246
|
+
"Proxy[#{header_details}]",
|
247
|
+
]
|
248
|
+
|
249
|
+
details.join('; ')
|
250
|
+
end
|
251
|
+
|
252
|
+
# Check if user agent matches blocked patterns
|
253
|
+
#
|
254
|
+
# This method checks if the current request's user agent string
|
255
|
+
# matches any of the provided blocked agent patterns.
|
256
|
+
#
|
257
|
+
# @param blocked_agents [Array<String, Symbol, Regexp>] Patterns to check against
|
258
|
+
# @return [Boolean] true if user agent is allowed, false if blocked
|
259
|
+
#
|
260
|
+
# @example
|
261
|
+
# blocked_user_agent?([:bot, :crawler, 'BadAgent'])
|
262
|
+
# # => false if user agent contains 'bot', 'crawler', or 'BadAgent'
|
263
|
+
def blocked_user_agent?(blocked_agents: [])
|
264
|
+
return true if blocked_agents.empty?
|
265
|
+
|
266
|
+
user_agent_string = user_agent.to_s.downcase
|
267
|
+
return true if user_agent_string.empty?
|
268
|
+
|
269
|
+
blocked_agents.flatten.any? do |agent|
|
270
|
+
case agent
|
271
|
+
when Regexp
|
272
|
+
user_agent_string.match?(agent)
|
273
|
+
else
|
274
|
+
user_agent_string.include?(agent.to_s.downcase)
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# Build application path by joining path segments
|
280
|
+
#
|
281
|
+
# This method safely joins multiple path segments, handling
|
282
|
+
# duplicate slashes and ensuring proper path formatting.
|
283
|
+
# Includes the script name (mount point) as the first segment.
|
284
|
+
#
|
285
|
+
# @param paths [Array<String>] Path segments to join
|
286
|
+
# @return [String] Properly formatted path
|
287
|
+
#
|
288
|
+
# @example
|
289
|
+
# app_path('api', 'v1', 'users')
|
290
|
+
# # => "/myapp/api/v1/users"
|
291
|
+
#
|
292
|
+
# @example
|
293
|
+
# app_path(['admin', 'settings'])
|
294
|
+
# # => "/myapp/admin/settings"
|
295
|
+
def app_path(*paths)
|
296
|
+
paths = paths.flatten.compact
|
297
|
+
paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
|
298
|
+
paths.join('/').gsub('//', '/')
|
299
|
+
end
|
300
|
+
|
301
|
+
# Set the locale for the request based on multiple sources
|
302
|
+
#
|
303
|
+
# This method determines the locale to be used for the request by checking
|
304
|
+
# the following sources in order of precedence:
|
305
|
+
# 1. The locale parameter passed to the method
|
306
|
+
# 2. The locale query parameter in the request
|
307
|
+
# 3. The user's saved locale preference (if provided)
|
308
|
+
# 4. The rack.locale environment variable
|
309
|
+
#
|
310
|
+
# If a valid locale is found, it's stored in the request environment.
|
311
|
+
# If no valid locale is found, the default locale is used.
|
312
|
+
#
|
313
|
+
# @param locale [String, nil] The locale to use, if specified
|
314
|
+
# @param opts [Hash] Configuration options
|
315
|
+
# @option opts [Hash] :available_locales Hash of available locales to validate against (required unless configured at Otto level)
|
316
|
+
# @option opts [String] :default_locale Default locale to use as fallback (required unless configured at Otto level)
|
317
|
+
# @option opts [String, nil] :preferred_locale User's saved locale preference
|
318
|
+
# @option opts [String] :locale_env_key Environment key to store the locale (default: 'locale')
|
319
|
+
# @option opts [Boolean] :debug Enable debug logging for locale selection
|
320
|
+
# @return [String] The selected locale
|
321
|
+
#
|
322
|
+
# @example Basic usage
|
323
|
+
# check_locale!(
|
324
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish' },
|
325
|
+
# default_locale: 'en'
|
326
|
+
# )
|
327
|
+
# # => 'en'
|
328
|
+
#
|
329
|
+
# @example With user preference
|
330
|
+
# check_locale!(nil, {
|
331
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish' },
|
332
|
+
# default_locale: 'en',
|
333
|
+
# preferred_locale: 'es'
|
334
|
+
# })
|
335
|
+
# # => 'es'
|
336
|
+
#
|
337
|
+
# @example Using Otto-level configuration
|
338
|
+
# # Otto configured with: Otto.new(routes, { locale_config: { available: {...}, default: 'en' } })
|
339
|
+
# check_locale!('es') # Uses Otto's config automatically
|
340
|
+
# # => 'es'
|
341
|
+
#
|
342
|
+
def check_locale!(locale = nil, opts = {})
|
343
|
+
# Get configuration from options, Otto config, or environment (in that order)
|
344
|
+
otto_config = env['otto.locale_config']
|
345
|
+
|
346
|
+
available_locales = opts[:available_locales] ||
|
347
|
+
otto_config&.dig(:available_locales) ||
|
348
|
+
env['otto.available_locales']
|
349
|
+
default_locale = opts[:default_locale] ||
|
350
|
+
otto_config&.dig(:default_locale) ||
|
351
|
+
env['otto.default_locale']
|
352
|
+
preferred_locale = opts[:preferred_locale]
|
353
|
+
locale_env_key = opts[:locale_env_key] || 'locale'
|
354
|
+
debug_enabled = opts[:debug] || false
|
355
|
+
|
356
|
+
# Guard clause - required configuration must be present
|
357
|
+
unless available_locales && default_locale
|
358
|
+
raise ArgumentError, 'available_locales and default_locale are required (provide via opts or Otto configuration)'
|
359
|
+
end
|
360
|
+
|
361
|
+
# Check sources in order of precedence
|
362
|
+
locale ||= env['rack.request.query_hash'] && env['rack.request.query_hash']['locale']
|
363
|
+
locale ||= preferred_locale if preferred_locale
|
364
|
+
locale ||= (env['rack.locale'] || []).first
|
365
|
+
|
366
|
+
# Validate locale against available translations
|
367
|
+
have_translations = locale && available_locales.key?(locale.to_s)
|
368
|
+
|
369
|
+
# Debug logging if enabled
|
370
|
+
if debug_enabled && defined?(Otto.logger)
|
371
|
+
message = format(
|
372
|
+
'[check_locale!] sources[param=%s query=%s user=%s rack=%s] valid=%s',
|
373
|
+
locale,
|
374
|
+
env.dig('rack.request.query_hash', 'locale'),
|
375
|
+
preferred_locale,
|
376
|
+
(env['rack.locale'] || []).first,
|
377
|
+
have_translations
|
378
|
+
)
|
379
|
+
Otto.logger.debug message
|
380
|
+
end
|
381
|
+
|
382
|
+
# Set the locale in request environment
|
383
|
+
selected_locale = have_translations ? locale : default_locale
|
384
|
+
env[locale_env_key] = selected_locale
|
385
|
+
|
386
|
+
selected_locale
|
387
|
+
end
|
169
388
|
end
|
170
389
|
end
|
@@ -1,7 +1,11 @@
|
|
1
1
|
# lib/otto/helpers/response.rb
|
2
2
|
|
3
|
+
require_relative 'base'
|
4
|
+
|
3
5
|
class Otto
|
4
6
|
module ResponseHelpers
|
7
|
+
include Otto::BaseHelpers
|
8
|
+
|
5
9
|
attr_accessor :request
|
6
10
|
|
7
11
|
def send_secure_cookie(name, value, ttl, opts = {})
|
@@ -76,5 +80,76 @@ class Otto
|
|
76
80
|
|
77
81
|
headers
|
78
82
|
end
|
83
|
+
|
84
|
+
# Set Content Security Policy (CSP) headers with nonce support
|
85
|
+
#
|
86
|
+
# This method generates and sets CSP headers with the provided nonce value,
|
87
|
+
# following the same usage pattern as send_cookie methods. The CSP policy
|
88
|
+
# is generated dynamically based on the security configuration and environment.
|
89
|
+
#
|
90
|
+
# @param content_type [String] Content-Type header value to set
|
91
|
+
# @param nonce [String] Nonce value to include in CSP directives
|
92
|
+
# @param opts [Hash] Options for CSP generation
|
93
|
+
# @option opts [Otto::Security::Config] :security_config Security config to use
|
94
|
+
# @option opts [Boolean] :development_mode Use development-friendly CSP directives
|
95
|
+
# @option opts [Boolean] :debug Enable debug logging for this request
|
96
|
+
# @return [void]
|
97
|
+
#
|
98
|
+
# @example Basic usage
|
99
|
+
# nonce = SecureRandom.base64(16)
|
100
|
+
# res.send_csp_headers('text/html; charset=utf-8', nonce)
|
101
|
+
#
|
102
|
+
# @example With options
|
103
|
+
# res.send_csp_headers('text/html; charset=utf-8', nonce, {
|
104
|
+
# development_mode: Rails.env.development?,
|
105
|
+
# debug: true
|
106
|
+
# })
|
107
|
+
def send_csp_headers(content_type, nonce, opts = {})
|
108
|
+
# Set content type if not already set
|
109
|
+
headers['content-type'] ||= content_type
|
110
|
+
|
111
|
+
# Warn if CSP header already exists but don't skip
|
112
|
+
if headers['content-security-policy']
|
113
|
+
warn 'CSP header already set, overriding with nonce-based policy'
|
114
|
+
end
|
115
|
+
|
116
|
+
# Get security configuration
|
117
|
+
security_config = opts[:security_config] ||
|
118
|
+
(request&.env && request.env['otto.security_config']) ||
|
119
|
+
nil
|
120
|
+
|
121
|
+
# Skip if CSP nonce support is not enabled
|
122
|
+
return unless security_config&.csp_nonce_enabled?
|
123
|
+
|
124
|
+
# Generate CSP policy with nonce
|
125
|
+
development_mode = opts[:development_mode] || false
|
126
|
+
csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
|
127
|
+
|
128
|
+
# Debug logging if enabled
|
129
|
+
debug_enabled = opts[:debug] || security_config.debug_csp?
|
130
|
+
if debug_enabled && defined?(Otto.logger)
|
131
|
+
Otto.logger.debug "[CSP] #{csp_policy}"
|
132
|
+
end
|
133
|
+
|
134
|
+
# Set the CSP header
|
135
|
+
headers['content-security-policy'] = csp_policy
|
136
|
+
end
|
137
|
+
|
138
|
+
# Set cache control headers to prevent caching
|
139
|
+
#
|
140
|
+
# This method sets comprehensive cache control headers to ensure that
|
141
|
+
# the response is not cached by browsers, proxies, or CDNs. This is
|
142
|
+
# particularly useful for sensitive pages or dynamic content that
|
143
|
+
# should always be fresh.
|
144
|
+
#
|
145
|
+
# @return [void]
|
146
|
+
#
|
147
|
+
# @example
|
148
|
+
# res.no_cache!
|
149
|
+
def no_cache!
|
150
|
+
headers['cache-control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
151
|
+
headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
|
152
|
+
headers['pragma'] = 'no-cache'
|
153
|
+
end
|
79
154
|
end
|
80
155
|
end
|
@@ -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
|