vector_mcp 0.2.0 → 0.3.1
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/CHANGELOG.md +281 -0
- data/README.md +302 -373
- data/lib/vector_mcp/definitions.rb +3 -1
- data/lib/vector_mcp/errors.rb +24 -0
- data/lib/vector_mcp/handlers/core.rb +132 -6
- data/lib/vector_mcp/logging/component.rb +131 -0
- data/lib/vector_mcp/logging/configuration.rb +156 -0
- data/lib/vector_mcp/logging/constants.rb +21 -0
- data/lib/vector_mcp/logging/core.rb +175 -0
- data/lib/vector_mcp/logging/filters/component.rb +69 -0
- data/lib/vector_mcp/logging/filters/level.rb +23 -0
- data/lib/vector_mcp/logging/formatters/base.rb +52 -0
- data/lib/vector_mcp/logging/formatters/json.rb +83 -0
- data/lib/vector_mcp/logging/formatters/text.rb +72 -0
- data/lib/vector_mcp/logging/outputs/base.rb +64 -0
- data/lib/vector_mcp/logging/outputs/console.rb +35 -0
- data/lib/vector_mcp/logging/outputs/file.rb +157 -0
- data/lib/vector_mcp/logging.rb +71 -0
- data/lib/vector_mcp/security/auth_manager.rb +79 -0
- data/lib/vector_mcp/security/authorization.rb +96 -0
- data/lib/vector_mcp/security/middleware.rb +172 -0
- data/lib/vector_mcp/security/session_context.rb +147 -0
- data/lib/vector_mcp/security/strategies/api_key.rb +167 -0
- data/lib/vector_mcp/security/strategies/custom.rb +71 -0
- data/lib/vector_mcp/security/strategies/jwt_token.rb +118 -0
- data/lib/vector_mcp/security.rb +46 -0
- data/lib/vector_mcp/server/registry.rb +24 -0
- data/lib/vector_mcp/server.rb +141 -1
- data/lib/vector_mcp/transport/sse/client_connection.rb +113 -0
- data/lib/vector_mcp/transport/sse/message_handler.rb +166 -0
- data/lib/vector_mcp/transport/sse/puma_config.rb +77 -0
- data/lib/vector_mcp/transport/sse/stream_manager.rb +92 -0
- data/lib/vector_mcp/transport/sse.rb +119 -460
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +35 -2
- metadata +63 -21
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "date"
|
5
|
+
|
6
|
+
module VectorMCP
|
7
|
+
module Logging
|
8
|
+
module Outputs
|
9
|
+
class File < Base
|
10
|
+
def initialize(config = {})
|
11
|
+
super
|
12
|
+
@path = @config[:path] or raise OutputError, "File path required"
|
13
|
+
@max_size = parse_size(@config[:max_size] || "100MB")
|
14
|
+
@max_files = @config[:max_files] || 7
|
15
|
+
@rotation = @config[:rotation] || "daily"
|
16
|
+
@mutex = Mutex.new
|
17
|
+
@file = nil
|
18
|
+
@current_date = nil
|
19
|
+
|
20
|
+
ensure_directory_exists
|
21
|
+
open_file
|
22
|
+
end
|
23
|
+
|
24
|
+
def close
|
25
|
+
@mutex.synchronize do
|
26
|
+
@file&.close
|
27
|
+
@file = nil
|
28
|
+
end
|
29
|
+
super
|
30
|
+
end
|
31
|
+
|
32
|
+
protected
|
33
|
+
|
34
|
+
def write_formatted(message)
|
35
|
+
@mutex.synchronize do
|
36
|
+
rotate_if_needed
|
37
|
+
@file.write(message)
|
38
|
+
@file.flush
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def ensure_directory_exists
|
45
|
+
dir = ::File.dirname(@path)
|
46
|
+
FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
|
47
|
+
rescue StandardError => e
|
48
|
+
raise OutputError, "Cannot create log directory #{dir}: #{e.message}"
|
49
|
+
end
|
50
|
+
|
51
|
+
def open_file
|
52
|
+
@file = ::File.open(current_log_path, "a")
|
53
|
+
@file.sync = true
|
54
|
+
@current_date = Date.today if daily_rotation?
|
55
|
+
rescue StandardError => e
|
56
|
+
raise OutputError, "Cannot open log file #{current_log_path}: #{e.message}"
|
57
|
+
end
|
58
|
+
|
59
|
+
def current_log_path
|
60
|
+
if daily_rotation?
|
61
|
+
base, ext = split_path(@path)
|
62
|
+
"#{base}_#{Date.today.strftime("%Y%m%d")}#{ext}"
|
63
|
+
else
|
64
|
+
@path
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def rotate_if_needed
|
69
|
+
return unless should_rotate?
|
70
|
+
|
71
|
+
rotate_file
|
72
|
+
open_file
|
73
|
+
end
|
74
|
+
|
75
|
+
def should_rotate?
|
76
|
+
return false unless @file
|
77
|
+
|
78
|
+
case @rotation
|
79
|
+
when "daily"
|
80
|
+
daily_rotation? && @current_date != Date.today
|
81
|
+
when "size"
|
82
|
+
@file.size >= @max_size
|
83
|
+
else
|
84
|
+
false
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def rotate_file
|
89
|
+
@file&.close
|
90
|
+
|
91
|
+
if daily_rotation?
|
92
|
+
cleanup_old_files
|
93
|
+
else
|
94
|
+
rotate_numbered_files
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def daily_rotation?
|
99
|
+
@rotation == "daily"
|
100
|
+
end
|
101
|
+
|
102
|
+
def rotate_numbered_files
|
103
|
+
return unless ::File.exist?(@path)
|
104
|
+
|
105
|
+
(@max_files - 1).downto(1) do |i|
|
106
|
+
old_file = "#{@path}.#{i}"
|
107
|
+
new_file = "#{@path}.#{i + 1}"
|
108
|
+
|
109
|
+
::File.rename(old_file, new_file) if ::File.exist?(old_file)
|
110
|
+
end
|
111
|
+
|
112
|
+
::File.rename(@path, "#{@path}.1")
|
113
|
+
end
|
114
|
+
|
115
|
+
def cleanup_old_files
|
116
|
+
base, ext = split_path(@path)
|
117
|
+
pattern = "#{base}_*#{ext}"
|
118
|
+
|
119
|
+
old_files = Dir.glob(pattern).reverse
|
120
|
+
files_to_remove = old_files[@max_files..] || []
|
121
|
+
|
122
|
+
files_to_remove.each do |file|
|
123
|
+
::File.unlink(file)
|
124
|
+
rescue StandardError => e
|
125
|
+
fallback_write("Warning: Could not remove old log file #{file}: #{e.message}\n")
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def split_path(path)
|
130
|
+
ext = ::File.extname(path)
|
131
|
+
base = path.chomp(ext)
|
132
|
+
[base, ext]
|
133
|
+
end
|
134
|
+
|
135
|
+
def parse_size(size_str)
|
136
|
+
size_str = size_str.to_s.upcase
|
137
|
+
|
138
|
+
raise OutputError, "Invalid size format: #{size_str}" unless size_str =~ /\A(\d+)(KB|MB|GB)?\z/
|
139
|
+
|
140
|
+
number = ::Regexp.last_match(1).to_i
|
141
|
+
unit = ::Regexp.last_match(2) || "B"
|
142
|
+
|
143
|
+
case unit
|
144
|
+
when "KB"
|
145
|
+
number * 1024
|
146
|
+
when "MB"
|
147
|
+
number * 1024 * 1024
|
148
|
+
when "GB"
|
149
|
+
number * 1024 * 1024 * 1024
|
150
|
+
else
|
151
|
+
number
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "logging/constants"
|
4
|
+
require_relative "logging/core"
|
5
|
+
require_relative "logging/configuration"
|
6
|
+
require_relative "logging/component"
|
7
|
+
require_relative "logging/formatters/base"
|
8
|
+
require_relative "logging/formatters/text"
|
9
|
+
require_relative "logging/formatters/json"
|
10
|
+
require_relative "logging/outputs/base"
|
11
|
+
require_relative "logging/outputs/console"
|
12
|
+
require_relative "logging/outputs/file"
|
13
|
+
require_relative "logging/filters/level"
|
14
|
+
require_relative "logging/filters/component"
|
15
|
+
|
16
|
+
module VectorMCP
|
17
|
+
module Logging
|
18
|
+
class Error < StandardError; end
|
19
|
+
class ConfigurationError < Error; end
|
20
|
+
class FormatterError < Error; end
|
21
|
+
class OutputError < Error; end
|
22
|
+
|
23
|
+
LEVELS = {
|
24
|
+
TRACE: 0,
|
25
|
+
DEBUG: 1,
|
26
|
+
INFO: 2,
|
27
|
+
WARN: 3,
|
28
|
+
ERROR: 4,
|
29
|
+
FATAL: 5,
|
30
|
+
SECURITY: 6
|
31
|
+
}.freeze
|
32
|
+
|
33
|
+
LEVEL_NAMES = LEVELS.invert.freeze
|
34
|
+
|
35
|
+
def self.level_name(level)
|
36
|
+
(LEVEL_NAMES[level] || "UNKNOWN").to_s
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.level_value(name)
|
40
|
+
LEVELS[name.to_s.upcase.to_sym] || LEVELS[:INFO]
|
41
|
+
end
|
42
|
+
|
43
|
+
class LogEntry
|
44
|
+
attr_reader :timestamp, :level, :component, :message, :context, :thread_id
|
45
|
+
|
46
|
+
def initialize(attributes = {})
|
47
|
+
@timestamp = attributes[:timestamp]
|
48
|
+
@level = attributes[:level]
|
49
|
+
@component = attributes[:component]
|
50
|
+
@message = attributes[:message]
|
51
|
+
@context = attributes[:context] || {}
|
52
|
+
@thread_id = attributes[:thread_id]
|
53
|
+
end
|
54
|
+
|
55
|
+
def level_name
|
56
|
+
Logging.level_name(@level)
|
57
|
+
end
|
58
|
+
|
59
|
+
def to_h
|
60
|
+
{
|
61
|
+
timestamp: @timestamp.iso8601(Constants::TIMESTAMP_PRECISION),
|
62
|
+
level: level_name,
|
63
|
+
component: @component,
|
64
|
+
message: @message,
|
65
|
+
context: @context,
|
66
|
+
thread_id: @thread_id
|
67
|
+
}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Security
|
5
|
+
# Manages authentication strategies for VectorMCP servers
|
6
|
+
# Provides opt-in authentication with zero configuration by default
|
7
|
+
class AuthManager
|
8
|
+
attr_reader :strategies, :enabled, :default_strategy
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@strategies = {}
|
12
|
+
@enabled = false
|
13
|
+
@default_strategy = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
# Enable authentication with optional default strategy
|
17
|
+
# @param default_strategy [Symbol] the default authentication strategy to use
|
18
|
+
def enable!(default_strategy: :api_key)
|
19
|
+
@enabled = true
|
20
|
+
@default_strategy = default_strategy
|
21
|
+
end
|
22
|
+
|
23
|
+
# Disable authentication (return to pass-through mode)
|
24
|
+
def disable!
|
25
|
+
@enabled = false
|
26
|
+
@default_strategy = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# Add an authentication strategy
|
30
|
+
# @param name [Symbol] the strategy name
|
31
|
+
# @param strategy [Object] the strategy instance
|
32
|
+
def add_strategy(name, strategy)
|
33
|
+
@strategies[name] = strategy
|
34
|
+
end
|
35
|
+
|
36
|
+
# Remove an authentication strategy
|
37
|
+
# @param name [Symbol] the strategy name to remove
|
38
|
+
def remove_strategy(name)
|
39
|
+
@strategies.delete(name)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Authenticate a request using the specified or default strategy
|
43
|
+
# @param request [Hash] the request object containing headers, params, etc.
|
44
|
+
# @param strategy [Symbol] optional strategy override
|
45
|
+
# @return [Object, false] authentication result or false if failed
|
46
|
+
def authenticate(request, strategy: nil)
|
47
|
+
return { authenticated: true, user: nil } unless @enabled
|
48
|
+
|
49
|
+
strategy_name = strategy || @default_strategy
|
50
|
+
auth_strategy = @strategies[strategy_name]
|
51
|
+
|
52
|
+
return { authenticated: false, error: "Unknown strategy: #{strategy_name}" } unless auth_strategy
|
53
|
+
|
54
|
+
begin
|
55
|
+
result = auth_strategy.authenticate(request)
|
56
|
+
if result
|
57
|
+
{ authenticated: true, user: result }
|
58
|
+
else
|
59
|
+
{ authenticated: false, error: "Authentication failed" }
|
60
|
+
end
|
61
|
+
rescue StandardError => e
|
62
|
+
{ authenticated: false, error: "Authentication error: #{e.message}" }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Check if authentication is required
|
67
|
+
# @return [Boolean] true if authentication is enabled
|
68
|
+
def required?
|
69
|
+
@enabled
|
70
|
+
end
|
71
|
+
|
72
|
+
# Get list of available strategies
|
73
|
+
# @return [Array<Symbol>] array of strategy names
|
74
|
+
def available_strategies
|
75
|
+
@strategies.keys
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Security
|
5
|
+
# Manages authorization policies for VectorMCP servers
|
6
|
+
# Provides fine-grained access control for tools and resources
|
7
|
+
class Authorization
|
8
|
+
attr_reader :policies, :enabled
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@policies = {}
|
12
|
+
@enabled = false
|
13
|
+
end
|
14
|
+
|
15
|
+
# Enable authorization system
|
16
|
+
def enable!
|
17
|
+
@enabled = true
|
18
|
+
end
|
19
|
+
|
20
|
+
# Disable authorization (return to pass-through mode)
|
21
|
+
def disable!
|
22
|
+
@enabled = false
|
23
|
+
end
|
24
|
+
|
25
|
+
# Add an authorization policy for a resource type
|
26
|
+
# @param resource_type [Symbol] the type of resource (e.g., :tool, :resource, :prompt)
|
27
|
+
# @param block [Proc] the policy block that receives (user, action, resource)
|
28
|
+
def add_policy(resource_type, &block)
|
29
|
+
@policies[resource_type] = block
|
30
|
+
end
|
31
|
+
|
32
|
+
# Remove an authorization policy
|
33
|
+
# @param resource_type [Symbol] the resource type to remove policy for
|
34
|
+
def remove_policy(resource_type)
|
35
|
+
@policies.delete(resource_type)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Check if a user is authorized to perform an action on a resource
|
39
|
+
# @param user [Object] the authenticated user object
|
40
|
+
# @param action [Symbol] the action being attempted (e.g., :call, :read, :list)
|
41
|
+
# @param resource [Object] the resource being accessed
|
42
|
+
# @return [Boolean] true if authorized, false otherwise
|
43
|
+
def authorize(user, action, resource)
|
44
|
+
return true unless @enabled
|
45
|
+
|
46
|
+
resource_type = determine_resource_type(resource)
|
47
|
+
policy = @policies[resource_type]
|
48
|
+
|
49
|
+
# If no policy is defined, allow access (opt-in authorization)
|
50
|
+
return true unless policy
|
51
|
+
|
52
|
+
begin
|
53
|
+
policy_result = policy.call(user, action, resource)
|
54
|
+
policy_result ? true : false
|
55
|
+
rescue StandardError
|
56
|
+
# Log error but deny access for safety
|
57
|
+
false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Check if authorization is required
|
62
|
+
# @return [Boolean] true if authorization is enabled
|
63
|
+
def required?
|
64
|
+
@enabled
|
65
|
+
end
|
66
|
+
|
67
|
+
# Get list of resource types with policies
|
68
|
+
# @return [Array<Symbol>] array of resource types
|
69
|
+
def policy_types
|
70
|
+
@policies.keys
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# Determine the resource type from the resource object
|
76
|
+
# @param resource [Object] the resource object
|
77
|
+
# @return [Symbol] the resource type
|
78
|
+
def determine_resource_type(resource)
|
79
|
+
case resource
|
80
|
+
when VectorMCP::Definitions::Tool
|
81
|
+
:tool
|
82
|
+
when VectorMCP::Definitions::Resource
|
83
|
+
:resource
|
84
|
+
when VectorMCP::Definitions::Prompt
|
85
|
+
:prompt
|
86
|
+
when VectorMCP::Definitions::Root
|
87
|
+
:root
|
88
|
+
else
|
89
|
+
# Try to infer from class name
|
90
|
+
class_name = resource.class.name.split("::").last&.downcase
|
91
|
+
class_name&.to_sym || :unknown
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Security
|
5
|
+
# Security middleware for request authentication and authorization
|
6
|
+
# Integrates with transport layers to provide security controls
|
7
|
+
class Middleware
|
8
|
+
attr_reader :auth_manager, :authorization
|
9
|
+
|
10
|
+
# Initialize middleware with auth components
|
11
|
+
# @param auth_manager [AuthManager] the authentication manager
|
12
|
+
# @param authorization [Authorization] the authorization manager
|
13
|
+
def initialize(auth_manager, authorization)
|
14
|
+
@auth_manager = auth_manager
|
15
|
+
@authorization = authorization
|
16
|
+
end
|
17
|
+
|
18
|
+
# Authenticate a request and return session context
|
19
|
+
# @param request [Hash] the request object
|
20
|
+
# @param strategy [Symbol] optional authentication strategy override
|
21
|
+
# @return [SessionContext] the session context for the request
|
22
|
+
def authenticate_request(request, strategy: nil)
|
23
|
+
auth_result = @auth_manager.authenticate(request, strategy: strategy)
|
24
|
+
SessionContext.from_auth_result(auth_result)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Check if a session is authorized for an action on a resource
|
28
|
+
# @param session_context [SessionContext] the session context
|
29
|
+
# @param action [Symbol] the action being attempted
|
30
|
+
# @param resource [Object] the resource being accessed
|
31
|
+
# @return [Boolean] true if authorized
|
32
|
+
def authorize_action(session_context, action, resource)
|
33
|
+
# Always allow if authorization is disabled
|
34
|
+
return true unless @authorization.required?
|
35
|
+
|
36
|
+
# Check authorization policy
|
37
|
+
@authorization.authorize(session_context.user, action, resource)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Process a request through the complete security pipeline
|
41
|
+
# @param request [Hash] the request object
|
42
|
+
# @param action [Symbol] the action being attempted
|
43
|
+
# @param resource [Object] the resource being accessed
|
44
|
+
# @return [Hash] result with session_context and authorization status
|
45
|
+
def process_request(request, action: :access, resource: nil)
|
46
|
+
# Step 1: Authenticate the request
|
47
|
+
session_context = authenticate_request(request)
|
48
|
+
|
49
|
+
# Step 2: Check if authentication is required but failed
|
50
|
+
if @auth_manager.required? && !session_context.authenticated?
|
51
|
+
return {
|
52
|
+
success: false,
|
53
|
+
error: "Authentication required",
|
54
|
+
error_code: "AUTHENTICATION_REQUIRED",
|
55
|
+
session_context: session_context
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
# Step 3: Check authorization if resource is provided
|
60
|
+
if resource && !authorize_action(session_context, action, resource)
|
61
|
+
return {
|
62
|
+
success: false,
|
63
|
+
error: "Access denied",
|
64
|
+
error_code: "AUTHORIZATION_FAILED",
|
65
|
+
session_context: session_context
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
# Step 4: Success
|
70
|
+
{
|
71
|
+
success: true,
|
72
|
+
session_context: session_context
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
# Create a request object from different transport formats
|
77
|
+
# @param transport_request [Object] the transport-specific request
|
78
|
+
# @return [Hash] normalized request object
|
79
|
+
def normalize_request(transport_request)
|
80
|
+
case transport_request
|
81
|
+
when Hash
|
82
|
+
# Check if it's a Rack environment (has REQUEST_METHOD key)
|
83
|
+
if transport_request.key?("REQUEST_METHOD")
|
84
|
+
extract_from_rack_env(transport_request)
|
85
|
+
else
|
86
|
+
# Already normalized
|
87
|
+
transport_request
|
88
|
+
end
|
89
|
+
else
|
90
|
+
# Extract from transport-specific request (e.g., custom objects)
|
91
|
+
extract_request_data(transport_request)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Check if security is enabled
|
96
|
+
# @return [Boolean] true if any security features are enabled
|
97
|
+
def security_enabled?
|
98
|
+
@auth_manager.required? || @authorization.required?
|
99
|
+
end
|
100
|
+
|
101
|
+
# Get security status for debugging/monitoring
|
102
|
+
# @return [Hash] current security configuration status
|
103
|
+
def security_status
|
104
|
+
{
|
105
|
+
authentication: {
|
106
|
+
enabled: @auth_manager.required?,
|
107
|
+
strategies: @auth_manager.available_strategies,
|
108
|
+
default_strategy: @auth_manager.default_strategy
|
109
|
+
},
|
110
|
+
authorization: {
|
111
|
+
enabled: @authorization.required?,
|
112
|
+
policy_types: @authorization.policy_types
|
113
|
+
}
|
114
|
+
}
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
# Extract request data from transport-specific formats
|
120
|
+
# @param transport_request [Object] the transport request
|
121
|
+
# @return [Hash] extracted request data
|
122
|
+
def extract_request_data(transport_request)
|
123
|
+
# Handle Rack environment (for SSE transport)
|
124
|
+
if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
|
125
|
+
extract_from_rack_env(transport_request)
|
126
|
+
else
|
127
|
+
# Default fallback
|
128
|
+
{ headers: {}, params: {} }
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Extract data from Rack environment
|
133
|
+
# @param env [Hash] the Rack environment
|
134
|
+
# @return [Hash] extracted request data
|
135
|
+
def extract_from_rack_env(env)
|
136
|
+
# Extract headers (HTTP_ prefixed in Rack env)
|
137
|
+
headers = {}
|
138
|
+
env.each do |key, value|
|
139
|
+
next unless key.start_with?("HTTP_")
|
140
|
+
|
141
|
+
# Convert HTTP_X_API_KEY to X-API-Key format
|
142
|
+
header_name = key[5..].split("_").map do |part|
|
143
|
+
case part.upcase
|
144
|
+
when "API" then "API" # Keep API in all caps
|
145
|
+
else part.capitalize
|
146
|
+
end
|
147
|
+
end.join("-")
|
148
|
+
headers[header_name] = value
|
149
|
+
end
|
150
|
+
|
151
|
+
# Add special headers
|
152
|
+
headers["Authorization"] = env["HTTP_AUTHORIZATION"] if env["HTTP_AUTHORIZATION"]
|
153
|
+
headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
|
154
|
+
|
155
|
+
# Extract query parameters
|
156
|
+
params = {}
|
157
|
+
if env["QUERY_STRING"]
|
158
|
+
require "uri"
|
159
|
+
params = URI.decode_www_form(env["QUERY_STRING"]).to_h
|
160
|
+
end
|
161
|
+
|
162
|
+
{
|
163
|
+
headers: headers,
|
164
|
+
params: params,
|
165
|
+
method: env["REQUEST_METHOD"],
|
166
|
+
path: env["PATH_INFO"],
|
167
|
+
rack_env: env
|
168
|
+
}
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|