exaonruby 1.0.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/exaonruby.gemspec +37 -0
  5. data/exe/exa +7 -0
  6. data/lib/exa/cli.rb +458 -0
  7. data/lib/exa/client.rb +210 -0
  8. data/lib/exa/configuration.rb +81 -0
  9. data/lib/exa/endpoints/answer.rb +109 -0
  10. data/lib/exa/endpoints/contents.rb +141 -0
  11. data/lib/exa/endpoints/events.rb +71 -0
  12. data/lib/exa/endpoints/find_similar.rb +154 -0
  13. data/lib/exa/endpoints/imports.rb +145 -0
  14. data/lib/exa/endpoints/monitors.rb +193 -0
  15. data/lib/exa/endpoints/research.rb +158 -0
  16. data/lib/exa/endpoints/search.rb +195 -0
  17. data/lib/exa/endpoints/webhooks.rb +161 -0
  18. data/lib/exa/endpoints/webset_enrichments.rb +162 -0
  19. data/lib/exa/endpoints/webset_items.rb +90 -0
  20. data/lib/exa/endpoints/webset_searches.rb +137 -0
  21. data/lib/exa/endpoints/websets.rb +214 -0
  22. data/lib/exa/errors.rb +180 -0
  23. data/lib/exa/resources/answer_response.rb +101 -0
  24. data/lib/exa/resources/base.rb +56 -0
  25. data/lib/exa/resources/contents_response.rb +123 -0
  26. data/lib/exa/resources/event.rb +84 -0
  27. data/lib/exa/resources/import.rb +137 -0
  28. data/lib/exa/resources/monitor.rb +205 -0
  29. data/lib/exa/resources/paginated_response.rb +87 -0
  30. data/lib/exa/resources/research_task.rb +165 -0
  31. data/lib/exa/resources/search_response.rb +111 -0
  32. data/lib/exa/resources/search_result.rb +95 -0
  33. data/lib/exa/resources/webhook.rb +152 -0
  34. data/lib/exa/resources/webset.rb +491 -0
  35. data/lib/exa/resources/webset_item.rb +256 -0
  36. data/lib/exa/utils/parameter_converter.rb +159 -0
  37. data/lib/exa/utils/webhook_handler.rb +239 -0
  38. data/lib/exa/version.rb +7 -0
  39. data/lib/exa.rb +130 -0
  40. metadata +146 -0
data/lib/exa/cli.rb ADDED
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "thor"
6
+ require "json"
7
+
8
+ module Exa
9
+ # Beautiful command-line interface for the Exa.ai API
10
+ #
11
+ # Features:
12
+ # - Neural search from the terminal
13
+ # - Answer questions instantly
14
+ # - Manage Websets and enrichments
15
+ # - Monitor research tasks
16
+ # - Colorful, informative output
17
+ #
18
+ # @example Basic usage
19
+ # $ exa search "latest AI research"
20
+ # $ exa answer "What is the valuation of SpaceX?"
21
+ # $ exa websets list
22
+ class CLI < Thor
23
+ class_option :api_key, type: :string, aliases: "-k", desc: "API key (or set EXA_API_KEY)"
24
+ class_option :json, type: :boolean, aliases: "-j", desc: "Output raw JSON"
25
+ class_option :no_color, type: :boolean, desc: "Disable colored output"
26
+
27
+ def self.exit_on_failure?
28
+ true
29
+ end
30
+
31
+ # ═══════════════════════════════════════════════════════════════════════
32
+ # SEARCH COMMANDS
33
+ # ═══════════════════════════════════════════════════════════════════════
34
+
35
+ desc "search QUERY", "Search the web using Exa's neural search"
36
+ method_option :num, type: :numeric, default: 10, aliases: "-n", desc: "Number of results"
37
+ method_option :type, type: :string, default: "auto", desc: "Search type: auto, neural, fast, deep"
38
+ method_option :text, type: :boolean, default: false, aliases: "-t", desc: "Include text content"
39
+ method_option :summary, type: :boolean, default: false, aliases: "-s", desc: "Include AI summary"
40
+ method_option :domain, type: :string, aliases: "-d", desc: "Filter by domain"
41
+ method_option :category, type: :string, aliases: "-c", desc: "Category filter"
42
+ def search(query)
43
+ with_client do |client|
44
+ opts = {
45
+ num_results: options[:num],
46
+ type: options[:type].to_sym,
47
+ text: options[:text],
48
+ summary: options[:summary]
49
+ }
50
+ opts[:include_domains] = [options[:domain]] if options[:domain]
51
+ opts[:category] = options[:category].to_sym if options[:category]
52
+
53
+ response = client.search(query, **opts)
54
+
55
+ if options[:json]
56
+ puts JSON.pretty_generate(response.raw)
57
+ else
58
+ print_search_results(response, query)
59
+ end
60
+ end
61
+ end
62
+
63
+ desc "answer QUESTION", "Get an AI-powered answer with citations"
64
+ method_option :text, type: :boolean, default: true, aliases: "-t", desc: "Include source text"
65
+ def answer(question)
66
+ with_client do |client|
67
+ response = client.answer(question, text: options[:text])
68
+
69
+ if options[:json]
70
+ puts JSON.pretty_generate(response.raw)
71
+ else
72
+ print_answer(response, question)
73
+ end
74
+ end
75
+ end
76
+
77
+ desc "similar URL", "Find pages similar to a URL"
78
+ method_option :num, type: :numeric, default: 10, aliases: "-n", desc: "Number of results"
79
+ method_option :text, type: :boolean, default: false, aliases: "-t", desc: "Include text content"
80
+ def similar(url)
81
+ with_client do |client|
82
+ response = client.find_similar(url, num_results: options[:num], text: options[:text])
83
+
84
+ if options[:json]
85
+ puts JSON.pretty_generate(response.raw)
86
+ else
87
+ print_search_results(response, "Similar to: #{url}")
88
+ end
89
+ end
90
+ end
91
+
92
+ desc "contents URLS...", "Fetch full page contents from URLs"
93
+ method_option :summary, type: :boolean, default: false, aliases: "-s", desc: "Include AI summary"
94
+ def contents(*urls)
95
+ with_client do |client|
96
+ response = client.get_contents(urls, text: true, summary: options[:summary])
97
+
98
+ if options[:json]
99
+ puts JSON.pretty_generate(response.raw)
100
+ else
101
+ print_contents(response)
102
+ end
103
+ end
104
+ end
105
+
106
+ # ═══════════════════════════════════════════════════════════════════════
107
+ # RESEARCH COMMANDS
108
+ # ═══════════════════════════════════════════════════════════════════════
109
+
110
+ desc "research INSTRUCTIONS", "Start an async research task"
111
+ method_option :model, type: :string, default: "exa-research", desc: "Model: exa-research-fast, exa-research, exa-research-pro"
112
+ method_option :wait, type: :boolean, default: false, aliases: "-w", desc: "Wait for completion"
113
+ def research(instructions)
114
+ with_client do |client|
115
+ task = client.create_research(instructions: instructions, model: options[:model])
116
+
117
+ if options[:wait]
118
+ print_waiting_for_research(task)
119
+ task = wait_for_research(client, task.research_id)
120
+ end
121
+
122
+ if options[:json]
123
+ puts JSON.pretty_generate(task.raw)
124
+ else
125
+ print_research_task(task)
126
+ end
127
+ end
128
+ end
129
+
130
+ desc "research_status ID", "Get status of a research task"
131
+ def research_status(research_id)
132
+ with_client do |client|
133
+ task = client.get_research(research_id)
134
+
135
+ if options[:json]
136
+ puts JSON.pretty_generate(task.raw)
137
+ else
138
+ print_research_task(task)
139
+ end
140
+ end
141
+ end
142
+
143
+ # ═══════════════════════════════════════════════════════════════════════
144
+ # WEBSET COMMANDS
145
+ # ═══════════════════════════════════════════════════════════════════════
146
+
147
+ desc "websets SUBCOMMAND", "Manage Websets"
148
+ subcommand "websets", Class.new(Thor) {
149
+ class_option :api_key, type: :string, aliases: "-k", desc: "API key (or set EXA_API_KEY)"
150
+ class_option :json, type: :boolean, aliases: "-j", desc: "Output raw JSON"
151
+ class_option :no_color, type: :boolean, desc: "Disable colored output"
152
+
153
+ desc "list", "List all Websets"
154
+ method_option :limit, type: :numeric, default: 20, aliases: "-n", desc: "Max results"
155
+ def list
156
+ with_client do |client|
157
+ response = client.list_websets(limit: options[:limit])
158
+
159
+ if options[:json]
160
+ puts JSON.pretty_generate(response.raw)
161
+ else
162
+ print_websets_list(response)
163
+ end
164
+ end
165
+ end
166
+
167
+ desc "get ID", "Get a Webset by ID"
168
+ def get(id)
169
+ with_client do |client|
170
+ webset = client.get_webset(id)
171
+
172
+ if options[:json]
173
+ puts JSON.pretty_generate(webset.raw)
174
+ else
175
+ print_webset(webset)
176
+ end
177
+ end
178
+ end
179
+
180
+ desc "items ID", "List items in a Webset"
181
+ method_option :limit, type: :numeric, default: 20, aliases: "-n", desc: "Max results"
182
+ def items(id)
183
+ with_client do |client|
184
+ response = client.list_webset_items(id, limit: options[:limit])
185
+
186
+ if options[:json]
187
+ puts JSON.pretty_generate(response.raw)
188
+ else
189
+ print_webset_items(response)
190
+ end
191
+ end
192
+ end
193
+
194
+ desc "delete ID", "Delete a Webset"
195
+ def delete(id)
196
+ with_client do |client|
197
+ client.delete_webset(id)
198
+ puts color("✓ Webset #{id} deleted", :green)
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def with_client
205
+ api_key = options[:api_key] || ENV["EXA_API_KEY"]
206
+ raise Thor::Error, "API key required. Set EXA_API_KEY or use --api-key" unless api_key
207
+
208
+ client = Exa::Client.new(api_key: api_key)
209
+ yield client
210
+ rescue Exa::Error => e
211
+ raise Thor::Error, color("✗ #{e.message}", :red)
212
+ end
213
+
214
+ def color(text, color_name)
215
+ return text if options[:no_color]
216
+
217
+ colors = {
218
+ red: "\e[31m",
219
+ green: "\e[32m",
220
+ yellow: "\e[33m",
221
+ blue: "\e[34m",
222
+ magenta: "\e[35m",
223
+ cyan: "\e[36m",
224
+ white: "\e[37m",
225
+ bold: "\e[1m",
226
+ dim: "\e[2m",
227
+ reset: "\e[0m"
228
+ }
229
+ "#{colors[color_name]}#{text}#{colors[:reset]}"
230
+ end
231
+
232
+ def print_websets_list(response)
233
+ header("📊 Websets (#{response.data.length})")
234
+ response.data.each do |ws|
235
+ status_color = ws.status == "idle" ? :green : :yellow
236
+ puts " #{color(ws.id, :cyan)} #{color(ws.status, status_color)}"
237
+ puts " #{color(ws.title || "Untitled", :white)}" if ws.title
238
+ end
239
+ end
240
+
241
+ def print_webset(webset)
242
+ header("📊 Webset: #{webset.id}")
243
+ puts " #{color("Status:", :dim)} #{webset.status}"
244
+ puts " #{color("Title:", :dim)} #{webset.title}" if webset.title
245
+ puts " #{color("Created:", :dim)} #{webset.created_at}"
246
+ puts ""
247
+ if webset.searches && !webset.searches.empty?
248
+ puts " #{color("Searches:", :bold)}"
249
+ webset.searches.each do |s|
250
+ puts " #{color("•", :cyan)} #{s.query} (#{s.status})"
251
+ end
252
+ end
253
+ end
254
+
255
+ def print_webset_items(response)
256
+ header("📋 Items (#{response.data.length})")
257
+ response.data.each_with_index do |item, i|
258
+ puts " #{color("[#{i + 1}]", :cyan)} #{item.url}"
259
+ if item.properties
260
+ props = item.properties
261
+ puts " #{color("Title:", :dim)} #{props.title}" if props.title
262
+ end
263
+ end
264
+ end
265
+
266
+ def header(text)
267
+ puts ""
268
+ puts color("═" * 60, :dim)
269
+ puts color(" #{text}", :bold)
270
+ puts color("═" * 60, :dim)
271
+ puts ""
272
+ end
273
+ }
274
+
275
+ # ═══════════════════════════════════════════════════════════════════════
276
+ # VERSION AND INFO
277
+ # ═══════════════════════════════════════════════════════════════════════
278
+
279
+ desc "version", "Show version information"
280
+ def version
281
+ puts ""
282
+ puts color(" ╔═══════════════════════════════════════╗", :cyan)
283
+ puts color(" ║", :cyan) + color(" EXA RUBY CLI", :bold) + color(" ║", :cyan)
284
+ puts color(" ║", :cyan) + " Version #{Exa::VERSION} " + color("║", :cyan)
285
+ puts color(" ╚═══════════════════════════════════════╝", :cyan)
286
+ puts ""
287
+ puts " #{color("API:", :dim)} https://api.exa.ai"
288
+ puts " #{color("Docs:", :dim)} https://docs.exa.ai"
289
+ puts ""
290
+ end
291
+
292
+ desc "info", "Show current configuration"
293
+ def info
294
+ api_key = options[:api_key] || ENV["EXA_API_KEY"]
295
+
296
+ puts ""
297
+ header("⚙️ Configuration")
298
+ puts " #{color("API Key:", :dim)} #{api_key ? "#{api_key[0..7]}..." : color("Not set", :red)}"
299
+ puts " #{color("Environment:", :dim)} #{ENV["EXA_API_KEY"] ? "EXA_API_KEY set" : "Not set"}"
300
+ puts ""
301
+ end
302
+
303
+ private
304
+
305
+ def with_client
306
+ api_key = options[:api_key] || ENV["EXA_API_KEY"]
307
+ raise Thor::Error, "API key required. Set EXA_API_KEY or use --api-key" unless api_key
308
+
309
+ client = Client.new(api_key: api_key)
310
+ yield client
311
+ rescue Error => e
312
+ raise Thor::Error, color("✗ #{e.message}", :red)
313
+ end
314
+
315
+ def color(text, color_name)
316
+ return text if options[:no_color]
317
+
318
+ colors = {
319
+ red: "\e[31m",
320
+ green: "\e[32m",
321
+ yellow: "\e[33m",
322
+ blue: "\e[34m",
323
+ magenta: "\e[35m",
324
+ cyan: "\e[36m",
325
+ white: "\e[37m",
326
+ bold: "\e[1m",
327
+ dim: "\e[2m",
328
+ reset: "\e[0m"
329
+ }
330
+ "#{colors[color_name]}#{text}#{colors[:reset]}"
331
+ end
332
+
333
+ def header(text)
334
+ puts ""
335
+ puts color("═" * 60, :dim)
336
+ puts color(" #{text}", :bold)
337
+ puts color("═" * 60, :dim)
338
+ puts ""
339
+ end
340
+
341
+ def print_search_results(response, query)
342
+ header("🔍 Search: \"#{query}\"")
343
+ puts " #{color("Results:", :dim)} #{response.count} #{color("Cost:", :dim)} $#{response.total_cost.round(4)}"
344
+ puts ""
345
+
346
+ response.results.each_with_index do |result, i|
347
+ puts " #{color("[#{i + 1}]", :cyan)} #{color(result.title || "Untitled", :bold)}"
348
+ puts " #{color(result.url, :blue)}"
349
+ if result.published_date
350
+ puts " #{color("Published:", :dim)} #{result.published_date.strftime("%Y-%m-%d")}"
351
+ end
352
+ if result.text
353
+ snippet = result.text[0..200].gsub(/\s+/, " ").strip
354
+ puts " #{color(snippet, :dim)}..."
355
+ end
356
+ if result.summary
357
+ puts " #{color("Summary:", :green)} #{result.summary[0..150]}..."
358
+ end
359
+ puts ""
360
+ end
361
+ end
362
+
363
+ def print_answer(response, question)
364
+ header("💡 Answer")
365
+ puts " #{color("Q:", :cyan)} #{question}"
366
+ puts ""
367
+ puts " #{color("A:", :green)} #{response.answer}"
368
+ puts ""
369
+
370
+ if response.citations.any?
371
+ puts " #{color("Sources:", :bold)}"
372
+ response.citations.each_with_index do |citation, i|
373
+ puts " #{color("[#{i + 1}]", :cyan)} #{citation.title}"
374
+ puts " #{color(citation.url, :blue)}"
375
+ end
376
+ puts ""
377
+ end
378
+
379
+ puts " #{color("Cost:", :dim)} $#{response.total_cost.round(4)}"
380
+ end
381
+
382
+ def print_contents(response)
383
+ header("📄 Contents (#{response.results.length} pages)")
384
+
385
+ response.results.each_with_index do |page, i|
386
+ puts " #{color("[#{i + 1}]", :cyan)} #{color(page.title || "Untitled", :bold)}"
387
+ puts " #{color(page.url, :blue)}"
388
+ if page.text
389
+ puts ""
390
+ lines = page.text.split("\n").map(&:strip).reject(&:empty?).first(10)
391
+ lines.each { |line| puts " #{color(line[0..80], :dim)}" }
392
+ puts " ..."
393
+ end
394
+ if page.summary
395
+ puts ""
396
+ puts " #{color("Summary:", :green)} #{page.summary[0..200]}..."
397
+ end
398
+ puts ""
399
+ end
400
+ end
401
+
402
+ def print_research_task(task)
403
+ status_colors = {
404
+ "pending" => :yellow,
405
+ "running" => :cyan,
406
+ "completed" => :green,
407
+ "failed" => :red,
408
+ "canceled" => :dim
409
+ }
410
+ status_color = status_colors[task.status] || :white
411
+
412
+ header("🔬 Research Task")
413
+ puts " #{color("ID:", :dim)} #{task.research_id}"
414
+ puts " #{color("Status:", :dim)} #{color(task.status.upcase, status_color)}"
415
+ puts " #{color("Model:", :dim)} #{task.model}"
416
+
417
+ if task.progress
418
+ progress_bar = "▓" * (task.progress * 20).round + "░" * (20 - (task.progress * 20).round)
419
+ puts " #{color("Progress:", :dim)} [#{color(progress_bar, :cyan)}] #{(task.progress * 100).round}%"
420
+ end
421
+
422
+ if task.output
423
+ puts ""
424
+ puts " #{color("Output:", :bold)}"
425
+ output_lines = task.output.to_s.split("\n").first(20)
426
+ output_lines.each { |line| puts " #{line[0..70]}" }
427
+ puts " ..." if task.output.to_s.split("\n").length > 20
428
+ end
429
+
430
+ if task.error_message
431
+ puts ""
432
+ puts " #{color("Error:", :red)} #{task.error_message}"
433
+ end
434
+ end
435
+
436
+ def print_waiting_for_research(task)
437
+ puts " #{color("⏳ Waiting for research to complete...", :yellow)}"
438
+ puts " #{color("ID:", :dim)} #{task.research_id}"
439
+ end
440
+
441
+ def wait_for_research(client, research_id)
442
+ loop do
443
+ task = client.get_research(research_id)
444
+
445
+ case task.status
446
+ when "completed", "failed", "canceled"
447
+ return task
448
+ when "running"
449
+ if task.progress
450
+ print "\r #{color("Progress:", :dim)} #{(task.progress * 100).round}% "
451
+ end
452
+ end
453
+
454
+ sleep 3
455
+ end
456
+ end
457
+ end
458
+ end
data/lib/exa/client.rb ADDED
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "faraday"
6
+ require "faraday/retry"
7
+ require "json"
8
+
9
+ module Exa
10
+ class Client
11
+ include Endpoints::Search
12
+ include Endpoints::Contents
13
+ include Endpoints::FindSimilar
14
+ include Endpoints::Answer
15
+ include Endpoints::Research
16
+ include Endpoints::Websets
17
+ include Endpoints::WebsetItems
18
+ include Endpoints::WebsetSearches
19
+ include Endpoints::WebsetEnrichments
20
+ include Endpoints::Monitors
21
+ include Endpoints::Imports
22
+ include Endpoints::Webhooks
23
+ include Endpoints::Events
24
+
25
+ # @return [Configuration] Client configuration
26
+ attr_reader :config
27
+
28
+ # Creates a new Exa API client
29
+ #
30
+ # @param api_key [String, nil] API key (defaults to EXA_API_KEY env var)
31
+ # @param base_url [String, nil] Base URL for Search API
32
+ # @param websets_base_url [String, nil] Base URL for Websets API
33
+ # @param timeout [Integer, nil] Request timeout in seconds
34
+ # @param max_retries [Integer, nil] Maximum retry attempts
35
+ #
36
+ # @yield [Configuration] Optional configuration block
37
+ #
38
+ # @example Create client with API key
39
+ # client = Exa::Client.new(api_key: "your-api-key")
40
+ #
41
+ # @example Create client with configuration block
42
+ # client = Exa::Client.new do |config|
43
+ # config.api_key = "your-api-key"
44
+ # config.timeout = 120
45
+ # end
46
+ def initialize(api_key: nil, base_url: nil, websets_base_url: nil, timeout: nil, max_retries: nil)
47
+ @config = Configuration.new
48
+
49
+ @config.api_key = api_key if api_key
50
+ @config.base_url = base_url if base_url
51
+ @config.websets_base_url = websets_base_url if websets_base_url
52
+ @config.timeout = timeout if timeout
53
+ @config.max_retries = max_retries if max_retries
54
+
55
+ yield @config if block_given?
56
+
57
+ @config.validate!
58
+
59
+ @connection = build_connection(@config.base_url)
60
+ @websets_connection = build_connection(@config.websets_base_url)
61
+ end
62
+
63
+ private
64
+
65
+ # Builds a Faraday connection with retry middleware
66
+ # @param base_url [String] Base URL for the connection
67
+ # @return [Faraday::Connection]
68
+ def build_connection(base_url)
69
+ Faraday.new(url: base_url) do |conn|
70
+ conn.request :json
71
+ conn.response :json, content_type: /\bjson$/
72
+
73
+ conn.request :retry, {
74
+ max: config.max_retries,
75
+ interval: config.retry_delay,
76
+ max_interval: config.max_retry_delay,
77
+ backoff_factor: 2,
78
+ retry_statuses: config.retry_statuses,
79
+ exceptions: config.retry_exceptions,
80
+ retry_block: ->(env, options, retries, exc) {
81
+ Exa.logger&.warn("Retrying request (attempt #{retries + 1}): #{exc.message}")
82
+ }
83
+ }
84
+
85
+ conn.options.timeout = config.timeout
86
+ conn.options.open_timeout = 10
87
+
88
+ conn.adapter Faraday.default_adapter
89
+ end
90
+ end
91
+
92
+ # Makes a POST request to the Search API
93
+ # @param path [String] API endpoint path
94
+ # @param body [Hash] Request body
95
+ # @return [Hash] Parsed response
96
+ def post(path, body)
97
+ response = @connection.post(path) do |req|
98
+ req.headers["x-api-key"] = config.api_key
99
+ req.headers["Content-Type"] = "application/json"
100
+ req.body = body
101
+ end
102
+
103
+ handle_response(response)
104
+ end
105
+
106
+ # Makes a GET request to the Search API
107
+ # @param path [String] API endpoint path
108
+ # @param params [Hash] Query parameters
109
+ # @return [Hash] Parsed response
110
+ def get(path, params = {})
111
+ response = @connection.get(path) do |req|
112
+ req.headers["x-api-key"] = config.api_key
113
+ req.params = params unless params.empty?
114
+ end
115
+
116
+ handle_response(response)
117
+ end
118
+
119
+ # Makes a POST request to the Websets API
120
+ # @param path [String] API endpoint path
121
+ # @param body [Hash] Request body
122
+ # @return [Hash] Parsed response
123
+ def websets_post(path, body)
124
+ response = @websets_connection.post(path) do |req|
125
+ req.headers["x-api-key"] = config.api_key
126
+ req.headers["Content-Type"] = "application/json"
127
+ req.body = body
128
+ end
129
+
130
+ handle_response(response)
131
+ end
132
+
133
+ # Makes a GET request to the Websets API
134
+ # @param path [String] API endpoint path
135
+ # @param params [Hash] Query parameters
136
+ # @return [Hash] Parsed response
137
+ def websets_get(path, params = {})
138
+ response = @websets_connection.get(path) do |req|
139
+ req.headers["x-api-key"] = config.api_key
140
+ req.params = params unless params.empty?
141
+ end
142
+
143
+ handle_response(response)
144
+ end
145
+
146
+ # Makes a DELETE request to the Websets API
147
+ # @param path [String] API endpoint path
148
+ # @return [Hash] Parsed response
149
+ def websets_delete(path)
150
+ response = @websets_connection.delete(path) do |req|
151
+ req.headers["x-api-key"] = config.api_key
152
+ end
153
+
154
+ handle_response(response)
155
+ end
156
+
157
+ # Makes a PATCH request to the Websets API
158
+ # @param path [String] API endpoint path
159
+ # @param body [Hash] Request body
160
+ # @return [Hash] Parsed response
161
+ def websets_patch(path, body)
162
+ response = @websets_connection.patch(path) do |req|
163
+ req.headers["x-api-key"] = config.api_key
164
+ req.headers["Content-Type"] = "application/json"
165
+ req.body = body
166
+ end
167
+
168
+ handle_response(response)
169
+ end
170
+
171
+ # Handles API response and raises appropriate errors
172
+ # @param response [Faraday::Response] HTTP response
173
+ # @return [Hash] Parsed response body
174
+ # @raise [Exa::Error] on non-success status
175
+ def handle_response(response)
176
+ body = response.body
177
+
178
+ case response.status
179
+ when 200..299
180
+ body.is_a?(Hash) ? symbolize_keys(body) : body
181
+ else
182
+ raise Exa.error_for_status(
183
+ response.status,
184
+ response_body: body.is_a?(Hash) ? symbolize_keys(body) : nil,
185
+ headers: response.headers
186
+ )
187
+ end
188
+ rescue Faraday::TimeoutError => e
189
+ raise TimeoutError, "Request timed out: #{e.message}"
190
+ rescue Faraday::ConnectionFailed => e
191
+ raise ConnectionError, "Connection failed: #{e.message}"
192
+ end
193
+
194
+ # Recursively symbolizes hash keys
195
+ # @param hash [Hash, Array, Object] Object to process
196
+ # @return [Hash, Array, Object] Object with symbolized keys
197
+ def symbolize_keys(hash)
198
+ case hash
199
+ when Hash
200
+ hash.each_with_object({}) do |(key, value), result|
201
+ result[key.to_sym] = symbolize_keys(value)
202
+ end
203
+ when Array
204
+ hash.map { |item| symbolize_keys(item) }
205
+ else
206
+ hash
207
+ end
208
+ end
209
+ end
210
+ end