otto 2.0.0.pre8 → 2.0.0.pre9

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.
@@ -1,8 +1,6 @@
1
1
  # lib/otto/route_handlers/logic_class.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- require 'json'
5
- require 'securerandom'
6
4
 
7
5
  require_relative 'base'
8
6
 
@@ -20,98 +18,95 @@ class Otto
20
18
  # For endpoints requiring direct request access (sessions, cookies, headers,
21
19
  # or logout flows), use controller handlers (Controller#action or Controller.action).
22
20
  class LogicClassHandler < BaseHandler
23
- def call(env, extra_params = {})
24
- start_time = Otto::Utils.now_in_μs
25
- req = Rack::Request.new(env)
26
- res = Rack::Response.new
21
+ protected
27
22
 
23
+ # Invoke Logic class with constrained signature
24
+ # @param req [Rack::Request] Request object
25
+ # @param res [Rack::Response] Response object
26
+ # @return [Array] [result, context] for handle_response
27
+ def invoke_target(req, _res)
28
+ env = req.env
29
+
30
+ # Get strategy result (guaranteed to exist from RouteAuthWrapper)
31
+ strategy_result = env['otto.strategy_result']
32
+
33
+ # Extract params including JSON body parsing
34
+ logic_params = extract_logic_params(req, env)
35
+
36
+ # Get locale
37
+ locale = env['otto.locale'] || 'en'
38
+
39
+ # Instantiate Logic class
40
+ logic = target_class.new(strategy_result, logic_params, locale)
41
+
42
+ # Execute standard Logic class lifecycle
43
+ logic.raise_concerns if logic.respond_to?(:raise_concerns)
44
+
45
+ result = if logic.respond_to?(:process)
46
+ logic.process
47
+ else
48
+ logic.call || logic
49
+ end
50
+
51
+ context = {
52
+ logic_instance: logic,
53
+ request: req,
54
+ status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
55
+ }
56
+
57
+ [result, context]
58
+ end
59
+
60
+ # Extract logic parameters including JSON body parsing
61
+ # @param req [Rack::Request] Request object
62
+ # @param env [Hash] Rack environment
63
+ # @return [Hash] Parameters for Logic class
64
+ def extract_logic_params(req, env)
65
+ # req.params already has extra_params merged and indifferent_params applied
66
+ # by setup_request_response in BaseHandler
67
+ logic_params = req.params.dup
68
+
69
+ # Handle JSON request bodies
70
+ if req.content_type&.include?('application/json') && req.body.size.positive?
71
+ logic_params = parse_json_body(req, env, logic_params)
72
+ end
73
+
74
+ logic_params
75
+ end
76
+
77
+ # Parse JSON request body with error handling
78
+ # @param req [Rack::Request] Request object
79
+ # @param env [Hash] Rack environment
80
+ # @param logic_params [Hash] Current parameters
81
+ # @return [Hash] Parameters with JSON merged (or original if parsing fails)
82
+ def parse_json_body(req, env, logic_params)
28
83
  begin
29
- # Get strategy result (guaranteed to exist from RouteAuthWrapper)
30
- strategy_result = env['otto.strategy_result']
31
-
32
- # Initialize Logic class with new signature: context, params, locale
33
- logic_params = req.params.merge(extra_params)
34
-
35
- # Handle JSON request bodies
36
- if req.content_type&.include?('application/json') && req.body.size.positive?
37
- begin
38
- req.body.rewind
39
- json_data = JSON.parse(req.body.read)
40
- logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
41
- rescue JSON::ParserError => e
42
- # Base context pattern: create once, reuse for correlation
43
- base_context = Otto::LoggingHelpers.request_context(env)
44
-
45
- Otto.structured_log(:error, "JSON parsing error",
46
- base_context.merge(
47
- handler: "#{target_class}#call",
48
- error: e.message,
49
- error_class: e.class.name,
50
- duration: Otto::Utils.now_in_μs - start_time
51
- )
52
- )
53
-
54
- Otto::LoggingHelpers.log_backtrace(e,
55
- base_context.merge(handler: "#{target_class}#call")
56
- )
57
- end
58
- end
59
-
60
- locale = env['otto.locale'] || 'en'
61
-
62
- logic = target_class.new(strategy_result, logic_params, locale)
63
-
64
- # Execute standard Logic class lifecycle
65
- logic.raise_concerns if logic.respond_to?(:raise_concerns)
66
-
67
- result = if logic.respond_to?(:process)
68
- logic.process
69
- else
70
- logic.call || logic
71
- end
72
-
73
- # Handle response with Logic instance context
74
- handle_response(result, res, {
75
- logic_instance: logic,
76
- request: req,
77
- status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
78
- })
79
- rescue StandardError => e
80
- # Check if we're being called through Otto's integrated context (vs direct handler testing)
81
- # In integrated context, let Otto's centralized error handler manage the response
82
- # In direct testing context, handle errors locally for unit testing
83
- if otto_instance
84
- # Store handler context in env for centralized error handler
85
- handler_name = "#{target_class}#call"
86
- env['otto.handler'] = handler_name
87
- env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
88
-
89
- raise e # Re-raise to let Otto's centralized error handler manage the response
90
- else
91
- # Direct handler testing context - handle errors locally with security improvements
92
- error_id = SecureRandom.hex(8)
93
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
94
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
95
-
96
- res.status = 500
97
- res.headers['content-type'] = 'text/plain'
98
-
99
- if Otto.env?(:dev, :development)
100
- res.write "Server error (ID: #{error_id}). Check logs for details."
101
- else
102
- res.write 'An error occurred. Please try again later.'
103
- end
104
-
105
- # Add security headers if available
106
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
107
- otto_instance.security_config.security_headers.each do |header, value|
108
- res.headers[header] = value
109
- end
110
- end
111
- end
84
+ req.body.rewind
85
+ json_data = JSON.parse(req.body.read)
86
+ logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
87
+ rescue JSON::ParserError => e
88
+ # Base context pattern: create once, reuse for correlation
89
+ log_context = Otto::LoggingHelpers.request_context(env)
90
+
91
+ Otto.structured_log(:error, 'JSON parsing error',
92
+ log_context.merge(
93
+ handler: handler_name,
94
+ error: e.message,
95
+ error_class: e.class.name,
96
+ duration: Otto::Utils.now_in_μs - @start_time
97
+ ))
98
+
99
+ Otto::LoggingHelpers.log_backtrace(e,
100
+ log_context.merge(handler: handler_name))
112
101
  end
113
102
 
114
- res.finish
103
+ logic_params
104
+ end
105
+
106
+ # Format handler name for Logic routes
107
+ # @return [String] Handler name in format "ClassName#call"
108
+ def handler_name
109
+ "#{target_class.name}#call"
115
110
  end
116
111
  end
117
112
  end
@@ -1,7 +1,7 @@
1
1
  # lib/otto/security/authentication/auth_strategy.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- #
4
+
5
5
  # Base class for all authentication strategies in Otto framework
6
6
  # Provides pluggable authentication patterns that can be customized per application
7
7
 
@@ -33,7 +33,7 @@ class Otto
33
33
  user: user,
34
34
  auth_method: auth_method || self.class.name.split('::').last,
35
35
  metadata: metadata,
36
- strategy_name: nil # Will be set by RouteAuthWrapper
36
+ strategy_name: nil # Will be set by RouteAuthWrapper
37
37
  )
38
38
  end
39
39
 
@@ -320,16 +320,16 @@ class Otto
320
320
  # @return [Hash] Hash representation of the context
321
321
  def to_h
322
322
  {
323
- session: session,
324
- user: user,
325
- auth_method: auth_method,
326
- metadata: metadata,
327
- authenticated: authenticated?,
323
+ session: session,
324
+ user: user,
325
+ auth_method: auth_method,
326
+ metadata: metadata,
327
+ authenticated: authenticated?,
328
328
  auth_attempt_succeeded: auth_attempt_succeeded?,
329
- user_id: user_id,
330
- user_name: user_name,
331
- roles: roles,
332
- permissions: permissions
329
+ user_id: user_id,
330
+ user_name: user_name,
331
+ roles: roles,
332
+ permissions: permissions,
333
333
  }
334
334
  end
335
335
  end
@@ -40,7 +40,7 @@ class Otto
40
40
  # end
41
41
  # end
42
42
  #
43
- class AuthorizationError < StandardError
43
+ class AuthorizationError < Otto::ForbiddenError
44
44
  # Optional additional context for logging/debugging
45
45
  attr_reader :resource, :action, :user_id
46
46
 
@@ -436,12 +436,12 @@ class Otto
436
436
  end
437
437
 
438
438
  # Raised when a request exceeds the configured size limit
439
- class RequestTooLargeError < StandardError; end
439
+ class RequestTooLargeError < Otto::PayloadTooLargeError; end
440
440
 
441
441
  # Raised when CSRF token validation fails
442
- class CSRFError < StandardError; end
442
+ class CSRFError < Otto::ForbiddenError; end
443
443
 
444
444
  # Raised when input validation fails (XSS, SQL injection, etc.)
445
- class ValidationError < StandardError; end
445
+ class ValidationError < Otto::BadRequestError; end
446
446
  end
447
447
  end
@@ -55,9 +55,13 @@ class Otto
55
55
  'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
56
56
  }
57
57
 
58
- # Check if request expects JSON
59
- accept_header = request.env['HTTP_ACCEPT'].to_s
60
- if accept_header.include?('application/json')
58
+ # Content negotiation for rate limit response
59
+ # Route's response_type takes precedence over Accept header
60
+ route_def = request.env['otto.route_definition']
61
+ wants_json = (route_def&.response_type == 'json') ||
62
+ request.env['HTTP_ACCEPT'].to_s.include?('application/json')
63
+
64
+ if wants_json
61
65
  error_response = {
62
66
  error: 'Rate limit exceeded',
63
67
  message: 'Too many requests',
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.0.pre8'
6
+ VERSION = '2.0.0.pre9'
7
7
  end
data/lib/otto.rb CHANGED
@@ -17,6 +17,7 @@ require_relative 'otto/static'
17
17
  require_relative 'otto/helpers'
18
18
  require_relative 'otto/response_handlers'
19
19
  require_relative 'otto/route_handlers'
20
+ require_relative 'otto/errors'
20
21
  require_relative 'otto/locale'
21
22
  require_relative 'otto/mcp'
22
23
  require_relative 'otto/core'
@@ -79,9 +80,10 @@ class Otto
79
80
  load(path) unless path.nil?
80
81
  super()
81
82
 
82
- # Auto-register AuthorizationError for 403 Forbidden responses
83
- # This allows Logic classes to raise AuthorizationError for resource-level access control
84
- register_error_handler(Otto::Security::AuthorizationError, status: 403, log_level: :warn)
83
+ # Auto-register all Otto framework error classes
84
+ # This allows Logic classes and framework code to raise appropriate errors
85
+ # without requiring manual registration in implementing projects
86
+ register_framework_errors
85
87
 
86
88
  # Build the middleware app once after all initialization is complete
87
89
  build_app!
@@ -586,6 +588,48 @@ class Otto
586
588
  configure_mcp(opts)
587
589
  end
588
590
 
591
+ # Register all Otto framework error classes with appropriate status codes
592
+ #
593
+ # This method auto-registers base HTTP error classes and all framework-specific
594
+ # error classes (Security, MCP) so that raising them automatically returns the
595
+ # correct HTTP status code instead of 500.
596
+ #
597
+ # Users can override these registrations by calling register_error_handler
598
+ # after Otto.new with custom status codes or log levels.
599
+ #
600
+ # @return [void]
601
+ # @api private
602
+ def register_framework_errors
603
+ # Base HTTP errors (for direct use or subclassing by implementing projects)
604
+ register_error_from_class(Otto::NotFoundError)
605
+ register_error_from_class(Otto::BadRequestError)
606
+ register_error_from_class(Otto::UnauthorizedError)
607
+ register_error_from_class(Otto::ForbiddenError)
608
+ register_error_from_class(Otto::PayloadTooLargeError)
609
+
610
+ # Security module errors
611
+ register_error_from_class(Otto::Security::AuthorizationError)
612
+ register_error_from_class(Otto::Security::CSRFError)
613
+ register_error_from_class(Otto::Security::RequestTooLargeError)
614
+ register_error_from_class(Otto::Security::ValidationError)
615
+
616
+ # MCP module errors
617
+ register_error_from_class(Otto::MCP::ValidationError)
618
+ end
619
+
620
+ # Register an error handler using the error class as the single source of truth
621
+ #
622
+ # @param error_class [Class] Error class that responds to default_status and default_log_level
623
+ # @return [void]
624
+ # @api private
625
+ def register_error_from_class(error_class)
626
+ register_error_handler(
627
+ error_class,
628
+ status: error_class.default_status,
629
+ log_level: error_class.default_log_level
630
+ )
631
+ end
632
+
589
633
  class << self
590
634
  attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
591
635
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre8
4
+ version: 2.0.0.pre9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -175,6 +175,8 @@ files:
175
175
  - docs/ipaddr-encoding-quirk.md
176
176
  - docs/migrating/v2.0.0-pre1.md
177
177
  - docs/migrating/v2.0.0-pre2.md
178
+ - docs/modern-authentication-authorization-landscape.md
179
+ - docs/multi-strategy-authentication-design.md
178
180
  - examples/.gitignore
179
181
  - examples/advanced_routes/README.md
180
182
  - examples/advanced_routes/app.rb
@@ -243,6 +245,7 @@ files:
243
245
  - lib/otto/core/uri_generator.rb
244
246
  - lib/otto/design_system.rb
245
247
  - lib/otto/env_keys.rb
248
+ - lib/otto/errors.rb
246
249
  - lib/otto/helpers.rb
247
250
  - lib/otto/helpers/base.rb
248
251
  - lib/otto/helpers/request.rb