sumologic-query 1.3.1 → 1.3.3

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,30 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'json'
5
- require 'uri'
6
3
  require_relative 'connection_pool'
4
+ require_relative 'debug_logger'
5
+ require_relative 'cookie_jar'
6
+ require_relative 'request_builder'
7
+ require_relative 'response_handler'
7
8
 
8
9
  module Sumologic
9
10
  module Http
10
- # Handles HTTP communication with Sumo Logic API
11
- # Responsibilities: request execution, error handling, SSL configuration
12
- # Uses connection pooling for thread-safe parallel requests
11
+ # Orchestrates HTTP communication with Sumo Logic API
12
+ # Delegates to specialized components for request building,
13
+ # response handling, connection pooling, and cookie management
13
14
  class Client
14
15
  def initialize(base_url:, authenticator:)
15
- @base_url = base_url
16
- @authenticator = authenticator
16
+ @cookie_jar = CookieJar.new
17
+ @request_builder = RequestBuilder.new(
18
+ base_url: base_url,
19
+ authenticator: authenticator,
20
+ cookie_jar: @cookie_jar
21
+ )
22
+ @response_handler = ResponseHandler.new
17
23
  @connection_pool = ConnectionPool.new(base_url: base_url, max_connections: 10)
18
24
  end
19
25
 
20
26
  # Execute HTTP request with error handling
21
27
  # Uses connection pool for thread-safe parallel execution
22
28
  def request(method:, path:, body: nil, query_params: nil)
23
- uri = build_uri(path, query_params)
24
- request = build_request(method, uri, body)
29
+ uri = @request_builder.build_uri(path, query_params)
30
+ request = @request_builder.build_request(method, uri, body)
31
+
32
+ DebugLogger.log_request(method, uri, body)
25
33
 
26
34
  response = execute_request(uri, request)
27
- handle_response(response)
35
+
36
+ DebugLogger.log_response(response)
37
+
38
+ @response_handler.handle(response)
28
39
  rescue Errno::ECONNRESET, Errno::EPIPE, EOFError, Net::HTTPBadResponse => e
29
40
  # Connection error - raise for retry at higher level
30
41
  raise Error, "Connection error: #{e.message}"
@@ -37,49 +48,15 @@ module Sumologic
37
48
 
38
49
  private
39
50
 
40
- def build_uri(path, query_params)
41
- uri = URI("#{@base_url}#{path}")
42
- uri.query = URI.encode_www_form(query_params) if query_params
43
- uri
44
- end
45
-
46
- def build_request(method, uri, body)
47
- request_class = case method
48
- when :get then Net::HTTP::Get
49
- when :post then Net::HTTP::Post
50
- when :delete then Net::HTTP::Delete
51
- else raise ArgumentError, "Unsupported HTTP method: #{method}"
52
- end
53
-
54
- request = request_class.new(uri)
55
- request['Authorization'] = @authenticator.auth_header
56
- request['Accept'] = 'application/json'
57
-
58
- if body
59
- request['Content-Type'] = 'application/json'
60
- request.body = body.to_json
61
- end
62
-
63
- request
64
- end
65
-
66
51
  def execute_request(uri, request)
67
- @connection_pool.with_connection(uri) do |http|
52
+ response = @connection_pool.with_connection(uri) do |http|
68
53
  http.request(request)
69
54
  end
70
- end
71
55
 
72
- def handle_response(response)
73
- case response.code.to_i
74
- when 200..299
75
- JSON.parse(response.body)
76
- when 401, 403
77
- raise AuthenticationError, "Authentication failed: #{response.body}"
78
- when 429
79
- raise Error, "Rate limit exceeded: #{response.body}"
80
- else
81
- raise Error, "HTTP #{response.code}: #{response.body}"
82
- end
56
+ # Store cookies from response for subsequent requests
57
+ @cookie_jar.store_from_response(response)
58
+
59
+ response
83
60
  end
84
61
  end
85
62
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  module Sumologic
4
6
  module Http
5
7
  # Thread-safe connection pool for HTTP clients
@@ -84,10 +86,22 @@ module Sumologic
84
86
  http.read_timeout = READ_TIMEOUT
85
87
  http.open_timeout = OPEN_TIMEOUT
86
88
  http.keep_alive_timeout = 30
89
+
90
+ # SSL configuration
91
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
92
+ http.cert_store = ssl_cert_store
93
+
87
94
  http.start
88
95
  http
89
96
  end
90
97
 
98
+ def ssl_cert_store
99
+ # Use system's default certificate store
100
+ store = OpenSSL::X509::Store.new
101
+ store.set_default_paths
102
+ store
103
+ end
104
+
91
105
  def create_temporary_connection(uri)
92
106
  # Fallback: create a temporary connection if pool is exhausted
93
107
  create_connection(uri)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Http
5
+ # Simple cookie jar for storing and managing HTTP cookies
6
+ # Handles Set-Cookie response headers and Cookie request headers
7
+ class CookieJar
8
+ def initialize
9
+ @cookies = {}
10
+ end
11
+
12
+ # Store cookies from response Set-Cookie headers
13
+ def store_from_response(response)
14
+ return unless response['Set-Cookie']
15
+
16
+ Array(response['Set-Cookie']).each do |cookie_header|
17
+ parse_and_store(cookie_header)
18
+ end
19
+ end
20
+
21
+ # Format cookies for Cookie request header
22
+ # Returns nil if no cookies stored
23
+ def to_header
24
+ return nil if @cookies.empty?
25
+
26
+ @cookies.map { |name, value| "#{name}=#{value}" }.join('; ')
27
+ end
28
+
29
+ # Check if any cookies are stored
30
+ def any?
31
+ @cookies.any?
32
+ end
33
+
34
+ # Clear all stored cookies
35
+ def clear
36
+ @cookies.clear
37
+ end
38
+
39
+ private
40
+
41
+ def parse_and_store(cookie_header)
42
+ # Parse cookie name=value (ignore path, domain, expires, etc.)
43
+ # Example: "session_id=abc123; Path=/; HttpOnly"
44
+ return unless cookie_header =~ /^([^=]+)=([^;]+)/
45
+
46
+ name = Regexp.last_match(1).strip
47
+ value = Regexp.last_match(2).strip
48
+ @cookies[name] = value
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Sumologic
6
+ module Http
7
+ # Handles debug logging for HTTP requests and responses
8
+ # Only logs when $DEBUG is enabled
9
+ module DebugLogger
10
+ module_function
11
+
12
+ def log_request(method, uri, body)
13
+ return unless $DEBUG
14
+
15
+ warn "\n[DEBUG] API Request:"
16
+ warn " Method: #{method.to_s.upcase}"
17
+ warn " URL: #{uri}"
18
+ log_request_body(body) if body
19
+ warn ''
20
+ end
21
+
22
+ def log_response(response)
23
+ return unless $DEBUG
24
+
25
+ warn '[DEBUG] API Response:'
26
+ warn " Status: #{response.code} #{response.message}"
27
+ log_response_body(response.body)
28
+ warn ''
29
+ end
30
+
31
+ def log_request_body(body)
32
+ warn " Body: #{JSON.pretty_generate(body)}"
33
+ rescue JSON::GeneratorError
34
+ warn " Body: #{body.inspect}"
35
+ end
36
+
37
+ def log_response_body(body)
38
+ truncated = body.length > 500
39
+ display_body = truncated ? "#{body[0..500]}..." : body
40
+ warn " Body: #{display_body}"
41
+ warn " (truncated, full length: #{body.length} characters)" if truncated
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Sumologic
8
+ module Http
9
+ # Builds HTTP requests with proper headers, authentication, and cookies
10
+ class RequestBuilder
11
+ def initialize(base_url:, authenticator:, cookie_jar:)
12
+ @base_url = base_url
13
+ @authenticator = authenticator
14
+ @cookie_jar = cookie_jar
15
+ end
16
+
17
+ # Build complete URI from path and query parameters
18
+ def build_uri(path, query_params = nil)
19
+ uri = URI("#{@base_url}#{path}")
20
+ uri.query = URI.encode_www_form(query_params) if query_params
21
+ uri
22
+ end
23
+
24
+ # Build HTTP request with all necessary headers
25
+ def build_request(method, uri, body = nil)
26
+ request = create_request_object(method, uri)
27
+ add_headers(request)
28
+ add_body(request, body) if body
29
+ request
30
+ end
31
+
32
+ private
33
+
34
+ def create_request_object(method, uri)
35
+ request_class = request_class_for(method)
36
+ request_class.new(uri)
37
+ end
38
+
39
+ def request_class_for(method)
40
+ case method
41
+ when :get then Net::HTTP::Get
42
+ when :post then Net::HTTP::Post
43
+ when :delete then Net::HTTP::Delete
44
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
45
+ end
46
+ end
47
+
48
+ def add_headers(request)
49
+ request['Authorization'] = @authenticator.auth_header
50
+ request['Accept'] = 'application/json'
51
+
52
+ # Add cookies if available
53
+ cookie_header = @cookie_jar.to_header
54
+ request['Cookie'] = cookie_header if cookie_header
55
+ end
56
+
57
+ def add_body(request, body)
58
+ request['Content-Type'] = 'application/json'
59
+ request.body = body.to_json
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Sumologic
6
+ module Http
7
+ # Handles HTTP response parsing and error handling
8
+ class ResponseHandler
9
+ # Parse response and handle errors
10
+ def handle(response)
11
+ case response.code.to_i
12
+ when 200..299
13
+ parse_success(response)
14
+ when 401, 403
15
+ handle_authentication_error(response)
16
+ when 429
17
+ handle_rate_limit_error(response)
18
+ else
19
+ handle_generic_error(response)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def parse_success(response)
26
+ JSON.parse(response.body)
27
+ end
28
+
29
+ def handle_authentication_error(response)
30
+ raise AuthenticationError, "Authentication failed: #{response.body}"
31
+ end
32
+
33
+ def handle_rate_limit_error(response)
34
+ raise Error, "Rate limit exceeded: #{response.body}"
35
+ end
36
+
37
+ def handle_generic_error(response)
38
+ raise Error, "HTTP #{response.code}: #{response.body}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Interactive
5
+ class FzfViewer
6
+ module Config
7
+ # Display configuration
8
+ TIME_WIDTH = 8
9
+ LEVEL_WIDTH = 7
10
+ SOURCE_WIDTH = 25
11
+ MESSAGE_PREVIEW_LENGTH = 80
12
+ SEARCHABLE_PADDING = 5
13
+
14
+ # Searchable field names
15
+ SEARCHABLE_FIELDS = %w[
16
+ _source
17
+ _sourcecategory
18
+ _sourcename
19
+ _collector
20
+ _sourcehost
21
+ region
22
+ _group
23
+ _tier
24
+ _view
25
+ ].freeze
26
+
27
+ # ANSI color codes
28
+ COLORS = {
29
+ red: "\e[31m",
30
+ yellow: "\e[33m",
31
+ cyan: "\e[36m",
32
+ gray: "\e[90m",
33
+ reset: "\e[0m"
34
+ }.freeze
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Interactive
5
+ class FzfViewer
6
+ module Formatter
7
+ module_function
8
+
9
+ def format_time(timestamp_ms)
10
+ return 'N/A' unless timestamp_ms
11
+
12
+ Time.at(timestamp_ms.to_i / 1000).strftime('%H:%M:%S')
13
+ end
14
+
15
+ def format_level(level)
16
+ level_str = level.to_s.upcase.ljust(Config::LEVEL_WIDTH)
17
+ colorize_level(level_str)
18
+ end
19
+
20
+ def colorize_level(level_str)
21
+ case level_str.strip
22
+ when 'ERROR', 'FATAL', 'CRITICAL'
23
+ "#{Config::COLORS[:red]}#{level_str}#{Config::COLORS[:reset]}"
24
+ when 'WARN', 'WARNING'
25
+ "#{Config::COLORS[:yellow]}#{level_str}#{Config::COLORS[:reset]}"
26
+ when 'INFO'
27
+ "#{Config::COLORS[:cyan]}#{level_str}#{Config::COLORS[:reset]}"
28
+ when 'DEBUG', 'TRACE'
29
+ "#{Config::COLORS[:gray]}#{level_str}#{Config::COLORS[:reset]}"
30
+ else
31
+ level_str
32
+ end
33
+ end
34
+
35
+ def sanitize(text)
36
+ text.to_s.gsub(/[\n\r\t]/, ' ').squeeze(' ')
37
+ end
38
+
39
+ def truncate(text, length)
40
+ text = text.to_s
41
+ text.length > length ? "#{text[0...(length - 3)]}..." : text
42
+ end
43
+
44
+ def pad(text, width)
45
+ text.ljust(width)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Sumologic
6
+ module Interactive
7
+ class FzfViewer
8
+ module FzfConfig
9
+ module_function
10
+
11
+ def build_fzf_args(input_path, preview_path, header_text)
12
+ [
13
+ 'fzf',
14
+ *search_options,
15
+ *display_options(preview_path, header_text),
16
+ *keybinding_options(input_path, preview_path)
17
+ ]
18
+ end
19
+
20
+ def search_options
21
+ [
22
+ '--ansi',
23
+ '--multi',
24
+ '--exact', # Exact substring matching
25
+ '-i', # Case-insensitive
26
+ '--no-hscroll' # Prevent horizontal scrolling
27
+ ]
28
+ end
29
+
30
+ def display_options(preview_path, header_text)
31
+ [
32
+ "--header=#{header_text}",
33
+ "--preview=#{build_preview_command(preview_path)}",
34
+ '--preview-window=right:60%:wrap:follow',
35
+ '--height=100%'
36
+ ]
37
+ end
38
+
39
+ def keybinding_options(input_path, preview_path)
40
+ [
41
+ '--bind=enter:toggle',
42
+ "--bind=tab:execute(#{build_view_command(preview_path)})",
43
+ '--bind=ctrl-a:select-all',
44
+ '--bind=ctrl-d:deselect-all',
45
+ '--bind=ctrl-s:execute-silent(echo {+} > sumo-selected.txt)+abort',
46
+ '--bind=ctrl-y:execute-silent(echo {+} | pbcopy || ' \
47
+ 'echo {+} | xclip -selection clipboard 2>/dev/null)+abort',
48
+ '--bind=ctrl-e:execute-silent(echo {+} > sumo-export.jsonl)+abort',
49
+ '--bind=ctrl-/:toggle-preview',
50
+ "--bind=ctrl-r:reload(cat #{input_path})",
51
+ '--bind=ctrl-t:toggle-search',
52
+ '--bind=ctrl-q:abort'
53
+ ]
54
+ end
55
+
56
+ def build_view_command(preview_path)
57
+ # FZF {n} is 0-indexed, sed is 1-indexed
58
+ 'LINE=$(({n} + 1)); ' \
59
+ "sed -n \"$LINE\"p #{Shellwords.escape(preview_path)} | jq -C . | less -R"
60
+ end
61
+
62
+ def build_preview_command(preview_path)
63
+ escaped_path = Shellwords.escape(preview_path)
64
+
65
+ calc = "LINE=$(({n} + 1)); TOTAL=$(wc -l < #{escaped_path}); "
66
+ display = 'echo "Message $LINE of $TOTAL"; echo ""; '
67
+ extract = "sed -n \"$LINE\"p #{escaped_path}"
68
+
69
+ "#{calc}#{display}#{extract} | jq -C . || #{extract}"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Interactive
5
+ class FzfViewer
6
+ module HeaderBuilder
7
+ module_function
8
+
9
+ def build_header_text(results, messages)
10
+ [
11
+ build_column_headers,
12
+ build_info_line(results, messages),
13
+ build_search_tips,
14
+ build_keybindings_help
15
+ ].join("\n")
16
+ end
17
+
18
+ def build_column_headers
19
+ "#{Formatter.pad('TIME', Config::TIME_WIDTH)} " \
20
+ "#{Formatter.pad('LEVEL', Config::LEVEL_WIDTH)} " \
21
+ "#{Formatter.pad('SOURCE', Config::SOURCE_WIDTH)} MESSAGE"
22
+ end
23
+
24
+ def build_info_line(results, messages)
25
+ query = results['query'] || 'N/A'
26
+ count = messages.size
27
+ sources = messages.map { |m| m['map']['_source'] }.compact.uniq.size
28
+
29
+ "#{count} msgs | #{sources} sources | Query: #{Formatter.truncate(query, 40)}"
30
+ end
31
+
32
+ def build_search_tips
33
+ '💡 Simple text search (case-insensitive) - searches all JSON fields and log content'
34
+ end
35
+
36
+ def build_keybindings_help
37
+ 'Enter=select Tab=view Ctrl-T=toggle-search Ctrl-S=save Ctrl-Y=copy Ctrl-E=export Ctrl-Q=quit'
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sumologic
4
+ module Interactive
5
+ class FzfViewer
6
+ module SearchableBuilder
7
+ module_function
8
+
9
+ def build_searchable_content(map)
10
+ parts = []
11
+
12
+ add_primary_content(parts, map)
13
+ add_standard_fields(parts, map)
14
+ add_custom_fields(parts, map)
15
+
16
+ parts.compact.join(' ')
17
+ end
18
+
19
+ def add_primary_content(parts, map)
20
+ parts << Formatter.sanitize(map['_raw'] || map['message'] || '')
21
+ end
22
+
23
+ def add_standard_fields(parts, map)
24
+ Config::SEARCHABLE_FIELDS.each do |field|
25
+ parts << map[field] if map[field]
26
+ end
27
+ end
28
+
29
+ def add_custom_fields(parts, map)
30
+ map.each do |key, value|
31
+ next if key.start_with?('_')
32
+ next if value.nil? || value.to_s.empty?
33
+
34
+ parts << "#{key}:#{value}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end