sumologic-query 1.3.5 → 1.4.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +1 -1
  4. data/lib/sumologic/cli/commands/base_command.rb +0 -20
  5. data/lib/sumologic/cli/commands/{discover_sources_command.rb → discover_source_metadata_command.rb} +4 -4
  6. data/lib/sumologic/cli/commands/export_content_command.rb +20 -0
  7. data/lib/sumologic/cli/commands/get_content_command.rb +20 -0
  8. data/lib/sumologic/cli/commands/get_dashboard_command.rb +20 -0
  9. data/lib/sumologic/cli/commands/get_lookup_command.rb +20 -0
  10. data/lib/sumologic/cli/commands/get_monitor_command.rb +20 -0
  11. data/lib/sumologic/cli/commands/list_apps_command.rb +22 -0
  12. data/lib/sumologic/cli/commands/list_collectors_command.rb +1 -1
  13. data/lib/sumologic/cli/commands/list_dashboards_command.rb +22 -0
  14. data/lib/sumologic/cli/commands/list_fields_command.rb +27 -0
  15. data/lib/sumologic/cli/commands/list_folders_command.rb +55 -0
  16. data/lib/sumologic/cli/commands/list_health_events_command.rb +22 -0
  17. data/lib/sumologic/cli/commands/list_monitors_command.rb +27 -0
  18. data/lib/sumologic/cli/commands/list_sources_command.rb +2 -9
  19. data/lib/sumologic/cli/commands/search_command.rb +56 -18
  20. data/lib/sumologic/cli.rb +290 -12
  21. data/lib/sumologic/client.rb +207 -12
  22. data/lib/sumologic/configuration.rb +23 -9
  23. data/lib/sumologic/http/client.rb +76 -11
  24. data/lib/sumologic/http/connection_pool.rb +7 -5
  25. data/lib/sumologic/http/response_handler.rb +65 -1
  26. data/lib/sumologic/metadata/app.rb +34 -0
  27. data/lib/sumologic/metadata/content.rb +95 -0
  28. data/lib/sumologic/metadata/dashboard.rb +104 -0
  29. data/lib/sumologic/metadata/field.rb +49 -0
  30. data/lib/sumologic/metadata/folder.rb +89 -0
  31. data/lib/sumologic/metadata/health_event.rb +35 -0
  32. data/lib/sumologic/metadata/lookup_table.rb +34 -0
  33. data/lib/sumologic/metadata/models.rb +2 -80
  34. data/lib/sumologic/metadata/monitor.rb +113 -0
  35. data/lib/sumologic/metadata/source.rb +5 -7
  36. data/lib/sumologic/metadata/{dynamic_source_discovery.rb → source_metadata_discovery.rb} +7 -7
  37. data/lib/sumologic/version.rb +1 -1
  38. data/lib/sumologic.rb +23 -1
  39. metadata +23 -4
@@ -4,7 +4,8 @@ module Sumologic
4
4
  # Centralized configuration for Sumo Logic client
5
5
  class Configuration
6
6
  attr_accessor :access_id, :access_key, :deployment, :timeout, :initial_poll_interval, :max_poll_interval,
7
- :poll_backoff_factor, :max_messages_per_request, :max_workers, :request_delay
7
+ :poll_backoff_factor, :max_messages_per_request, :max_workers, :request_delay,
8
+ :connect_timeout, :read_timeout, :max_retries, :retry_base_delay, :retry_max_delay
8
9
 
9
10
  API_VERSION = 'v1'
10
11
 
@@ -20,9 +21,16 @@ module Sumologic
20
21
  @poll_backoff_factor = 1.5 # increase interval by 50% each time
21
22
 
22
23
  # Timeouts and limits
23
- @timeout = 300 # seconds (5 minutes)
24
+ @timeout = 300 # seconds (5 minutes) - overall operation timeout
25
+ @connect_timeout = ENV.fetch('SUMO_CONNECT_TIMEOUT', '10').to_i # seconds
26
+ @read_timeout = ENV.fetch('SUMO_READ_TIMEOUT', '60').to_i # seconds
24
27
  @max_messages_per_request = 10_000
25
28
 
29
+ # Retry configuration
30
+ @max_retries = ENV.fetch('SUMO_MAX_RETRIES', '3').to_i
31
+ @retry_base_delay = ENV.fetch('SUMO_RETRY_BASE_DELAY', '1.0').to_f # seconds
32
+ @retry_max_delay = ENV.fetch('SUMO_RETRY_MAX_DELAY', '30.0').to_f # seconds
33
+
26
34
  # Rate limiting (default: 5 workers, 250ms delay)
27
35
  @max_workers = ENV.fetch('SUMO_MAX_WORKERS', '5').to_i
28
36
  @request_delay = ENV.fetch('SUMO_REQUEST_DELAY', '0.25').to_f
@@ -32,6 +40,11 @@ module Sumologic
32
40
  @base_url ||= build_base_url
33
41
  end
34
42
 
43
+ # Base URL for v2 API endpoints (Content Library, etc.)
44
+ def base_url_v2
45
+ @base_url_v2 ||= build_base_url('v2')
46
+ end
47
+
35
48
  def validate!
36
49
  raise AuthenticationError, 'SUMO_ACCESS_ID not set' unless @access_id
37
50
  raise AuthenticationError, 'SUMO_ACCESS_KEY not set' unless @access_key
@@ -39,20 +52,21 @@ module Sumologic
39
52
 
40
53
  private
41
54
 
42
- def build_base_url
55
+ def build_base_url(version = API_VERSION)
43
56
  case @deployment
44
57
  when /^http/
45
- @deployment # Full URL provided
58
+ # Full URL provided - replace version if present
59
+ @deployment.sub(%r{/api/v\d+}, "/api/#{version}")
46
60
  when 'us1'
47
- "https://api.sumologic.com/api/#{API_VERSION}"
61
+ "https://api.sumologic.com/api/#{version}"
48
62
  when 'us2'
49
- "https://api.us2.sumologic.com/api/#{API_VERSION}"
63
+ "https://api.us2.sumologic.com/api/#{version}"
50
64
  when 'eu'
51
- "https://api.eu.sumologic.com/api/#{API_VERSION}"
65
+ "https://api.eu.sumologic.com/api/#{version}"
52
66
  when 'au'
53
- "https://api.au.sumologic.com/api/#{API_VERSION}"
67
+ "https://api.au.sumologic.com/api/#{version}"
54
68
  else
55
- "https://api.#{@deployment}.sumologic.com/api/#{API_VERSION}"
69
+ "https://api.#{@deployment}.sumologic.com/api/#{version}"
56
70
  end
57
71
  end
58
72
  end
@@ -11,8 +11,30 @@ module Sumologic
11
11
  # Orchestrates HTTP communication with Sumo Logic API
12
12
  # Delegates to specialized components for request building,
13
13
  # response handling, connection pooling, and cookie management
14
+ #
15
+ # Features automatic retry with exponential backoff for:
16
+ # - Rate limit errors (429)
17
+ # - Server errors (5xx)
18
+ # - Connection errors
14
19
  class Client
15
- def initialize(base_url:, authenticator:)
20
+ # Errors that are safe to retry
21
+ RETRYABLE_EXCEPTIONS = [
22
+ Errno::ECONNRESET,
23
+ Errno::EPIPE,
24
+ Errno::ETIMEDOUT,
25
+ Errno::ECONNREFUSED,
26
+ EOFError,
27
+ Net::HTTPBadResponse,
28
+ Net::OpenTimeout,
29
+ Net::ReadTimeout
30
+ ].freeze
31
+
32
+ def initialize(base_url:, authenticator:, config: nil)
33
+ @config = config
34
+ @max_retries = config&.max_retries || 3
35
+ @retry_base_delay = config&.retry_base_delay || 1.0
36
+ @retry_max_delay = config&.retry_max_delay || 30.0
37
+
16
38
  @cookie_jar = CookieJar.new
17
39
  @request_builder = RequestBuilder.new(
18
40
  base_url: base_url,
@@ -20,25 +42,47 @@ module Sumologic
20
42
  cookie_jar: @cookie_jar
21
43
  )
22
44
  @response_handler = ResponseHandler.new
23
- @connection_pool = ConnectionPool.new(base_url: base_url, max_connections: 10)
45
+ @connection_pool = ConnectionPool.new(
46
+ base_url: base_url,
47
+ max_connections: 10,
48
+ read_timeout: config&.read_timeout,
49
+ connect_timeout: config&.connect_timeout
50
+ )
24
51
  end
25
52
 
26
- # Execute HTTP request with error handling
53
+ # Execute HTTP request with automatic retry for transient errors
27
54
  # Uses connection pool for thread-safe parallel execution
28
55
  def request(method:, path:, body: nil, query_params: nil)
29
56
  uri = @request_builder.build_uri(path, query_params)
30
- request = @request_builder.build_request(method, uri, body)
57
+ attempt = 0
31
58
 
32
- DebugLogger.log_request(method, uri, body, request.to_hash)
59
+ loop do
60
+ attempt += 1
61
+ request = @request_builder.build_request(method, uri, body)
33
62
 
34
- response = execute_request(uri, request)
63
+ DebugLogger.log_request(method, uri, body, request.to_hash)
35
64
 
36
- DebugLogger.log_response(response)
65
+ begin
66
+ response = execute_request(uri, request)
67
+ DebugLogger.log_response(response)
37
68
 
38
- @response_handler.handle(response)
39
- rescue Errno::ECONNRESET, Errno::EPIPE, EOFError, Net::HTTPBadResponse => e
40
- # Connection error - raise for retry at higher level
41
- raise Error, "Connection error: #{e.message}"
69
+ # Check if response is retryable before handling
70
+ if @response_handler.retryable?(response) && attempt <= @max_retries
71
+ delay = calculate_retry_delay(attempt, response)
72
+ log_retry(attempt, delay, "HTTP #{response.code}")
73
+ sleep(delay)
74
+ next
75
+ end
76
+
77
+ return @response_handler.handle(response)
78
+ rescue *RETRYABLE_EXCEPTIONS => e
79
+ raise Error, "Connection error: #{e.message}" if attempt > @max_retries
80
+
81
+ delay = calculate_retry_delay(attempt)
82
+ log_retry(attempt, delay, e.class.name)
83
+ sleep(delay)
84
+ end
85
+ end
42
86
  end
43
87
 
44
88
  # Close all connections in the pool
@@ -58,6 +102,27 @@ module Sumologic
58
102
 
59
103
  response
60
104
  end
105
+
106
+ def calculate_retry_delay(attempt, response = nil)
107
+ # Use Retry-After header if available (for rate limits)
108
+ if response
109
+ info = @response_handler.extract_rate_limit_info(response)
110
+ return info[:retry_after] if info[:retry_after]&.positive?
111
+ end
112
+
113
+ # Exponential backoff with jitter
114
+ base_delay = @retry_base_delay * (2**(attempt - 1))
115
+ jitter = rand * 0.5 * base_delay # Add up to 50% jitter
116
+ delay = base_delay + jitter
117
+
118
+ [delay, @retry_max_delay].min
119
+ end
120
+
121
+ def log_retry(attempt, delay, reason)
122
+ return unless ENV['SUMO_DEBUG'] || $DEBUG
123
+
124
+ warn "[Sumologic::Http::Client] Retry #{attempt}/#{@max_retries} after #{delay.round(2)}s (#{reason})"
125
+ end
61
126
  end
62
127
  end
63
128
  end
@@ -7,12 +7,14 @@ module Sumologic
7
7
  # Thread-safe connection pool for HTTP clients
8
8
  # Allows multiple threads to have their own connections
9
9
  class ConnectionPool
10
- READ_TIMEOUT = 60
11
- OPEN_TIMEOUT = 10
10
+ DEFAULT_READ_TIMEOUT = 60
11
+ DEFAULT_OPEN_TIMEOUT = 10
12
12
 
13
- def initialize(base_url:, max_connections: 10)
13
+ def initialize(base_url:, max_connections: 10, read_timeout: nil, connect_timeout: nil)
14
14
  @base_url = base_url
15
15
  @max_connections = max_connections
16
+ @read_timeout = read_timeout || DEFAULT_READ_TIMEOUT
17
+ @connect_timeout = connect_timeout || DEFAULT_OPEN_TIMEOUT
16
18
  @pool = []
17
19
  @mutex = Mutex.new
18
20
  end
@@ -83,8 +85,8 @@ module Sumologic
83
85
  def create_connection(uri)
84
86
  http = Net::HTTP.new(uri.host, uri.port)
85
87
  http.use_ssl = true
86
- http.read_timeout = READ_TIMEOUT
87
- http.open_timeout = OPEN_TIMEOUT
88
+ http.read_timeout = @read_timeout
89
+ http.open_timeout = @connect_timeout
88
90
  http.keep_alive_timeout = 30
89
91
 
90
92
  # SSL configuration
@@ -15,14 +15,34 @@ module Sumologic
15
15
  handle_authentication_error(response)
16
16
  when 429
17
17
  handle_rate_limit_error(response)
18
+ when 500..599
19
+ handle_server_error(response)
18
20
  else
19
21
  handle_generic_error(response)
20
22
  end
21
23
  end
22
24
 
25
+ # Check if response indicates a retryable error
26
+ def retryable?(response)
27
+ code = response.code.to_i
28
+ code == 429 || code.between?(500, 599)
29
+ end
30
+
31
+ # Extract rate limit info from response headers
32
+ def extract_rate_limit_info(response)
33
+ {
34
+ retry_after: parse_retry_after(response),
35
+ limit: response['X-RateLimit-Limit']&.to_i,
36
+ remaining: response['X-RateLimit-Remaining']&.to_i,
37
+ reset_at: parse_reset_time(response)
38
+ }
39
+ end
40
+
23
41
  private
24
42
 
25
43
  def parse_success(response)
44
+ return {} if response.body.nil? || response.body.empty?
45
+
26
46
  JSON.parse(response.body)
27
47
  end
28
48
 
@@ -31,12 +51,56 @@ module Sumologic
31
51
  end
32
52
 
33
53
  def handle_rate_limit_error(response)
34
- raise Error, "Rate limit exceeded: #{response.body}"
54
+ info = extract_rate_limit_info(response)
55
+ message = 'Rate limit exceeded'
56
+ message += " (retry after #{info[:retry_after]}s)" if info[:retry_after]
57
+
58
+ raise RateLimitError.new(
59
+ message,
60
+ retry_after: info[:retry_after],
61
+ limit: info[:limit],
62
+ remaining: info[:remaining],
63
+ reset_at: info[:reset_at]
64
+ )
65
+ end
66
+
67
+ def handle_server_error(response)
68
+ raise Error, "Server error HTTP #{response.code}: #{response.body}"
35
69
  end
36
70
 
37
71
  def handle_generic_error(response)
38
72
  raise Error, "HTTP #{response.code}: #{response.body}"
39
73
  end
74
+
75
+ def parse_retry_after(response)
76
+ # Try Retry-After header first (standard HTTP)
77
+ retry_after = response['Retry-After']
78
+ return retry_after.to_i if retry_after&.match?(/^\d+$/)
79
+
80
+ # Try X-RateLimit-Reset (common alternative)
81
+ reset = response['X-RateLimit-Reset']
82
+ return nil unless reset
83
+
84
+ # Reset can be seconds or Unix timestamp
85
+ reset_val = reset.to_i
86
+ if reset_val > 1_000_000_000 # Likely a Unix timestamp
87
+ [reset_val - Time.now.to_i, 1].max
88
+ else
89
+ reset_val
90
+ end
91
+ end
92
+
93
+ def parse_reset_time(response)
94
+ reset = response['X-RateLimit-Reset']
95
+ return nil unless reset
96
+
97
+ reset_val = reset.to_i
98
+ if reset_val > 1_000_000_000 # Unix timestamp
99
+ Time.at(reset_val)
100
+ else
101
+ Time.now + reset_val
102
+ end
103
+ end
40
104
  end
41
105
  end
42
106
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'loggable'
4
+
5
+ module Sumologic
6
+ module Metadata
7
+ # Handles app catalog operations
8
+ # Uses GET /v1/apps endpoint
9
+ # Note: This lists the app catalog (available apps), not installed apps
10
+ class App
11
+ include Loggable
12
+
13
+ def initialize(http_client:)
14
+ @http = http_client
15
+ end
16
+
17
+ # List available apps from the Sumo Logic app catalog
18
+ #
19
+ # @return [Array<Hash>] Array of app data
20
+ def list
21
+ data = @http.request(
22
+ method: :get,
23
+ path: '/apps'
24
+ )
25
+
26
+ apps = data['apps'] || []
27
+ log_info "Fetched #{apps.size} apps from catalog"
28
+ apps
29
+ rescue StandardError => e
30
+ raise Error, "Failed to list apps: #{e.message}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'loggable'
4
+
5
+ module Sumologic
6
+ module Metadata
7
+ # Handles content library operations
8
+ # Uses v2 content API endpoints for path lookup and export
9
+ class Content
10
+ include Loggable
11
+
12
+ EXPORT_POLL_INTERVAL = 2 # seconds
13
+ EXPORT_MAX_WAIT = 120 # seconds
14
+
15
+ def initialize(http_client:)
16
+ @http = http_client
17
+ end
18
+
19
+ # Get a content item by its library path
20
+ # Returns item ID, type, name, and parent folder
21
+ #
22
+ # @param path [String] Content library path (e.g., '/Library/Users/me/My Search')
23
+ # @return [Hash] Content item data
24
+ def get_by_path(path)
25
+ data = @http.request(
26
+ method: :get,
27
+ path: '/content/path',
28
+ query_params: { path: path }
29
+ )
30
+
31
+ log_info "Retrieved content at path: #{path}"
32
+ data
33
+ rescue StandardError => e
34
+ raise Error, "Failed to get content at path '#{path}': #{e.message}"
35
+ end
36
+
37
+ # Export a content item as JSON
38
+ # Handles the async job lifecycle: start → poll → fetch result
39
+ #
40
+ # @param content_id [String] The content item ID to export
41
+ # @return [Hash] Exported content data
42
+ def export(content_id)
43
+ # Start export job
44
+ job = @http.request(
45
+ method: :post,
46
+ path: "/content/#{content_id}/export"
47
+ )
48
+ job_id = job['id']
49
+ log_info "Started export job #{job_id} for content #{content_id}"
50
+
51
+ # Poll until complete
52
+ poll_export_status(content_id, job_id)
53
+
54
+ # Fetch result
55
+ result = @http.request(
56
+ method: :get,
57
+ path: "/content/#{content_id}/export/#{job_id}/result"
58
+ )
59
+
60
+ log_info "Export complete for content #{content_id}"
61
+ result
62
+ rescue StandardError => e
63
+ raise Error, "Failed to export content #{content_id}: #{e.message}"
64
+ end
65
+
66
+ private
67
+
68
+ def poll_export_status(content_id, job_id)
69
+ start_time = Time.now
70
+
71
+ loop do
72
+ elapsed = Time.now - start_time
73
+ raise TimeoutError, "Export job timed out after #{EXPORT_MAX_WAIT}s" if elapsed > EXPORT_MAX_WAIT
74
+
75
+ status = @http.request(
76
+ method: :get,
77
+ path: "/content/#{content_id}/export/#{job_id}/status"
78
+ )
79
+
80
+ state = status['status']
81
+ log_info "Export status: #{state}"
82
+
83
+ case state
84
+ when 'Success'
85
+ return
86
+ when 'Failed'
87
+ raise Error, "Export job failed: #{status['error']&.dig('message') || 'unknown error'}"
88
+ end
89
+
90
+ sleep EXPORT_POLL_INTERVAL
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'loggable'
4
+ require_relative 'models'
5
+
6
+ module Sumologic
7
+ module Metadata
8
+ # Handles dashboard operations via v2 API
9
+ # Uses GET /v2/dashboards endpoints
10
+ class Dashboard
11
+ include Loggable
12
+
13
+ def initialize(http_client:)
14
+ @http = http_client
15
+ end
16
+
17
+ # List all dashboards
18
+ # Returns array of dashboard objects
19
+ #
20
+ # @param limit [Integer] Maximum number of dashboards to return (default: 100)
21
+ # @return [Array<Hash>] Array of dashboard data
22
+ def list(limit: 100)
23
+ dashboards = []
24
+ token = nil
25
+
26
+ loop do
27
+ query_params = { limit: [limit - dashboards.size, 100].min }
28
+ query_params[:token] = token if token
29
+
30
+ data = @http.request(
31
+ method: :get,
32
+ path: '/dashboards',
33
+ query_params: query_params
34
+ )
35
+
36
+ batch = data['dashboards'] || []
37
+ dashboards.concat(batch)
38
+
39
+ log_info "Fetched #{batch.size} dashboards (total: #{dashboards.size})"
40
+
41
+ # Check for pagination
42
+ token = data['next']
43
+ break if token.nil? || dashboards.size >= limit
44
+ end
45
+
46
+ dashboards.take(limit)
47
+ rescue StandardError => e
48
+ raise Error, "Failed to list dashboards: #{e.message}"
49
+ end
50
+
51
+ # Get a specific dashboard by ID
52
+ # Returns full dashboard details including panels
53
+ #
54
+ # @param dashboard_id [String] The dashboard ID
55
+ # @return [Hash] Dashboard data
56
+ def get(dashboard_id)
57
+ data = @http.request(
58
+ method: :get,
59
+ path: "/dashboards/#{dashboard_id}"
60
+ )
61
+
62
+ log_info "Retrieved dashboard: #{data['title']} (#{dashboard_id})"
63
+ data
64
+ rescue StandardError => e
65
+ raise Error, "Failed to get dashboard #{dashboard_id}: #{e.message}"
66
+ end
67
+
68
+ # Search dashboards by title or description
69
+ # Returns matching dashboards
70
+ #
71
+ # @param query [String] Search query
72
+ # @param limit [Integer] Maximum results (default: 100)
73
+ # @return [Array<Hash>] Matching dashboards
74
+ def search(query:, limit: 100)
75
+ # Use list and filter client-side
76
+ dashboards = list(limit: limit * 2)
77
+ query_lower = query.downcase
78
+
79
+ filtered = dashboards.select do |d|
80
+ title_match = d['title']&.downcase&.include?(query_lower)
81
+ desc_match = d['description']&.downcase&.include?(query_lower)
82
+ title_match || desc_match
83
+ end
84
+
85
+ filtered.take(limit)
86
+ end
87
+
88
+ # List dashboards in a specific folder
89
+ #
90
+ # @param folder_id [String] Folder ID to search in
91
+ # @param limit [Integer] Maximum results
92
+ # @return [Array<Hash>] Dashboards in folder
93
+ def list_by_folder(folder_id:, limit: 100)
94
+ dashboards = list(limit: limit * 2)
95
+
96
+ filtered = dashboards.select do |d|
97
+ d['folderId'] == folder_id
98
+ end
99
+
100
+ filtered.take(limit)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'loggable'
4
+
5
+ module Sumologic
6
+ module Metadata
7
+ # Handles field operations
8
+ # Uses GET /v1/fields and GET /v1/fields/builtin endpoints
9
+ class Field
10
+ include Loggable
11
+
12
+ def initialize(http_client:)
13
+ @http = http_client
14
+ end
15
+
16
+ # List custom fields
17
+ #
18
+ # @return [Array<Hash>] Array of custom field data
19
+ def list
20
+ data = @http.request(
21
+ method: :get,
22
+ path: '/fields'
23
+ )
24
+
25
+ fields = data['data'] || []
26
+ log_info "Fetched #{fields.size} custom fields"
27
+ fields
28
+ rescue StandardError => e
29
+ raise Error, "Failed to list fields: #{e.message}"
30
+ end
31
+
32
+ # List built-in fields
33
+ #
34
+ # @return [Array<Hash>] Array of built-in field data
35
+ def list_builtin
36
+ data = @http.request(
37
+ method: :get,
38
+ path: '/fields/builtin'
39
+ )
40
+
41
+ fields = data['data'] || []
42
+ log_info "Fetched #{fields.size} built-in fields"
43
+ fields
44
+ rescue StandardError => e
45
+ raise Error, "Failed to list built-in fields: #{e.message}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'loggable'
4
+ require_relative 'models'
5
+
6
+ module Sumologic
7
+ module Metadata
8
+ # Handles folder/content library operations
9
+ # Folders organize dashboards, searches, and other content
10
+ # NOTE: Content API uses v2, not v1
11
+ class Folder
12
+ include Loggable
13
+
14
+ # @param http_client [Http::Client] HTTP client configured for v2 API
15
+ def initialize(http_client:)
16
+ @http = http_client
17
+ end
18
+
19
+ # Get the personal folder for the current user
20
+ # Returns folder with children
21
+ #
22
+ # @return [Hash] Personal folder data
23
+ def personal
24
+ data = @http.request(
25
+ method: :get,
26
+ path: '/content/folders/personal'
27
+ )
28
+
29
+ log_info "Retrieved personal folder: #{data['name']}"
30
+ data
31
+ rescue StandardError => e
32
+ raise Error, "Failed to get personal folder: #{e.message}"
33
+ end
34
+
35
+ # Get a specific folder by ID
36
+ # Returns folder details with children
37
+ #
38
+ # @param folder_id [String] The folder ID
39
+ # @return [Hash] Folder data with children
40
+ def get(folder_id)
41
+ data = @http.request(
42
+ method: :get,
43
+ path: "/content/folders/#{folder_id}"
44
+ )
45
+
46
+ log_info "Retrieved folder: #{data['name']} (#{folder_id})"
47
+ data
48
+ rescue StandardError => e
49
+ raise Error, "Failed to get folder #{folder_id}: #{e.message}"
50
+ end
51
+
52
+ # List all items in a folder (recursive tree)
53
+ # Builds a tree structure of all content
54
+ #
55
+ # @param folder_id [String] Starting folder ID (nil for personal)
56
+ # @param max_depth [Integer] Maximum recursion depth (default: 3)
57
+ # @return [Hash] Folder tree with nested children
58
+ def tree(folder_id: nil, max_depth: 3)
59
+ root = folder_id ? get(folder_id) : personal
60
+ build_tree(root, 0, max_depth)
61
+ rescue StandardError => e
62
+ raise Error, "Failed to build folder tree: #{e.message}"
63
+ end
64
+
65
+ private
66
+
67
+ def build_tree(folder, depth, max_depth)
68
+ return folder if depth >= max_depth
69
+
70
+ children = folder['children'] || []
71
+ folder['children'] = children.map do |child|
72
+ if child['itemType'] == 'Folder'
73
+ begin
74
+ child_folder = get(child['id'])
75
+ build_tree(child_folder, depth + 1, max_depth)
76
+ rescue StandardError => e
77
+ log_error "Failed to fetch child folder #{child['id']}: #{e.message}"
78
+ child
79
+ end
80
+ else
81
+ child
82
+ end
83
+ end
84
+
85
+ folder
86
+ end
87
+ end
88
+ end
89
+ end