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.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+
6
+ module Rack
7
+ module AI
8
+ module Utils
9
+ class Logger
10
+ class << self
11
+ def logger
12
+ @logger ||= build_logger
13
+ end
14
+
15
+ def debug(message, metadata = {})
16
+ log(:debug, message, metadata)
17
+ end
18
+
19
+ def info(message, metadata = {})
20
+ log(:info, message, metadata)
21
+ end
22
+
23
+ def warn(message, metadata = {})
24
+ log(:warn, message, metadata)
25
+ end
26
+
27
+ def error(message, metadata = {})
28
+ log(:error, message, metadata)
29
+ end
30
+
31
+ def fatal(message, metadata = {})
32
+ log(:fatal, message, metadata)
33
+ end
34
+
35
+ private
36
+
37
+ def log(level, message, metadata)
38
+ log_entry = {
39
+ timestamp: Time.now.iso8601,
40
+ level: level.to_s.upcase,
41
+ message: message,
42
+ component: "rack-ai",
43
+ metadata: sanitize_metadata(metadata)
44
+ }
45
+
46
+ logger.public_send(level, log_entry.to_json)
47
+ end
48
+
49
+ def build_logger
50
+ logger = ::Logger.new($stdout)
51
+ logger.level = log_level_from_env
52
+ logger.formatter = proc do |severity, datetime, progname, msg|
53
+ "#{datetime.iso8601} [#{severity}] #{progname}: #{msg}\n"
54
+ end
55
+ logger
56
+ end
57
+
58
+ def log_level_from_env
59
+ case ENV["RACK_AI_LOG_LEVEL"]&.downcase
60
+ when "debug"
61
+ ::Logger::DEBUG
62
+ when "info"
63
+ ::Logger::INFO
64
+ when "warn"
65
+ ::Logger::WARN
66
+ when "error"
67
+ ::Logger::ERROR
68
+ when "fatal"
69
+ ::Logger::FATAL
70
+ else
71
+ ::Logger::INFO
72
+ end
73
+ end
74
+
75
+ def sanitize_metadata(metadata)
76
+ return {} unless metadata.is_a?(Hash)
77
+
78
+ sanitized = {}
79
+ metadata.each do |key, value|
80
+ sanitized[key] = sanitize_value(value)
81
+ end
82
+ sanitized
83
+ end
84
+
85
+ def sanitize_value(value)
86
+ case value
87
+ when String
88
+ # Truncate very long strings and sanitize sensitive data
89
+ sanitized = value.length > 1000 ? "#{value[0..997]}..." : value
90
+ sanitize_sensitive_data(sanitized)
91
+ when Hash
92
+ sanitize_metadata(value)
93
+ when Array
94
+ value.map { |v| sanitize_value(v) }
95
+ else
96
+ value
97
+ end
98
+ end
99
+
100
+ def sanitize_sensitive_data(text)
101
+ # Remove potential API keys, tokens, passwords
102
+ text.gsub(/\b[A-Za-z0-9]{20,}\b/, "[REDACTED]")
103
+ .gsub(/password[=:]\s*\S+/i, "password=[REDACTED]")
104
+ .gsub(/token[=:]\s*\S+/i, "token=[REDACTED]")
105
+ .gsub(/key[=:]\s*\S+/i, "key=[REDACTED]")
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module AI
5
+ module Utils
6
+ class Metrics
7
+ class << self
8
+ def initialize_metrics
9
+ @metrics = {}
10
+ @counters = {}
11
+ @histograms = {}
12
+ @gauges = {}
13
+ end
14
+
15
+ def record(metric_name, value, tags = {})
16
+ initialize_metrics unless @metrics
17
+
18
+ @metrics[metric_name] ||= []
19
+ @metrics[metric_name] << {
20
+ value: value,
21
+ timestamp: Time.now.to_f,
22
+ tags: tags
23
+ }
24
+
25
+ # Keep only last 1000 measurements per metric
26
+ @metrics[metric_name] = @metrics[metric_name].last(1000)
27
+ end
28
+
29
+ def increment(counter_name, value = 1, tags = {})
30
+ initialize_metrics unless @counters
31
+
32
+ key = build_key(counter_name, tags)
33
+ @counters[key] ||= 0
34
+ @counters[key] += value
35
+ end
36
+
37
+ def decrement(counter_name, value = 1, tags = {})
38
+ increment(counter_name, -value, tags)
39
+ end
40
+
41
+ def histogram(metric_name, value, tags = {})
42
+ initialize_metrics unless @histograms
43
+
44
+ key = build_key(metric_name, tags)
45
+ @histograms[key] ||= []
46
+ @histograms[key] << value
47
+
48
+ # Keep only last 1000 values per histogram
49
+ @histograms[key] = @histograms[key].last(1000)
50
+ end
51
+
52
+ def gauge(metric_name, value, tags = {})
53
+ initialize_metrics unless @gauges
54
+
55
+ key = build_key(metric_name, tags)
56
+ @gauges[key] = {
57
+ value: value,
58
+ timestamp: Time.now.to_f,
59
+ tags: tags
60
+ }
61
+ end
62
+
63
+ def get_counter(counter_name, tags = {})
64
+ return 0 unless @counters
65
+
66
+ key = build_key(counter_name, tags)
67
+ @counters[key] || 0
68
+ end
69
+
70
+ def get_histogram_stats(metric_name, tags = {})
71
+ return {} unless @histograms
72
+
73
+ key = build_key(metric_name, tags)
74
+ values = @histograms[key] || []
75
+ return {} if values.empty?
76
+
77
+ sorted_values = values.sort
78
+ {
79
+ count: values.length,
80
+ min: sorted_values.first,
81
+ max: sorted_values.last,
82
+ mean: values.sum.to_f / values.length,
83
+ median: percentile(sorted_values, 50),
84
+ p95: percentile(sorted_values, 95),
85
+ p99: percentile(sorted_values, 99)
86
+ }
87
+ end
88
+
89
+ def get_gauge(metric_name, tags = {})
90
+ return nil unless @gauges
91
+
92
+ key = build_key(metric_name, tags)
93
+ @gauges[key]
94
+ end
95
+
96
+ def get_metrics_summary
97
+ {
98
+ counters: @counters&.keys&.length || 0,
99
+ histograms: @histograms&.keys&.length || 0,
100
+ gauges: @gauges&.keys&.length || 0,
101
+ metrics: @metrics&.keys&.length || 0,
102
+ timestamp: Time.now.iso8601
103
+ }
104
+ end
105
+
106
+ def export_prometheus_format
107
+ lines = []
108
+
109
+ # Export counters
110
+ @counters&.each do |key, value|
111
+ metric_name, tags = parse_key(key)
112
+ tags_str = format_prometheus_tags(tags)
113
+ lines << "# TYPE #{metric_name} counter"
114
+ lines << "#{metric_name}#{tags_str} #{value}"
115
+ end
116
+
117
+ # Export gauges
118
+ @gauges&.each do |key, data|
119
+ metric_name, tags = parse_key(key)
120
+ tags_str = format_prometheus_tags(tags)
121
+ lines << "# TYPE #{metric_name} gauge"
122
+ lines << "#{metric_name}#{tags_str} #{data[:value]}"
123
+ end
124
+
125
+ # Export histogram summaries
126
+ @histograms&.each do |key, values|
127
+ next if values.empty?
128
+
129
+ metric_name, tags = parse_key(key)
130
+ stats = calculate_histogram_stats(values)
131
+ tags_str = format_prometheus_tags(tags)
132
+
133
+ lines << "# TYPE #{metric_name} histogram"
134
+ lines << "#{metric_name}_count#{tags_str} #{stats[:count]}"
135
+ lines << "#{metric_name}_sum#{tags_str} #{stats[:sum]}"
136
+
137
+ [0.5, 0.9, 0.95, 0.99].each do |quantile|
138
+ quantile_tags = tags.merge(quantile: quantile)
139
+ quantile_tags_str = format_prometheus_tags(quantile_tags)
140
+ lines << "#{metric_name}#{quantile_tags_str} #{stats[:"p#{(quantile * 100).to_i}"]}"
141
+ end
142
+ end
143
+
144
+ lines.join("\n")
145
+ end
146
+
147
+ def reset_metrics!
148
+ @metrics = {}
149
+ @counters = {}
150
+ @histograms = {}
151
+ @gauges = {}
152
+ end
153
+
154
+ private
155
+
156
+ def build_key(metric_name, tags)
157
+ return metric_name.to_s if tags.empty?
158
+
159
+ tag_string = tags.map { |k, v| "#{k}=#{v}" }.sort.join(",")
160
+ "#{metric_name}|#{tag_string}"
161
+ end
162
+
163
+ def parse_key(key)
164
+ parts = key.split("|", 2)
165
+ metric_name = parts[0]
166
+
167
+ if parts[1]
168
+ tags = {}
169
+ parts[1].split(",").each do |tag_pair|
170
+ k, v = tag_pair.split("=", 2)
171
+ tags[k.to_sym] = v
172
+ end
173
+ [metric_name, tags]
174
+ else
175
+ [metric_name, {}]
176
+ end
177
+ end
178
+
179
+ def percentile(sorted_values, percentile)
180
+ return 0 if sorted_values.empty?
181
+
182
+ index = (percentile / 100.0) * (sorted_values.length - 1)
183
+
184
+ if index == index.to_i
185
+ sorted_values[index.to_i]
186
+ else
187
+ lower = sorted_values[index.floor]
188
+ upper = sorted_values[index.ceil]
189
+ lower + (upper - lower) * (index - index.floor)
190
+ end
191
+ end
192
+
193
+ def calculate_histogram_stats(values)
194
+ sorted_values = values.sort
195
+ {
196
+ count: values.length,
197
+ sum: values.sum,
198
+ min: sorted_values.first,
199
+ max: sorted_values.last,
200
+ p50: percentile(sorted_values, 50),
201
+ p90: percentile(sorted_values, 90),
202
+ p95: percentile(sorted_values, 95),
203
+ p99: percentile(sorted_values, 99)
204
+ }
205
+ end
206
+
207
+ def format_prometheus_tags(tags)
208
+ return "" if tags.empty?
209
+
210
+ tag_pairs = tags.map { |k, v| "#{k}=\"#{v}\"" }
211
+ "{#{tag_pairs.join(",")}}"
212
+ end
213
+ end
214
+
215
+ # Initialize on first load
216
+ initialize_metrics
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module AI
5
+ module Utils
6
+ class Sanitizer
7
+ SENSITIVE_PATTERNS = [
8
+ # API Keys and tokens
9
+ /\b[A-Za-z0-9]{20,}\b/,
10
+ /sk-[A-Za-z0-9]{48}/, # OpenAI API keys
11
+ /xoxb-[A-Za-z0-9-]+/, # Slack tokens
12
+ /ghp_[A-Za-z0-9]{36}/, # GitHub tokens
13
+
14
+ # Credit card numbers
15
+ /\b(?:\d{4}[-\s]?){3}\d{4}\b/,
16
+
17
+ # Social Security Numbers
18
+ /\b\d{3}-\d{2}-\d{4}\b/,
19
+
20
+ # Email addresses (partial)
21
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/,
22
+
23
+ # Phone numbers
24
+ /\b\d{3}-\d{3}-\d{4}\b/,
25
+ /\(\d{3}\)\s*\d{3}-\d{4}/,
26
+
27
+ # IP addresses (private ranges)
28
+ /\b(?:10\.|172\.(?:1[6-9]|2[0-9]|3[01])\.|192\.168\.)\d{1,3}\.\d{1,3}\b/
29
+ ].freeze
30
+
31
+ SENSITIVE_HEADERS = %w[
32
+ authorization
33
+ x-api-key
34
+ x-auth-token
35
+ cookie
36
+ set-cookie
37
+ x-session-id
38
+ x-csrf-token
39
+ ].freeze
40
+
41
+ class << self
42
+ def sanitize_request_data(env)
43
+ sanitized_env = {}
44
+
45
+ env.each do |key, value|
46
+ if sensitive_header?(key)
47
+ sanitized_env[key] = "[REDACTED]"
48
+ elsif key == "rack.input" && value.respond_to?(:read)
49
+ # Don't include request body in sanitized data
50
+ sanitized_env[key] = "[REQUEST_BODY]"
51
+ else
52
+ sanitized_env[key] = sanitize_value(value)
53
+ end
54
+ end
55
+
56
+ sanitized_env
57
+ end
58
+
59
+ def sanitize_response_data(headers, body)
60
+ sanitized_headers = {}
61
+ headers.each do |key, value|
62
+ if sensitive_header?(key.downcase)
63
+ sanitized_headers[key] = "[REDACTED]"
64
+ else
65
+ sanitized_headers[key] = sanitize_value(value)
66
+ end
67
+ end
68
+
69
+ sanitized_body = sanitize_body(body)
70
+
71
+ {
72
+ headers: sanitized_headers,
73
+ body: sanitized_body
74
+ }
75
+ end
76
+
77
+ def sanitize_for_ai_processing(data)
78
+ case data
79
+ when Hash
80
+ sanitized = {}
81
+ data.each do |key, value|
82
+ sanitized[key] = sanitize_for_ai_processing(value)
83
+ end
84
+ sanitized
85
+ when Array
86
+ data.map { |item| sanitize_for_ai_processing(item) }
87
+ when String
88
+ sanitize_string_for_ai(data)
89
+ else
90
+ data
91
+ end
92
+ end
93
+
94
+ def extract_safe_content(content, max_length = 1000)
95
+ return "" if content.nil? || content.empty?
96
+
97
+ # Remove sensitive patterns
98
+ safe_content = sanitize_string_for_ai(content.to_s)
99
+
100
+ # Truncate if too long
101
+ if safe_content.length > max_length
102
+ safe_content = safe_content[0..max_length-4] + "..."
103
+ end
104
+
105
+ safe_content
106
+ end
107
+
108
+ def redact_pii(text)
109
+ return text unless text.is_a?(String)
110
+
111
+ redacted = text.dup
112
+
113
+ # Redact email addresses
114
+ redacted.gsub!(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, "[EMAIL]")
115
+
116
+ # Redact phone numbers
117
+ redacted.gsub!(/\b\d{3}-\d{3}-\d{4}\b/, "[PHONE]")
118
+ redacted.gsub!(/\(\d{3}\)\s*\d{3}-\d{4}/, "[PHONE]")
119
+
120
+ # Redact SSN
121
+ redacted.gsub!(/\b\d{3}-\d{2}-\d{4}\b/, "[SSN]")
122
+
123
+ # Redact credit card numbers
124
+ redacted.gsub!(/\b(?:\d{4}[-\s]?){3}\d{4}\b/, "[CREDIT_CARD]")
125
+
126
+ redacted
127
+ end
128
+
129
+ private
130
+
131
+ def sensitive_header?(header_name)
132
+ normalized = header_name.to_s.downcase.gsub(/^http_/, "").gsub(/_/, "-")
133
+ SENSITIVE_HEADERS.include?(normalized)
134
+ end
135
+
136
+ def sanitize_value(value)
137
+ case value
138
+ when String
139
+ sanitize_string(value)
140
+ when Hash
141
+ value.transform_values { |v| sanitize_value(v) }
142
+ when Array
143
+ value.map { |v| sanitize_value(v) }
144
+ else
145
+ value
146
+ end
147
+ end
148
+
149
+ def sanitize_string(str)
150
+ return str if str.length < 10 # Skip very short strings
151
+
152
+ sanitized = str.dup
153
+ SENSITIVE_PATTERNS.each do |pattern|
154
+ sanitized.gsub!(pattern, "[REDACTED]")
155
+ end
156
+ sanitized
157
+ end
158
+
159
+ def sanitize_string_for_ai(str)
160
+ return str if str.length < 10
161
+
162
+ # More aggressive sanitization for AI processing
163
+ sanitized = str.dup
164
+
165
+ # Remove all potential sensitive patterns
166
+ SENSITIVE_PATTERNS.each do |pattern|
167
+ sanitized.gsub!(pattern, "[REDACTED]")
168
+ end
169
+
170
+ # Remove common password/key patterns
171
+ sanitized.gsub!(/password[=:]\s*\S+/i, "password=[REDACTED]")
172
+ sanitized.gsub!(/token[=:]\s*\S+/i, "token=[REDACTED]")
173
+ sanitized.gsub!(/key[=:]\s*\S+/i, "key=[REDACTED]")
174
+ sanitized.gsub!(/secret[=:]\s*\S+/i, "secret=[REDACTED]")
175
+
176
+ # Remove base64 encoded data (potential tokens)
177
+ sanitized.gsub!(/[A-Za-z0-9+\/]{40,}={0,2}/, "[BASE64_DATA]")
178
+
179
+ sanitized
180
+ end
181
+
182
+ def sanitize_body(body)
183
+ return "[EMPTY_BODY]" unless body
184
+
185
+ if body.respond_to?(:each)
186
+ content = ""
187
+ body.each { |chunk| content += chunk.to_s }
188
+ else
189
+ content = body.to_s
190
+ end
191
+
192
+ # Only include first 500 chars of body for logging
193
+ truncated = content.length > 500 ? "#{content[0..497]}..." : content
194
+ sanitize_string_for_ai(truncated)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module AI
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/rack/ai.rb ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ai/version"
4
+ require_relative "ai/configuration"
5
+ require_relative "ai/middleware"
6
+ require_relative "ai/providers/base"
7
+ require_relative "ai/providers/openai"
8
+ require_relative "ai/providers/huggingface"
9
+ require_relative "ai/providers/local"
10
+ require_relative "ai/features/classification"
11
+ require_relative "ai/features/moderation"
12
+ require_relative "ai/features/caching"
13
+ require_relative "ai/features/routing"
14
+ require_relative "ai/features/logging"
15
+ require_relative "ai/features/enhancement"
16
+ require_relative "ai/features/security"
17
+ require_relative "ai/utils/logger"
18
+ require_relative "ai/utils/metrics"
19
+ require_relative "ai/utils/sanitizer"
20
+
21
+ module Rack
22
+ module AI
23
+ class Error < StandardError; end
24
+ class ConfigurationError < Error; end
25
+ class ProviderError < Error; end
26
+ class FeatureError < Error; end
27
+
28
+ class << self
29
+ # Global configuration
30
+ def configure
31
+ yield(configuration)
32
+ end
33
+
34
+ def configuration
35
+ @configuration ||= Configuration.new
36
+ end
37
+
38
+ def reset_configuration!
39
+ @configuration = nil
40
+ end
41
+
42
+ # Convenience method for creating middleware
43
+ def middleware(**options)
44
+ Middleware.new(nil, **options)
45
+ end
46
+ end
47
+ end
48
+ end
data/rack-ai.gemspec ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rack/ai/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "rack-ai"
7
+ spec.version = Rack::AI::VERSION
8
+ spec.authors = ["Ahmet KAHRAMAN"]
9
+ spec.email = ["ahmetxhero@gmail.com"]
10
+
11
+ spec.summary = "AI-powered middleware for Rack applications"
12
+ spec.description = "Extends Rack with AI capabilities including request classification, content moderation, smart caching, and security features"
13
+ spec.homepage = "https://github.com/ahmetxhero/rack-ai"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/ahmetxhero/rack-ai"
20
+ spec.metadata["changelog_uri"] = "https://github.com/ahmetxhero/rack-ai/blob/main/CHANGELOG.md"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # Dependencies
34
+ spec.add_dependency "rack", ">= 2.0", "< 4.0"
35
+ spec.add_dependency "concurrent-ruby", "~> 1.1"
36
+ spec.add_dependency "dry-configurable", "~> 1.0"
37
+ spec.add_dependency "dry-validation", "~> 1.10"
38
+ spec.add_dependency "faraday", "~> 2.0"
39
+ spec.add_dependency "faraday-retry", "~> 2.0"
40
+ spec.add_dependency "redis", "~> 5.0"
41
+
42
+ # Development dependencies
43
+ spec.add_development_dependency "rspec", "~> 3.12"
44
+ spec.add_development_dependency "rack-test", "~> 2.0"
45
+ spec.add_development_dependency "webmock", "~> 3.18"
46
+ spec.add_development_dependency "benchmark-ips", "~> 2.12"
47
+ spec.add_development_dependency "rubocop", "~> 1.50"
48
+ spec.add_development_dependency "rubocop-rspec", "~> 2.20"
49
+ spec.add_development_dependency "simplecov", "~> 0.22"
50
+ spec.add_development_dependency "yard", "~> 0.9"
51
+ end