query_guard 0.4.1 → 0.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/DESIGN.md +420 -0
  4. data/INDEX.md +309 -0
  5. data/README.md +579 -30
  6. data/exe/queryguard +23 -0
  7. data/lib/query_guard/action_controller_subscriber.rb +27 -0
  8. data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
  9. data/lib/query_guard/analysis/risk_detectors.rb +258 -0
  10. data/lib/query_guard/analysis/risk_level.rb +35 -0
  11. data/lib/query_guard/analyzers/base.rb +30 -0
  12. data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
  13. data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
  14. data/lib/query_guard/analyzers/registry.rb +57 -0
  15. data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
  16. data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
  17. data/lib/query_guard/budget.rb +148 -0
  18. data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
  19. data/lib/query_guard/cli/command.rb +93 -0
  20. data/lib/query_guard/cli/commands/analyze.rb +52 -0
  21. data/lib/query_guard/cli/commands/check.rb +58 -0
  22. data/lib/query_guard/cli/formatter.rb +278 -0
  23. data/lib/query_guard/cli/json_reporter.rb +247 -0
  24. data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
  25. data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
  26. data/lib/query_guard/cli.rb +197 -0
  27. data/lib/query_guard/client.rb +4 -6
  28. data/lib/query_guard/config.rb +145 -6
  29. data/lib/query_guard/core/context.rb +80 -0
  30. data/lib/query_guard/core/finding.rb +162 -0
  31. data/lib/query_guard/core/finding_builders.rb +152 -0
  32. data/lib/query_guard/core/query.rb +40 -0
  33. data/lib/query_guard/explain/adapter_interface.rb +89 -0
  34. data/lib/query_guard/explain/explain_enricher.rb +367 -0
  35. data/lib/query_guard/explain/plan_signals.rb +385 -0
  36. data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
  37. data/lib/query_guard/exporter.rb +124 -0
  38. data/lib/query_guard/fingerprint.rb +96 -0
  39. data/lib/query_guard/middleware.rb +101 -15
  40. data/lib/query_guard/migrations/database_adapter.rb +88 -0
  41. data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
  42. data/lib/query_guard/migrations/migration_risk_detectors.rb +287 -0
  43. data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
  44. data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
  45. data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
  46. data/lib/query_guard/publish.rb +38 -0
  47. data/lib/query_guard/rspec.rb +119 -0
  48. data/lib/query_guard/security.rb +99 -0
  49. data/lib/query_guard/store.rb +38 -0
  50. data/lib/query_guard/subscriber.rb +46 -15
  51. data/lib/query_guard/suggest/index_suggester.rb +176 -0
  52. data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
  53. data/lib/query_guard/trace.rb +106 -0
  54. data/lib/query_guard/uploader/http_uploader.rb +166 -0
  55. data/lib/query_guard/uploader/interface.rb +79 -0
  56. data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
  57. data/lib/query_guard/uploader/registry.rb +37 -0
  58. data/lib/query_guard/uploader/upload_service.rb +80 -0
  59. data/lib/query_guard/version.rb +1 -1
  60. data/lib/query_guard.rb +54 -7
  61. metadata +78 -10
  62. data/.rspec +0 -3
  63. data/Rakefile +0 -21
  64. data/config/initializers/query_guard.rb +0 -9
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Suggest
5
+ # Generates index suggestions for queries with missing index indicators.
6
+ # Conservative approach: only suggests when patterns match common cases.
7
+ # All suggestions include disclaimer that they're recommendations only.
8
+ #
9
+ # Example:
10
+ # suggester = IndexSuggester.new
11
+ # suggestion = suggester.suggest_for_sequential_scan(
12
+ # "SELECT * FROM users WHERE email = 'test@example.com'",
13
+ # table_name: "users"
14
+ # )
15
+ # # => {
16
+ # # suggested_index_sql: "CREATE INDEX idx_users_email ON users (email);",
17
+ # # explanation: "Index on email column for WHERE clause equality",
18
+ # # confidence: :medium,
19
+ # # columns: ["email"]
20
+ # # }
21
+ class IndexSuggester
22
+ CONFIDENCE_LEVELS = %i[high medium low].freeze
23
+
24
+ def initialize
25
+ @extractors = PatternExtractors.new
26
+ end
27
+
28
+ # Generate index suggestion for sequential scan with filter
29
+ #
30
+ # @param sql [String] SQL query
31
+ # @param table_name [String] Table being scanned
32
+ # @param filter_condition [String] WHERE clause filter (optional)
33
+ # @return [Hash, nil] Suggestion hash or nil if no reasonable suggestion
34
+ def suggest_for_sequential_scan(sql, table_name:, filter_condition: nil)
35
+ return nil if sql.nil? || table_name.nil?
36
+
37
+ # Extract columns from WHERE clause
38
+ where_cols = @extractors.extract_where_columns(sql)
39
+ return nil if where_cols.empty?
40
+
41
+ # Use first column as primary index candidate
42
+ # This is conservative: most benefit comes from first filter
43
+ primary_col = where_cols.first
44
+ suggest_index(table_name, primary_col, high_confidence: true)
45
+ end
46
+
47
+ # Generate index suggestion for expensive sort
48
+ #
49
+ # @param sql [String] SQL query
50
+ # @param table_name [String] Table being sorted
51
+ # @return [Hash, nil] Suggestion hash or nil
52
+ def suggest_for_expensive_sort(sql, table_name:)
53
+ return nil if sql.nil? || table_name.nil?
54
+
55
+ # Extract ORDER BY columns
56
+ order_cols = @extractors.extract_order_by_columns(sql)
57
+ return nil if order_cols.empty?
58
+
59
+ # For sorts, index all ORDER BY columns in order
60
+ suggest_composite_index(table_name, order_cols, reason: "sort")
61
+ end
62
+
63
+ # Generate index suggestion for multi-column filter
64
+ # For WHERE with multiple equality conditions, suggest composite
65
+ #
66
+ # @param sql [String] SQL query
67
+ # @param table_name [String] Table being filtered
68
+ # @return [Hash, nil] Suggestion hash or nil
69
+ def suggest_for_complex_filter(sql, table_name:)
70
+ return nil if sql.nil? || table_name.nil?
71
+
72
+ where_cols = @extractors.extract_where_columns(sql)
73
+ return nil if where_cols.length < 2
74
+
75
+ # Conservative: only suggest composite for 2-3 columns
76
+ return nil if where_cols.length > 3
77
+
78
+ suggest_composite_index(table_name, where_cols, reason: "composite filter")
79
+ end
80
+
81
+ # Generate index suggestion for JOIN condition
82
+ # Suggests index on join key in inner table
83
+ #
84
+ # @param column_name [String] Join column name
85
+ # @param table_name [String] Inner table name
86
+ # @return [Hash] Suggestion hash
87
+ def suggest_for_join(column_name, table_name:)
88
+ return nil if column_name.nil? || table_name.nil?
89
+
90
+ suggest_index(table_name, column_name, high_confidence: true, reason: "join condition")
91
+ end
92
+
93
+ # Build neutral recommendation text for a suggestion
94
+ #
95
+ # @param suggestion [Hash] Suggestion from suggest_* methods
96
+ # @return [String] User-facing recommendation text
97
+ def build_recommendation_text(suggestion)
98
+ return nil unless suggestion
99
+
100
+ text = suggestion[:explanation]
101
+ text += " (confidence: #{suggestion[:confidence]})"
102
+ text += "\n\nIMPORTANT: This is a recommendation based on query pattern analysis. "
103
+ text += "Before creating the index:\n"
104
+ text += " 1. Verify this index hasn't already been suggested elsewhere\n"
105
+ text += " 2. Check index size impact and maintenance cost\n"
106
+ text += " 3. Run EXPLAIN ANALYZE with and without the index\n"
107
+ text += " 4. Consider selectivity of indexed columns\n"
108
+ text += " 5. Test in development first\n\n"
109
+ text += "Suggested SQL (REVIEW BEFORE RUNNING):\n#{suggestion[:suggested_index_sql]}"
110
+ text
111
+ end
112
+
113
+ private
114
+
115
+ def suggest_index(table_name, column_name, high_confidence: false, reason: nil)
116
+ col_safe = sanitize_identifier(column_name)
117
+ table_safe = sanitize_identifier(table_name)
118
+ index_name = "idx_#{table_safe}_#{col_safe}".downcase
119
+
120
+ {
121
+ suggested_index_sql: "CREATE INDEX #{index_name} ON #{table_safe} (#{col_safe});",
122
+ explanation: build_explanation(reason || "column filter", [column_name]),
123
+ confidence: high_confidence ? :high : :medium,
124
+ columns: [column_name],
125
+ index_name: index_name,
126
+ table_name: table_name
127
+ }
128
+ end
129
+
130
+ def suggest_composite_index(table_name, column_names, reason: nil)
131
+ return nil if column_names.empty?
132
+
133
+ # Conservative: don't suggest composite for too many columns
134
+ return nil if column_names.length > 4
135
+
136
+ table_safe = sanitize_identifier(table_name)
137
+ cols_safe = column_names.map { |c| sanitize_identifier(c) }
138
+ col_list = cols_safe.join(", ")
139
+
140
+ # Index name: idx_table_col1_col2
141
+ index_name = "idx_#{table_safe}_#{cols_safe.join('_')}".downcase[0...63] # 63 char limit
142
+
143
+ {
144
+ suggested_index_sql: "CREATE INDEX #{index_name} ON #{table_safe} (#{col_list});",
145
+ explanation: build_explanation(reason || "composite filter", column_names),
146
+ confidence: :medium,
147
+ columns: column_names,
148
+ index_name: index_name,
149
+ table_name: table_name
150
+ }
151
+ end
152
+
153
+ def build_explanation(reason, columns)
154
+ col_str = columns.length == 1 ? "#{columns[0]} column" : "#{columns.join(', ')} columns"
155
+ case reason
156
+ when "sort"
157
+ "Index on #{col_str} for ORDER BY clause"
158
+ when "composite filter"
159
+ "Composite index on #{col_str} for WHERE clause"
160
+ when "join condition"
161
+ "Index on #{col_str} for JOIN condition"
162
+ else
163
+ "Index on #{col_str} for #{reason}"
164
+ end
165
+ end
166
+
167
+ def sanitize_identifier(identifier)
168
+ # Basic sanitization: allow alphanumeric and underscore
169
+ # PostgreSQL identifiers: letters, digits, underscores
170
+ sanitized = identifier.to_s.downcase.gsub(/[^a-z0-9_]/, '_')
171
+ # Remove leading digits (invalid)
172
+ sanitized.gsub(/^[0-9]+/, '')
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Suggest
5
+ # Extracts SQL patterns from queries for index suggestion.
6
+ # Conservative patterns only - only suggests when reasonably confident.
7
+ #
8
+ # Example:
9
+ # extractor = PatternExtractors.new
10
+ # where_cols = extractor.extract_where_columns("SELECT * FROM users WHERE email = 'test@example.com'")
11
+ # # => ["email"]
12
+ #
13
+ # order_cols = extractor.extract_order_by_columns("SELECT * FROM events ORDER BY created_at DESC")
14
+ # # => ["created_at"]
15
+ class PatternExtractors
16
+ # Extract column names from WHERE clause
17
+ # Handles: simple equality, comparison operators
18
+ # Avoids: functions, expressions, BETWEEN
19
+ #
20
+ # @param sql [String] SQL query
21
+ # @return [Array<String>] Column names in WHERE clause
22
+ def extract_where_columns(sql)
23
+ return [] if sql.nil? || sql.empty?
24
+
25
+ normalized = normalize_sql(sql)
26
+ where_match = normalized.match(/\bWHERE\s+(.+?)(?:\s+(?:GROUP|ORDER|LIMIT|HAVING)(?:\s|$)|$)/i)
27
+ return [] unless where_match
28
+
29
+ where_clause = where_match[1]
30
+ extract_columns_from_where(where_clause)
31
+ end
32
+
33
+ # Extract column names from ORDER BY clause
34
+ # Handles: simple column ordering
35
+ # Avoids: expressions, COLLATE, NULLS FIRST/LAST
36
+ #
37
+ # @param sql [String] SQL query
38
+ # @return [Array<String>] Column names in ORDER BY clause
39
+ def extract_order_by_columns(sql)
40
+ return [] if sql.nil? || sql.empty?
41
+
42
+ normalized = normalize_sql(sql)
43
+ order_match = normalized.match(/\bORDER\s+BY\s+(.+?)(?:\s+(?:LIMIT)(?:\s|$)|$)/i)
44
+ return [] unless order_match
45
+
46
+ order_clause = order_match[1]
47
+ extract_columns_from_order_by(order_clause)
48
+ end
49
+
50
+ # Extract both WHERE and ORDER BY columns
51
+ # Returns them in a structured format for index suggestion
52
+ #
53
+ # @param sql [String] SQL query
54
+ # @return [Hash] { where_columns: [...], order_by_columns: [...] }
55
+ def extract_all_columns(sql)
56
+ {
57
+ where_columns: extract_where_columns(sql),
58
+ order_by_columns: extract_order_by_columns(sql)
59
+ }
60
+ end
61
+
62
+ # Extract table name from query
63
+ # Simple extraction of first table mentioned after FROM
64
+ #
65
+ # @param sql [String] SQL query
66
+ # @return [String, nil] Table name
67
+ def extract_table_name(sql)
68
+ return nil if sql.nil? || sql.empty?
69
+
70
+ normalized = normalize_sql(sql)
71
+ # Match FROM or JOIN followed by table name
72
+ # Handles: "FROM users", "FROM public.users", "JOIN orders ON"
73
+ match = normalized.match(/\b(?:FROM|JOIN)\s+(?:(?:\w+\.)?(\w+))\b/i)
74
+ match&.[](1)&.downcase
75
+ end
76
+
77
+ private
78
+
79
+ def normalize_sql(sql)
80
+ # Remove comments and extra whitespace
81
+ sql
82
+ .gsub(/--.*$/, '') # Remove line comments
83
+ .gsub(/\/\*.*?\*\//m, '') # Remove block comments
84
+ .gsub(/\s+/, ' ') # Collapse whitespace
85
+ .strip
86
+ end
87
+
88
+ def extract_columns_from_where(where_clause)
89
+ columns = []
90
+
91
+ # Match simple patterns: column = value, column > value, column IN (...)
92
+ # Skip function calls and complex expressions
93
+ where_clause.scan(/\b(\w+)\s*(?:=|<>|!=|>|<|>=|<=|LIKE|IN)/i) do |match|
94
+ col = match[0].downcase
95
+ # Skip obvious non-column keywords
96
+ next if skip_keyword?(col)
97
+
98
+ columns << col unless columns.include?(col)
99
+ end
100
+
101
+ columns
102
+ end
103
+
104
+ def extract_columns_from_order_by(order_clause)
105
+ columns = []
106
+
107
+ # Match column names before ASC/DESC/comma
108
+ # Pattern: word (optionally preceded by table name)
109
+ order_clause.split(',').each do |part|
110
+ # Remove ASC/DESC modifiers
111
+ cleaned = part.gsub(/\s+(?:ASC|DESC)\s*$/i, '').strip
112
+
113
+ # Extract column name (handle schema.table.column references)
114
+ col_match = cleaned.match(/\b(\w+)\s*$/)
115
+ next unless col_match
116
+
117
+ col = col_match[1].downcase
118
+ next if skip_keyword?(col)
119
+
120
+ columns << col unless columns.include?(col)
121
+ end
122
+
123
+ columns
124
+ end
125
+
126
+ def skip_keyword?(word)
127
+ # Skip SQL keywords and common aliases
128
+ keywords = %w[
129
+ and or not null true false case when then else end
130
+ select insert update delete from where join on group having order by limit offset
131
+ asc desc ignore using force straight_join key
132
+ ]
133
+ keywords.include?(word.downcase)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+
4
+ module QueryGuard
5
+ # Block-based tracing API for manual query tracking.
6
+ # Usable in console, tests, and Rails controllers/jobs.
7
+ module Trace
8
+ class Report
9
+ attr_reader :label, :context, :stats, :violations
10
+
11
+ def initialize(label, context, stats, violations)
12
+ @label = label
13
+ @context = context
14
+ @stats = stats
15
+ @violations = violations
16
+ end
17
+
18
+ def query_count
19
+ @stats[:count]
20
+ end
21
+
22
+ def total_duration_ms
23
+ @stats[:total_duration_ms]
24
+ end
25
+
26
+ def queries
27
+ @stats[:queries] || []
28
+ end
29
+
30
+ def fingerprints
31
+ @stats[:fingerprints] || {}
32
+ end
33
+
34
+ def has_violations?
35
+ !@violations.empty?
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ label: @label,
41
+ context: @context,
42
+ query_count: query_count,
43
+ total_duration_ms: total_duration_ms,
44
+ violations: @violations,
45
+ fingerprints: @fingerprints
46
+ }
47
+ end
48
+ end
49
+
50
+ module_function
51
+
52
+ # Trace a block of code and capture query stats.
53
+ # Returns [result, report] tuple.
54
+ #
55
+ # Example:
56
+ # result, report = QueryGuard.trace("load users") do
57
+ # User.where(active: true).limit(10).to_a
58
+ # end
59
+ #
60
+ # puts report.query_count
61
+ # puts report.total_duration_ms
62
+ #
63
+ # With context:
64
+ # result, report = QueryGuard.trace("process batch", context: { batch_id: 123 }) do
65
+ # # ... code ...
66
+ # end
67
+ def trace(label, context: {})
68
+ # Initialize thread-local stats
69
+ previous_stats = Thread.current[:query_guard_stats]
70
+ Thread.current[:query_guard_stats] = {
71
+ request_id: "trace-#{SecureRandom.hex(4)}",
72
+ count: 0,
73
+ total_duration_ms: 0.0,
74
+ violations: [],
75
+ fingerprints: Hash.new(0),
76
+ response_bytes: 0,
77
+ queries: [],
78
+ request: context
79
+ }
80
+
81
+ # Execute the block
82
+ result = yield
83
+
84
+ # Capture stats
85
+ stats = Thread.current[:query_guard_stats]
86
+ violations = stats[:violations].dup
87
+
88
+ # Check budget if configured
89
+ if QueryGuard.respond_to?(:config) && QueryGuard.config
90
+ budget = QueryGuard.config.budget
91
+ if budget
92
+ budget_violations = budget.check(label, stats)
93
+ violations.concat(budget_violations)
94
+ end
95
+ end
96
+
97
+ # Build report
98
+ report = Report.new(label, context, stats, violations)
99
+
100
+ [result, report]
101
+ ensure
102
+ # Restore previous stats
103
+ Thread.current[:query_guard_stats] = previous_stats
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Uploader
5
+ # HTTP uploader for sending reports to a remote QueryGuard API.
6
+ #
7
+ # This is a template/stub uploader for future SaaS platform integration.
8
+ # It provides the interface for authenticated HTTPS uploads but is not
9
+ # yet connected to a production service.
10
+ #
11
+ # Configuration:
12
+ # config.uploader_type = 'http'
13
+ # config.api_base_url = 'https://api.queryguard.example.com'
14
+ # config.project_key = 'proj-123'
15
+ # config.api_token = 'token-secret'
16
+ #
17
+ # Use case (future):
18
+ # - Upload findings to hosted QueryGuard platform
19
+ # - Centralized reporting & analytics
20
+ # - Team collaboration on findings
21
+ # - Integration with compliance dashboards
22
+ class HttpUploader < Interface
23
+ # Create HTTP uploader from config.
24
+ #
25
+ # @param config [QueryGuard::Config] Configuration object with API details
26
+ def initialize(config)
27
+ @config = config
28
+ @api_base_url = config.api_base_url
29
+ @project_key = config.project_key
30
+ @api_token = config.api_token
31
+ end
32
+
33
+ # Upload JSON report to remote API.
34
+ #
35
+ # @param json_report [String] JSON report string
36
+ # @param metadata [Hash] Additional metadata (tracing, timing, etc.)
37
+ # @return [UploadResult]
38
+ def upload(json_report, metadata = {})
39
+ unless ready?
40
+ return UploadResult.new(
41
+ success: false,
42
+ uploader_name: name,
43
+ error: "HTTP uploader not properly configured (missing #{missing_fields.join(', ')})"
44
+ )
45
+ end
46
+
47
+ # Parse report to extract metadata
48
+ report_data = parse_report(json_report)
49
+
50
+ perform_upload(json_report, report_data, metadata)
51
+ rescue StandardError => e
52
+ UploadResult.new(
53
+ success: false,
54
+ uploader_name: name,
55
+ error: "Upload failed: #{e.message}"
56
+ )
57
+ end
58
+
59
+ # Check if configuration is complete.
60
+ def ready?
61
+ !!(@api_base_url && !@api_base_url.empty? &&
62
+ @project_key && !@project_key.empty? &&
63
+ @api_token && !@api_token.empty?)
64
+ end
65
+
66
+ def name
67
+ 'http'
68
+ end
69
+
70
+ def status
71
+ {
72
+ enabled: ready?,
73
+ mode: 'http',
74
+ api_url: @api_base_url,
75
+ project_key: @project_key,
76
+ description: ready? ? 'Configured for remote API upload' : 'Not configured (missing credentials)'
77
+ }
78
+ end
79
+
80
+ private
81
+
82
+ # Missing required fields for upload.
83
+ def missing_fields
84
+ fields = []
85
+ fields << 'api_base_url' unless @api_base_url && !@api_base_url.empty?
86
+ fields << 'project_key' unless @project_key && !@project_key.empty?
87
+ fields << 'api_token' unless @api_token && !@api_token.empty?
88
+ fields
89
+ end
90
+
91
+ # Extract basic info from JSON report.
92
+ def parse_report(json_str)
93
+ JSON.parse(json_str)
94
+ rescue StandardError
95
+ {}
96
+ end
97
+
98
+ # Perform the actual HTTP upload.
99
+ #
100
+ # NOTE: This is a stub implementation. A production version would:
101
+ # - Use Net::HTTP or similar with proper SSL/verify certificate
102
+ # - Handle retries and exponential backoff
103
+ # - Implement request signing/HMAC
104
+ # - Support compression (gzip)
105
+ # - Set proper Content-Type and User-Agent headers
106
+ # - Implement request timeouts and circuit breaks
107
+ #
108
+ # For now, this is a documentation template.
109
+ def perform_upload(json_report, report_data, metadata)
110
+ require 'net/http'
111
+ require 'json'
112
+
113
+ url = build_upload_url(report_data)
114
+ request_body = build_request_body(json_report, metadata)
115
+
116
+ # Stub: Would make the actual HTTP request here
117
+ # This is intentionally not implemented to prevent accidental
118
+ # API calls during testing or misconfiguration.
119
+ #
120
+ # A production implementation would do something like:
121
+ # uri = URI(url)
122
+ # http = Net::HTTP.new(uri.host, uri.port)
123
+ # http.use_ssl = uri.scheme == 'https'
124
+ # req = Net::HTTP::Post.new(uri.path)
125
+ # req['Authorization'] = "Bearer #{@api_token}"
126
+ # req['Content-Type'] = 'application/json'
127
+ # response = http.request(req, request_body)
128
+ #
129
+ # But we leave that for when the SaaS platform exists.
130
+
131
+ # For now, return a success result indicating what WOULD be sent
132
+ UploadResult.new(
133
+ success: true,
134
+ uploader_name: name,
135
+ details: {
136
+ endpoint: url,
137
+ bytes_sent: request_body.bytesize,
138
+ report_version: report_data['report_version'],
139
+ project_key: @project_key,
140
+ note: 'HTTP uploader is configured but SaaS platform is not yet live'
141
+ }
142
+ )
143
+ end
144
+
145
+ # Build the upload endpoint URL.
146
+ def build_upload_url(report_data)
147
+ report_type = report_data['report_type'] || 'analysis'
148
+ base_url = @api_base_url.end_with?('/') ? @api_base_url[0...-1] : @api_base_url
149
+ "#{base_url}/api/v1/projects/#{@project_key}/reports/#{report_type}"
150
+ end
151
+
152
+ # Build the request body with headers and payload.
153
+ def build_request_body(json_report, metadata)
154
+ {
155
+ report: JSON.parse(json_report),
156
+ client_metadata: {
157
+ tool_version: QueryGuard::VERSION || 'unknown',
158
+ client_time: Time.now.utc.iso8601,
159
+ trace_id: metadata[:trace_id],
160
+ request_id: metadata[:request_id]
161
+ }
162
+ }.to_json
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Uploader
5
+ # Abstract interface for report uploaders.
6
+ #
7
+ # All uploaders must implement this interface. This allows:
8
+ # - Pluggable upload strategies (no-op, HTTP, webhook, etc.)
9
+ # - Easy testing with mock uploaders
10
+ # - Future extensibility without changing core code
11
+ #
12
+ # Example:
13
+ # uploader = QueryGuard::Uploader.for_config(config)
14
+ # result = uploader.upload(report_json)
15
+ class Interface
16
+ # Upload a JSON report to the configured destination.
17
+ #
18
+ # @param json_report [String] JSON-formatted report string
19
+ # @param metadata [Hash] Optional metadata (report_id, trace_id, etc.)
20
+ #
21
+ # @return [UploadResult] Result object with status and details
22
+ #
23
+ # Should never raise. Always return an UploadResult with success/failure state.
24
+ def upload(json_report, metadata = {})
25
+ raise NotImplementedError, "#{self.class} must implement #upload"
26
+ end
27
+
28
+ # Check if this uploader is properly configured and ready.
29
+ #
30
+ # @return [Boolean] true if ready to upload, false otherwise
31
+ def ready?
32
+ raise NotImplementedError, "#{self.class} must implement #ready?"
33
+ end
34
+
35
+ # Human-readable name of this uploader.
36
+ #
37
+ # @return [String]
38
+ def name
39
+ raise NotImplementedError, "#{self.class} must implement #name"
40
+ end
41
+
42
+ # Get status/diagnostic information for logging.
43
+ #
44
+ # @return [Hash] Status information (target_url, enabled, etc.)
45
+ def status
46
+ raise NotImplementedError, "#{self.class} must implement #status"
47
+ end
48
+ end
49
+
50
+ # Result of an upload operation.
51
+ class UploadResult
52
+ attr_reader :success, :uploader_name, :details, :error
53
+
54
+ def initialize(success:, uploader_name:, details: {}, error: nil)
55
+ @success = success
56
+ @uploader_name = uploader_name
57
+ @details = details
58
+ @error = error
59
+ end
60
+
61
+ def successful?
62
+ @success
63
+ end
64
+
65
+ def failed?
66
+ !@success
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ success: @success,
72
+ uploader: @uploader_name,
73
+ details: @details,
74
+ error: @error
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end