rack-ai 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +55 -0
- data/CHANGELOG.md +65 -0
- data/LICENSE +21 -0
- data/README.md +687 -0
- data/ROADMAP.md +203 -0
- data/Rakefile +40 -0
- data/benchmarks/performance_benchmark.rb +283 -0
- data/examples/rails_integration.rb +301 -0
- data/examples/sinatra_integration.rb +458 -0
- data/lib/rack/ai/configuration.rb +208 -0
- data/lib/rack/ai/features/caching.rb +278 -0
- data/lib/rack/ai/features/classification.rb +67 -0
- data/lib/rack/ai/features/enhancement.rb +219 -0
- data/lib/rack/ai/features/logging.rb +238 -0
- data/lib/rack/ai/features/moderation.rb +104 -0
- data/lib/rack/ai/features/routing.rb +143 -0
- data/lib/rack/ai/features/security.rb +275 -0
- data/lib/rack/ai/middleware.rb +268 -0
- data/lib/rack/ai/providers/base.rb +107 -0
- data/lib/rack/ai/providers/huggingface.rb +259 -0
- data/lib/rack/ai/providers/local.rb +152 -0
- data/lib/rack/ai/providers/openai.rb +246 -0
- data/lib/rack/ai/utils/logger.rb +111 -0
- data/lib/rack/ai/utils/metrics.rb +220 -0
- data/lib/rack/ai/utils/sanitizer.rb +200 -0
- data/lib/rack/ai/version.rb +7 -0
- data/lib/rack/ai.rb +48 -0
- data/rack-ai.gemspec +51 -0
- metadata +290 -0
@@ -0,0 +1,275 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
module AI
|
5
|
+
module Features
|
6
|
+
class Security
|
7
|
+
attr_reader :name, :provider, :config
|
8
|
+
|
9
|
+
def initialize(provider, config)
|
10
|
+
@name = :security
|
11
|
+
@provider = provider
|
12
|
+
@config = config
|
13
|
+
end
|
14
|
+
|
15
|
+
def enabled?
|
16
|
+
@config.feature_enabled?(:security)
|
17
|
+
end
|
18
|
+
|
19
|
+
def process_response?
|
20
|
+
false
|
21
|
+
end
|
22
|
+
|
23
|
+
def process_request(env)
|
24
|
+
request_data = @provider.build_request_data(env)
|
25
|
+
|
26
|
+
# Detect various security threats
|
27
|
+
anomaly_result = @provider.detect_anomalies(request_data)
|
28
|
+
injection_result = detect_injection_attacks(env)
|
29
|
+
rate_limit_result = check_rate_limiting(env)
|
30
|
+
|
31
|
+
# Combine all security checks
|
32
|
+
threat_level = determine_overall_threat_level([
|
33
|
+
anomaly_result[:threat_level],
|
34
|
+
injection_result[:threat_level],
|
35
|
+
rate_limit_result[:threat_level]
|
36
|
+
])
|
37
|
+
|
38
|
+
result = {
|
39
|
+
threat_level: threat_level,
|
40
|
+
anomaly_detection: anomaly_result,
|
41
|
+
injection_detection: injection_result,
|
42
|
+
rate_limiting: rate_limit_result,
|
43
|
+
action: determine_security_action(threat_level),
|
44
|
+
feature: @name,
|
45
|
+
timestamp: Time.now.iso8601
|
46
|
+
}
|
47
|
+
|
48
|
+
# Add detailed security metadata
|
49
|
+
result[:security_headers] = analyze_security_headers(env)
|
50
|
+
result[:suspicious_patterns] = identify_suspicious_patterns(env)
|
51
|
+
|
52
|
+
result
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def detect_injection_attacks(env)
|
58
|
+
threats = []
|
59
|
+
threat_level = :low
|
60
|
+
|
61
|
+
# SQL Injection detection
|
62
|
+
sql_patterns = [
|
63
|
+
/union\s+select/i,
|
64
|
+
/drop\s+table/i,
|
65
|
+
/insert\s+into/i,
|
66
|
+
/delete\s+from/i,
|
67
|
+
/'\s*or\s*'1'\s*=\s*'1/i,
|
68
|
+
/'\s*;\s*drop/i
|
69
|
+
]
|
70
|
+
|
71
|
+
# XSS detection
|
72
|
+
xss_patterns = [
|
73
|
+
/<script[^>]*>/i,
|
74
|
+
/javascript:/i,
|
75
|
+
/on\w+\s*=/i,
|
76
|
+
/<iframe[^>]*>/i,
|
77
|
+
/eval\s*\(/i
|
78
|
+
]
|
79
|
+
|
80
|
+
# Command injection detection
|
81
|
+
cmd_patterns = [
|
82
|
+
/;\s*cat\s+/i,
|
83
|
+
/;\s*ls\s+/i,
|
84
|
+
/;\s*rm\s+/i,
|
85
|
+
/\|\s*nc\s+/i,
|
86
|
+
/&&\s*curl/i
|
87
|
+
]
|
88
|
+
|
89
|
+
# Check query string and body
|
90
|
+
content_to_check = []
|
91
|
+
content_to_check << env["QUERY_STRING"] if env["QUERY_STRING"]
|
92
|
+
|
93
|
+
if %w[POST PUT PATCH].include?(env["REQUEST_METHOD"])
|
94
|
+
input = env["rack.input"]
|
95
|
+
if input && input.respond_to?(:read)
|
96
|
+
body_content = input.read
|
97
|
+
input.rewind if input.respond_to?(:rewind)
|
98
|
+
content_to_check << body_content
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
content_to_check.each do |content|
|
103
|
+
next if content.nil? || content.empty?
|
104
|
+
|
105
|
+
if sql_patterns.any? { |pattern| content.match?(pattern) }
|
106
|
+
threats << "sql_injection"
|
107
|
+
threat_level = :high
|
108
|
+
end
|
109
|
+
|
110
|
+
if xss_patterns.any? { |pattern| content.match?(pattern) }
|
111
|
+
threats << "xss_attempt"
|
112
|
+
threat_level = [:high, threat_level].max
|
113
|
+
end
|
114
|
+
|
115
|
+
if cmd_patterns.any? { |pattern| content.match?(pattern) }
|
116
|
+
threats << "command_injection"
|
117
|
+
threat_level = :high
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Check for prompt injection if this looks like an LLM endpoint
|
122
|
+
if llm_endpoint?(env) && content_to_check.any? { |c| prompt_injection?(c) }
|
123
|
+
threats << "prompt_injection"
|
124
|
+
threat_level = [:medium, threat_level].max
|
125
|
+
end
|
126
|
+
|
127
|
+
{
|
128
|
+
threats: threats,
|
129
|
+
threat_level: threat_level,
|
130
|
+
patterns_detected: threats.size
|
131
|
+
}
|
132
|
+
end
|
133
|
+
|
134
|
+
def check_rate_limiting(env)
|
135
|
+
client_ip = env["REMOTE_ADDR"]
|
136
|
+
user_agent = env["HTTP_USER_AGENT"]
|
137
|
+
|
138
|
+
# Simple in-memory rate limiting (in production, use Redis)
|
139
|
+
@rate_limiter ||= {}
|
140
|
+
current_time = Time.now.to_i
|
141
|
+
window_size = 60 # 1 minute window
|
142
|
+
|
143
|
+
# Clean old entries
|
144
|
+
@rate_limiter.delete_if { |key, data| current_time - data[:window_start] > window_size }
|
145
|
+
|
146
|
+
# Check rate for IP
|
147
|
+
ip_key = "ip:#{client_ip}"
|
148
|
+
ip_data = @rate_limiter[ip_key] ||= { count: 0, window_start: current_time }
|
149
|
+
|
150
|
+
if current_time - ip_data[:window_start] > window_size
|
151
|
+
ip_data[:count] = 0
|
152
|
+
ip_data[:window_start] = current_time
|
153
|
+
end
|
154
|
+
|
155
|
+
ip_data[:count] += 1
|
156
|
+
|
157
|
+
# Determine threat level based on request rate
|
158
|
+
rate_limit = @config.rate_limit
|
159
|
+
threat_level = if ip_data[:count] > rate_limit * 2
|
160
|
+
:high
|
161
|
+
elsif ip_data[:count] > rate_limit
|
162
|
+
:medium
|
163
|
+
else
|
164
|
+
:low
|
165
|
+
end
|
166
|
+
|
167
|
+
{
|
168
|
+
client_ip: client_ip,
|
169
|
+
request_count: ip_data[:count],
|
170
|
+
window_start: ip_data[:window_start],
|
171
|
+
threat_level: threat_level,
|
172
|
+
rate_exceeded: ip_data[:count] > rate_limit
|
173
|
+
}
|
174
|
+
end
|
175
|
+
|
176
|
+
def determine_overall_threat_level(threat_levels)
|
177
|
+
return :high if threat_levels.include?(:high)
|
178
|
+
return :medium if threat_levels.include?(:medium)
|
179
|
+
:low
|
180
|
+
end
|
181
|
+
|
182
|
+
def determine_security_action(threat_level)
|
183
|
+
case threat_level
|
184
|
+
when :high
|
185
|
+
:block
|
186
|
+
when :medium
|
187
|
+
:flag
|
188
|
+
else
|
189
|
+
:allow
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def analyze_security_headers(env)
|
194
|
+
headers = {}
|
195
|
+
|
196
|
+
# Check for security-related headers
|
197
|
+
security_headers = %w[
|
198
|
+
HTTP_X_FORWARDED_FOR
|
199
|
+
HTTP_X_REAL_IP
|
200
|
+
HTTP_X_FORWARDED_PROTO
|
201
|
+
HTTP_AUTHORIZATION
|
202
|
+
HTTP_X_API_KEY
|
203
|
+
]
|
204
|
+
|
205
|
+
security_headers.each do |header|
|
206
|
+
headers[header.downcase] = env[header] if env[header]
|
207
|
+
end
|
208
|
+
|
209
|
+
{
|
210
|
+
present_headers: headers.keys,
|
211
|
+
forwarded_for: env["HTTP_X_FORWARDED_FOR"],
|
212
|
+
real_ip: env["HTTP_X_REAL_IP"],
|
213
|
+
has_auth: !!(env["HTTP_AUTHORIZATION"] || env["HTTP_X_API_KEY"])
|
214
|
+
}
|
215
|
+
end
|
216
|
+
|
217
|
+
def identify_suspicious_patterns(env)
|
218
|
+
patterns = []
|
219
|
+
|
220
|
+
# Check for suspicious user agents
|
221
|
+
user_agent = env["HTTP_USER_AGENT"] || ""
|
222
|
+
if user_agent.empty?
|
223
|
+
patterns << "missing_user_agent"
|
224
|
+
elsif user_agent.match?(/curl|wget|python|bot|crawler/i)
|
225
|
+
patterns << "automated_client"
|
226
|
+
end
|
227
|
+
|
228
|
+
# Check for suspicious paths
|
229
|
+
path = env["PATH_INFO"] || ""
|
230
|
+
if path.match?(/\.\.|\/etc\/|\/proc\/|\/var\//)
|
231
|
+
patterns << "path_traversal_attempt"
|
232
|
+
end
|
233
|
+
|
234
|
+
if path.match?(/admin|config|backup|\.env|\.git/)
|
235
|
+
patterns << "sensitive_path_access"
|
236
|
+
end
|
237
|
+
|
238
|
+
# Check for unusual request methods
|
239
|
+
method = env["REQUEST_METHOD"]
|
240
|
+
if %w[TRACE CONNECT].include?(method)
|
241
|
+
patterns << "unusual_http_method"
|
242
|
+
end
|
243
|
+
|
244
|
+
# Check for missing referer on POST requests
|
245
|
+
if method == "POST" && !env["HTTP_REFERER"]
|
246
|
+
patterns << "missing_referer_on_post"
|
247
|
+
end
|
248
|
+
|
249
|
+
patterns
|
250
|
+
end
|
251
|
+
|
252
|
+
def llm_endpoint?(env)
|
253
|
+
path = env["PATH_INFO"] || ""
|
254
|
+
path.match?(/\/api\/.*(?:chat|completion|generate|prompt)/i)
|
255
|
+
end
|
256
|
+
|
257
|
+
def prompt_injection?(content)
|
258
|
+
# Common prompt injection patterns
|
259
|
+
injection_patterns = [
|
260
|
+
/ignore\s+previous\s+instructions/i,
|
261
|
+
/forget\s+everything/i,
|
262
|
+
/you\s+are\s+now/i,
|
263
|
+
/system\s*:\s*you/i,
|
264
|
+
/\[INST\]/i,
|
265
|
+
/\<\|system\|\>/i,
|
266
|
+
/act\s+as\s+if/i,
|
267
|
+
/pretend\s+to\s+be/i
|
268
|
+
]
|
269
|
+
|
270
|
+
injection_patterns.any? { |pattern| content.match?(pattern) }
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
@@ -0,0 +1,268 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent"
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
module AI
|
7
|
+
class Middleware
|
8
|
+
attr_reader :app, :config, :provider, :features
|
9
|
+
|
10
|
+
def initialize(app, **options)
|
11
|
+
@app = app
|
12
|
+
@config = build_config(options)
|
13
|
+
@provider = build_provider
|
14
|
+
@features = build_features
|
15
|
+
@thread_pool = Concurrent::ThreadPoolExecutor.new(
|
16
|
+
min_threads: 2,
|
17
|
+
max_threads: 10,
|
18
|
+
max_queue: 100
|
19
|
+
)
|
20
|
+
|
21
|
+
validate_configuration!
|
22
|
+
end
|
23
|
+
|
24
|
+
def call(env)
|
25
|
+
# Initialize AI context in env
|
26
|
+
env["rack.ai"] = {
|
27
|
+
results: {},
|
28
|
+
metadata: {},
|
29
|
+
start_time: Time.now
|
30
|
+
}
|
31
|
+
|
32
|
+
# Process request through AI features
|
33
|
+
blocked_response = process_request(env) if should_process_request?(env)
|
34
|
+
|
35
|
+
# Return blocked response if request should be blocked
|
36
|
+
return blocked_response if blocked_response
|
37
|
+
|
38
|
+
# Call the next middleware/app
|
39
|
+
status, headers, body = @app.call(env)
|
40
|
+
|
41
|
+
# Process response through AI features
|
42
|
+
process_response(env, status, headers, body) if should_process_response?(env)
|
43
|
+
|
44
|
+
# Add AI headers if configured
|
45
|
+
add_ai_headers(headers, env) if @config.explain_decisions
|
46
|
+
|
47
|
+
[status, headers, body]
|
48
|
+
rescue => e
|
49
|
+
handle_error(e, env)
|
50
|
+
# Fail-safe: continue with original request if AI processing fails
|
51
|
+
@config.fail_safe ? @app.call(env) : raise
|
52
|
+
ensure
|
53
|
+
# Record metrics
|
54
|
+
record_metrics(env) if @config.metrics_enabled
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def build_config(options)
|
60
|
+
config = Configuration.new
|
61
|
+
options.each { |key, value| config.public_send("#{key}=", value) if config.respond_to?("#{key}=") }
|
62
|
+
config
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_provider
|
66
|
+
case @config.provider
|
67
|
+
when :openai
|
68
|
+
Providers::OpenAI.new(@config.provider_config)
|
69
|
+
when :huggingface
|
70
|
+
Providers::HuggingFace.new(@config.provider_config)
|
71
|
+
when :local
|
72
|
+
Providers::Local.new(@config.provider_config)
|
73
|
+
else
|
74
|
+
raise ConfigurationError, "Unknown provider: #{@config.provider}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def build_features
|
79
|
+
feature_map = {
|
80
|
+
classification: Features::Classification,
|
81
|
+
moderation: Features::Moderation,
|
82
|
+
caching: Features::Caching,
|
83
|
+
routing: Features::Routing,
|
84
|
+
logging: Features::Logging,
|
85
|
+
enhancement: Features::Enhancement,
|
86
|
+
security: Features::Security
|
87
|
+
}
|
88
|
+
|
89
|
+
features = @config.features.map do |feature_name|
|
90
|
+
feature_class = feature_map[feature_name.to_sym]
|
91
|
+
raise ConfigurationError, "Unknown feature: #{feature_name}" unless feature_class
|
92
|
+
|
93
|
+
feature_class.new(@provider, @config)
|
94
|
+
end
|
95
|
+
|
96
|
+
Utils::Logger.debug("Built features: #{features.map(&:name)}")
|
97
|
+
features
|
98
|
+
end
|
99
|
+
|
100
|
+
def validate_configuration!
|
101
|
+
@config.validate!
|
102
|
+
@provider.validate! if @provider.respond_to?(:validate!)
|
103
|
+
end
|
104
|
+
|
105
|
+
def should_process_request?(env)
|
106
|
+
# Skip processing for certain paths or conditions
|
107
|
+
return false if env["PATH_INFO"] == "/health"
|
108
|
+
return false if env["REQUEST_METHOD"] == "OPTIONS"
|
109
|
+
true
|
110
|
+
end
|
111
|
+
|
112
|
+
def should_process_response?(env)
|
113
|
+
# Only process responses if features require it
|
114
|
+
@features.any? { |feature| feature.process_response? }
|
115
|
+
end
|
116
|
+
|
117
|
+
def process_request(env)
|
118
|
+
if @config.async_processing
|
119
|
+
process_request_async(env)
|
120
|
+
else
|
121
|
+
process_request_sync(env)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def process_request_sync(env)
|
126
|
+
blocked_response = nil
|
127
|
+
|
128
|
+
@features.each do |feature|
|
129
|
+
next unless feature.enabled?
|
130
|
+
|
131
|
+
begin
|
132
|
+
result = feature.process_request(env)
|
133
|
+
env["rack.ai"][:results][feature.name] = result
|
134
|
+
|
135
|
+
# Handle blocking decisions (e.g., block malicious requests)
|
136
|
+
if result[:action] == :block
|
137
|
+
blocked_response = build_blocked_response(result)
|
138
|
+
break
|
139
|
+
end
|
140
|
+
rescue => e
|
141
|
+
handle_feature_error(feature, e, env)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
blocked_response
|
146
|
+
end
|
147
|
+
|
148
|
+
def process_request_async(env)
|
149
|
+
blocked_response = nil
|
150
|
+
futures = @features.map do |feature|
|
151
|
+
next unless feature.enabled?
|
152
|
+
|
153
|
+
Concurrent::Future.execute(executor: @thread_pool) do
|
154
|
+
begin
|
155
|
+
feature.process_request(env)
|
156
|
+
rescue => e
|
157
|
+
{ error: e.message, feature: feature.name }
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end.compact
|
161
|
+
|
162
|
+
# Wait for critical features (security, moderation)
|
163
|
+
critical_features = [:security, :moderation]
|
164
|
+
futures.each_with_index do |future, index|
|
165
|
+
feature = @features[index]
|
166
|
+
next unless critical_features.include?(feature.name)
|
167
|
+
|
168
|
+
result = future.value(@config.timeout)
|
169
|
+
env["rack.ai"][:results][feature.name] = result
|
170
|
+
|
171
|
+
if result[:action] == :block
|
172
|
+
blocked_response = build_blocked_response(result)
|
173
|
+
break
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# Collect remaining results asynchronously
|
178
|
+
Concurrent::Future.execute(executor: @thread_pool) do
|
179
|
+
futures.each_with_index do |future, index|
|
180
|
+
feature = @features[index]
|
181
|
+
next if critical_features.include?(feature.name)
|
182
|
+
|
183
|
+
result = future.value
|
184
|
+
env["rack.ai"][:results][feature.name] = result
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
blocked_response
|
189
|
+
end
|
190
|
+
|
191
|
+
def process_response(env, status, headers, body)
|
192
|
+
@features.each do |feature|
|
193
|
+
next unless feature.enabled? && feature.process_response?
|
194
|
+
|
195
|
+
begin
|
196
|
+
result = feature.process_response(env, status, headers, body)
|
197
|
+
env["rack.ai"][:results]["#{feature.name}_response"] = result
|
198
|
+
rescue => e
|
199
|
+
handle_feature_error(feature, e, env)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def build_blocked_response(result)
|
205
|
+
status = result[:status] || 403
|
206
|
+
headers = { "Content-Type" => "application/json" }
|
207
|
+
body = [{
|
208
|
+
error: "Request blocked by AI security",
|
209
|
+
reason: result[:reason],
|
210
|
+
request_id: result[:request_id]
|
211
|
+
}.to_json]
|
212
|
+
|
213
|
+
[status, headers, body]
|
214
|
+
end
|
215
|
+
|
216
|
+
def add_ai_headers(headers, env)
|
217
|
+
ai_results = env["rack.ai"][:results]
|
218
|
+
headers["X-AI-Processed"] = "true"
|
219
|
+
headers["X-AI-Features"] = @config.features.join(",")
|
220
|
+
headers["X-AI-Processing-Time"] = "#{((Time.now - env["rack.ai"][:start_time]) * 1000).round(2)}ms"
|
221
|
+
|
222
|
+
if @config.explain_decisions
|
223
|
+
explanations = ai_results.map { |feature, result| "#{feature}:#{result[:confidence] || 'N/A'}" }
|
224
|
+
headers["X-AI-Decisions"] = explanations.join(";")
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def handle_error(error, env)
|
229
|
+
Utils::Logger.error("Rack::AI middleware error", {
|
230
|
+
error: error.message,
|
231
|
+
backtrace: error.backtrace&.first(5),
|
232
|
+
request_path: env["PATH_INFO"],
|
233
|
+
request_method: env["REQUEST_METHOD"]
|
234
|
+
})
|
235
|
+
end
|
236
|
+
|
237
|
+
def handle_feature_error(feature, error, env)
|
238
|
+
Utils::Logger.warn("Feature #{feature.name} error", {
|
239
|
+
error: error.message,
|
240
|
+
feature: feature.name,
|
241
|
+
request_path: env["PATH_INFO"]
|
242
|
+
})
|
243
|
+
|
244
|
+
env["rack.ai"][:results][feature.name] = {
|
245
|
+
error: error.message,
|
246
|
+
processed: false
|
247
|
+
}
|
248
|
+
|
249
|
+
# Re-raise error if fail_safe is disabled
|
250
|
+
raise error unless @config.fail_safe
|
251
|
+
end
|
252
|
+
|
253
|
+
def record_metrics(env)
|
254
|
+
processing_time = Time.now - env["rack.ai"][:start_time]
|
255
|
+
Utils::Metrics.record("rack_ai.processing_time", processing_time)
|
256
|
+
Utils::Metrics.increment("rack_ai.requests_processed")
|
257
|
+
|
258
|
+
@features.each do |feature|
|
259
|
+
result = env["rack.ai"][:results][feature.name]
|
260
|
+
next unless result && !result[:error]
|
261
|
+
|
262
|
+
Utils::Metrics.increment("rack_ai.feature.#{feature.name}.processed")
|
263
|
+
Utils::Metrics.record("rack_ai.feature.#{feature.name}.confidence", result[:confidence]) if result[:confidence]
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
module AI
|
5
|
+
module Providers
|
6
|
+
class Base
|
7
|
+
attr_reader :config
|
8
|
+
|
9
|
+
def initialize(config)
|
10
|
+
@config = config
|
11
|
+
end
|
12
|
+
|
13
|
+
# Abstract methods that must be implemented by subclasses
|
14
|
+
def classify_request(request_data)
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #classify_request"
|
16
|
+
end
|
17
|
+
|
18
|
+
def moderate_content(content, options = {})
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #moderate_content"
|
20
|
+
end
|
21
|
+
|
22
|
+
def analyze_patterns(data)
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #analyze_patterns"
|
24
|
+
end
|
25
|
+
|
26
|
+
def detect_anomalies(request_data)
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #detect_anomalies"
|
28
|
+
end
|
29
|
+
|
30
|
+
def enhance_content(content, enhancement_type)
|
31
|
+
raise NotImplementedError, "#{self.class} must implement #enhance_content"
|
32
|
+
end
|
33
|
+
|
34
|
+
# Common validation method
|
35
|
+
def validate!
|
36
|
+
raise ConfigurationError, "API key is required" if requires_api_key? && !@config[:api_key]
|
37
|
+
raise ConfigurationError, "API URL is required" if requires_api_url? && !@config[:api_url]
|
38
|
+
end
|
39
|
+
|
40
|
+
# Health check
|
41
|
+
def healthy?
|
42
|
+
ping
|
43
|
+
rescue
|
44
|
+
false
|
45
|
+
end
|
46
|
+
|
47
|
+
def ping
|
48
|
+
raise NotImplementedError, "#{self.class} must implement #ping"
|
49
|
+
end
|
50
|
+
|
51
|
+
def build_request_data(env)
|
52
|
+
{
|
53
|
+
method: env["REQUEST_METHOD"],
|
54
|
+
path: env["PATH_INFO"],
|
55
|
+
query_string: env["QUERY_STRING"],
|
56
|
+
user_agent: env["HTTP_USER_AGENT"],
|
57
|
+
remote_ip: env["REMOTE_ADDR"],
|
58
|
+
headers: extract_safe_headers(env),
|
59
|
+
timestamp: Time.now.iso8601
|
60
|
+
}
|
61
|
+
end
|
62
|
+
|
63
|
+
protected
|
64
|
+
|
65
|
+
def requires_api_key?
|
66
|
+
true
|
67
|
+
end
|
68
|
+
|
69
|
+
def requires_api_url?
|
70
|
+
false
|
71
|
+
end
|
72
|
+
|
73
|
+
def extract_safe_headers(env)
|
74
|
+
safe_headers = {}
|
75
|
+
env.each do |key, value|
|
76
|
+
next unless key.start_with?("HTTP_")
|
77
|
+
|
78
|
+
header_name = key.sub("HTTP_", "").downcase
|
79
|
+
next if sensitive_header?(header_name)
|
80
|
+
|
81
|
+
safe_headers[header_name] = value
|
82
|
+
end
|
83
|
+
safe_headers
|
84
|
+
end
|
85
|
+
|
86
|
+
def sensitive_header?(header_name)
|
87
|
+
%w[authorization cookie session_id x_api_key].include?(header_name)
|
88
|
+
end
|
89
|
+
|
90
|
+
def handle_api_error(response)
|
91
|
+
case response.status
|
92
|
+
when 401
|
93
|
+
raise ProviderError, "Authentication failed - check API key"
|
94
|
+
when 403
|
95
|
+
raise ProviderError, "Access forbidden - insufficient permissions"
|
96
|
+
when 429
|
97
|
+
raise ProviderError, "Rate limit exceeded - please retry later"
|
98
|
+
when 500..599
|
99
|
+
raise ProviderError, "Provider service error - #{response.status}"
|
100
|
+
else
|
101
|
+
raise ProviderError, "Unexpected response - #{response.status}: #{response.body}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|