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
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Endpoints
7
+ module Imports
8
+ VALID_FORMATS = %w[csv webset].freeze
9
+ VALID_ENTITY_TYPES = %w[company person article research_paper custom].freeze
10
+
11
+ # Creates a new Import to upload data into Websets
12
+ #
13
+ # Imports can be used to:
14
+ # - Enrich: Enhance data with additional information
15
+ # - Search: Query data using Websets' agentic search
16
+ # - Exclude: Prevent duplicate results in searches
17
+ #
18
+ # Once created, upload your data to the returned uploadUrl.
19
+ #
20
+ # @param size [Integer] File size in bytes (max 50MB)
21
+ # @param count [Integer] Number of records to import
22
+ # @param format [String] Import format: "csv" or "webset"
23
+ # @param entity [Hash] Entity type configuration
24
+ # @option entity [String] :type "company", "person", "article", "research_paper", or "custom"
25
+ # @param title [String, nil] Title of the import
26
+ # @param metadata [Hash, nil] Custom metadata
27
+ # @param csv [Hash, nil] CSV-specific parameters (identifier column index)
28
+ #
29
+ # @return [Exa::Resources::Import] Created Import with uploadUrl
30
+ #
31
+ # @example Create a CSV import
32
+ # import = client.create_import(
33
+ # size: 1024,
34
+ # count: 100,
35
+ # format: "csv",
36
+ # entity: { type: "company" },
37
+ # title: "Q4 Leads",
38
+ # csv: { identifier: 1 }
39
+ # )
40
+ # puts "Upload to: #{import.upload_url}"
41
+ # puts "Valid until: #{import.upload_valid_until}"
42
+ def create_import(size:, count:, format: "csv", entity:, title: nil, metadata: nil, csv: nil)
43
+ validate_import_params!(size, format, entity)
44
+
45
+ params = {
46
+ size: size,
47
+ count: count,
48
+ format: format,
49
+ entity: entity
50
+ }
51
+ params[:title] = title if title
52
+ params[:metadata] = metadata if metadata
53
+ params[:csv] = csv if csv
54
+
55
+ response = websets_post("/imports", params)
56
+
57
+ Resources::Import.new(
58
+ Utils::ParameterConverter.from_api_response(response)
59
+ )
60
+ end
61
+
62
+ # Gets an Import by ID
63
+ #
64
+ # @param import_id [String] Import ID
65
+ # @return [Exa::Resources::Import] The Import
66
+ def get_import(import_id)
67
+ raise InvalidRequestError, "import_id must be a non-empty string" if !import_id.is_a?(String) || import_id.empty?
68
+
69
+ response = websets_get("/imports/#{import_id}")
70
+
71
+ Resources::Import.new(
72
+ Utils::ParameterConverter.from_api_response(response)
73
+ )
74
+ end
75
+
76
+ # Lists all Imports
77
+ #
78
+ # @param cursor [String, nil] Cursor for pagination
79
+ # @param limit [Integer, nil] Number of results per page
80
+ #
81
+ # @return [Exa::Resources::ImportListResponse] Paginated list of Imports
82
+ def list_imports(cursor: nil, limit: nil)
83
+ params = {}
84
+ params[:cursor] = cursor if cursor
85
+ params[:limit] = limit if limit
86
+
87
+ response = websets_get("/imports", params)
88
+
89
+ Resources::ImportListResponse.new(
90
+ Utils::ParameterConverter.from_api_response(response)
91
+ )
92
+ end
93
+
94
+ # Updates an Import
95
+ #
96
+ # @param import_id [String] Import ID
97
+ # @param title [String, nil] Updated title
98
+ # @param metadata [Hash, nil] Updated metadata
99
+ #
100
+ # @return [Exa::Resources::Import] Updated Import
101
+ def update_import(import_id, title: nil, metadata: nil)
102
+ raise InvalidRequestError, "import_id must be a non-empty string" if !import_id.is_a?(String) || import_id.empty?
103
+
104
+ params = {}
105
+ params[:title] = title if title
106
+ params[:metadata] = metadata if metadata
107
+
108
+ response = websets_patch("/imports/#{import_id}", params)
109
+
110
+ Resources::Import.new(
111
+ Utils::ParameterConverter.from_api_response(response)
112
+ )
113
+ end
114
+
115
+ # Deletes an Import
116
+ #
117
+ # @param import_id [String] Import ID
118
+ # @return [Hash] Deletion confirmation
119
+ def delete_import(import_id)
120
+ raise InvalidRequestError, "import_id must be a non-empty string" if !import_id.is_a?(String) || import_id.empty?
121
+
122
+ websets_delete("/imports/#{import_id}")
123
+ end
124
+
125
+ private
126
+
127
+ def validate_import_params!(size, format, entity)
128
+ if size > 50_000_000
129
+ raise InvalidRequestError, "size must be 50MB or less"
130
+ end
131
+
132
+ unless VALID_FORMATS.include?(format)
133
+ raise InvalidRequestError, "Invalid format: #{format}. Valid formats: #{VALID_FORMATS.join(", ")}"
134
+ end
135
+
136
+ if entity && entity[:type]
137
+ entity_type = entity[:type].to_s.downcase.tr(" ", "_")
138
+ unless VALID_ENTITY_TYPES.include?(entity_type)
139
+ raise InvalidRequestError, "Invalid entity type: #{entity[:type]}. Valid types: #{VALID_ENTITY_TYPES.join(", ")}"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Endpoints
7
+ module Monitors
8
+ VALID_BEHAVIOR_TYPES = %w[search refresh].freeze
9
+
10
+ # Creates a new Monitor to continuously keep Websets updated
11
+ #
12
+ # Monitors automatically run on your defined schedule to ensure Websets
13
+ # stay current without manual intervention:
14
+ # - Find new content via search operations
15
+ # - Update existing content via refresh operations
16
+ # - Automated scheduling using cron expressions
17
+ #
18
+ # @param webset_id [String] The Webset ID to monitor
19
+ # @param cadence [Hash] Schedule configuration
20
+ # @option cadence [String] :cron Cron expression for scheduling
21
+ # @option cadence [String] :timezone Timezone (default: "Etc/UTC")
22
+ # @param behavior [Hash] Behavior when monitor runs
23
+ # @option behavior [String] :type "search" or "refresh"
24
+ # @option behavior [Hash] :config Configuration for the behavior
25
+ # @param metadata [Hash] Custom metadata key-value pairs
26
+ #
27
+ # @return [Exa::Resources::Monitor] Created Monitor
28
+ #
29
+ # @example Create a daily search monitor
30
+ # monitor = client.create_monitor(
31
+ # webset_id: "webset_abc123",
32
+ # cadence: { cron: "0 9 * * *", timezone: "America/New_York" },
33
+ # behavior: {
34
+ # type: "search",
35
+ # config: {
36
+ # count: 50,
37
+ # query: "AI news today",
38
+ # entity: { type: "article" },
39
+ # behavior: "append"
40
+ # }
41
+ # }
42
+ # )
43
+ def create_monitor(webset_id:, cadence:, behavior:, metadata: nil)
44
+ validate_monitor_params!(webset_id, cadence, behavior)
45
+
46
+ params = {
47
+ websetId: webset_id,
48
+ cadence: cadence,
49
+ behavior: behavior
50
+ }
51
+ params[:metadata] = metadata if metadata
52
+
53
+ response = websets_post("/monitors", params)
54
+
55
+ Resources::Monitor.new(
56
+ Utils::ParameterConverter.from_api_response(response)
57
+ )
58
+ end
59
+
60
+ # Gets a Monitor by ID
61
+ #
62
+ # @param monitor_id [String] Monitor ID
63
+ # @return [Exa::Resources::Monitor] The Monitor
64
+ #
65
+ # @example Get a monitor
66
+ # monitor = client.get_monitor("monitor_abc123")
67
+ # puts "Next run: #{monitor.next_run_at}"
68
+ def get_monitor(monitor_id)
69
+ raise InvalidRequestError, "monitor_id must be a non-empty string" if !monitor_id.is_a?(String) || monitor_id.empty?
70
+
71
+ response = websets_get("/monitors/#{monitor_id}")
72
+
73
+ Resources::Monitor.new(
74
+ Utils::ParameterConverter.from_api_response(response)
75
+ )
76
+ end
77
+
78
+ # Lists all Monitors
79
+ #
80
+ # @param cursor [String, nil] Cursor for pagination
81
+ # @param limit [Integer, nil] Number of results per page
82
+ # @param webset_id [String, nil] Filter by Webset ID
83
+ #
84
+ # @return [Exa::Resources::MonitorListResponse] Paginated list of Monitors
85
+ #
86
+ # @example List monitors
87
+ # response = client.list_monitors(limit: 10)
88
+ # response.data.each { |m| puts "#{m.id}: #{m.status}" }
89
+ def list_monitors(cursor: nil, limit: nil, webset_id: nil)
90
+ params = {}
91
+ params[:cursor] = cursor if cursor
92
+ params[:limit] = limit if limit
93
+ params[:websetId] = webset_id if webset_id
94
+
95
+ response = websets_get("/monitors", params)
96
+
97
+ Resources::MonitorListResponse.new(
98
+ Utils::ParameterConverter.from_api_response(response)
99
+ )
100
+ end
101
+
102
+ # Updates a Monitor
103
+ #
104
+ # @param monitor_id [String] Monitor ID
105
+ # @param cadence [Hash, nil] Updated schedule configuration
106
+ # @param behavior [Hash, nil] Updated behavior configuration
107
+ # @param status [String, nil] "enabled" or "disabled"
108
+ # @param metadata [Hash, nil] Updated metadata
109
+ #
110
+ # @return [Exa::Resources::Monitor] Updated Monitor
111
+ #
112
+ # @example Disable a monitor
113
+ # monitor = client.update_monitor("monitor_abc123", status: "disabled")
114
+ def update_monitor(monitor_id, cadence: nil, behavior: nil, status: nil, metadata: nil)
115
+ raise InvalidRequestError, "monitor_id must be a non-empty string" if !monitor_id.is_a?(String) || monitor_id.empty?
116
+
117
+ params = {}
118
+ params[:cadence] = cadence if cadence
119
+ params[:behavior] = behavior if behavior
120
+ params[:status] = status if status
121
+ params[:metadata] = metadata if metadata
122
+
123
+ response = websets_patch("/monitors/#{monitor_id}", params)
124
+
125
+ Resources::Monitor.new(
126
+ Utils::ParameterConverter.from_api_response(response)
127
+ )
128
+ end
129
+
130
+ # Deletes a Monitor
131
+ #
132
+ # @param monitor_id [String] Monitor ID
133
+ # @return [Hash] Deletion confirmation
134
+ #
135
+ # @example Delete a monitor
136
+ # client.delete_monitor("monitor_abc123")
137
+ def delete_monitor(monitor_id)
138
+ raise InvalidRequestError, "monitor_id must be a non-empty string" if !monitor_id.is_a?(String) || monitor_id.empty?
139
+
140
+ websets_delete("/monitors/#{monitor_id}")
141
+ end
142
+
143
+ # Gets a specific Monitor Run
144
+ #
145
+ # @param monitor_id [String] Monitor ID
146
+ # @param run_id [String] Run ID
147
+ # @return [Exa::Resources::MonitorRun] The Monitor Run
148
+ def get_monitor_run(monitor_id, run_id)
149
+ raise InvalidRequestError, "monitor_id must be a non-empty string" if !monitor_id.is_a?(String) || monitor_id.empty?
150
+ raise InvalidRequestError, "run_id must be a non-empty string" if !run_id.is_a?(String) || run_id.empty?
151
+
152
+ response = websets_get("/monitors/#{monitor_id}/runs/#{run_id}")
153
+
154
+ Resources::MonitorRun.new(
155
+ Utils::ParameterConverter.from_api_response(response)
156
+ )
157
+ end
158
+
159
+ # Lists runs for a Monitor
160
+ #
161
+ # @param monitor_id [String] Monitor ID
162
+ # @param cursor [String, nil] Cursor for pagination
163
+ # @param limit [Integer, nil] Number of results per page
164
+ #
165
+ # @return [Exa::Resources::MonitorRunListResponse] Paginated list of runs
166
+ def list_monitor_runs(monitor_id, cursor: nil, limit: nil)
167
+ raise InvalidRequestError, "monitor_id must be a non-empty string" if !monitor_id.is_a?(String) || monitor_id.empty?
168
+
169
+ params = {}
170
+ params[:cursor] = cursor if cursor
171
+ params[:limit] = limit if limit
172
+
173
+ response = websets_get("/monitors/#{monitor_id}/runs", params)
174
+
175
+ Resources::MonitorRunListResponse.new(
176
+ Utils::ParameterConverter.from_api_response(response)
177
+ )
178
+ end
179
+
180
+ private
181
+
182
+ def validate_monitor_params!(webset_id, cadence, behavior)
183
+ raise InvalidRequestError, "webset_id must be a non-empty string" if !webset_id.is_a?(String) || webset_id.empty?
184
+ raise InvalidRequestError, "cadence is required" unless cadence
185
+ raise InvalidRequestError, "behavior is required" unless behavior
186
+
187
+ if behavior[:type] && !VALID_BEHAVIOR_TYPES.include?(behavior[:type])
188
+ raise InvalidRequestError, "Invalid behavior type: #{behavior[:type]}. Valid types: #{VALID_BEHAVIOR_TYPES.join(", ")}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Endpoints
7
+ module Research
8
+ VALID_MODELS = %w[exa-research-fast exa-research exa-research-pro].freeze
9
+ VALID_STATUSES = %w[pending running completed canceled failed].freeze
10
+
11
+ # Create an asynchronous research task
12
+ #
13
+ # Creates a research task that explores the web, gathers sources, synthesizes
14
+ # findings, and returns results with citations. Can generate:
15
+ # 1. Structured JSON matching an outputSchema you provide
16
+ # 2. A detailed markdown report when no schema is provided
17
+ #
18
+ # The API responds immediately with a research_id for polling completion status.
19
+ #
20
+ # @param instructions [String] Instructions for what to research (max 4096 chars)
21
+ # @param model [String] Research model: "exa-research-fast", "exa-research", "exa-research-pro"
22
+ # @param output_schema [Hash] JSON Schema to enforce structured output
23
+ #
24
+ # @return [Exa::Resources::ResearchTask] Research task with ID for polling
25
+ #
26
+ # @raise [Exa::InvalidRequestError] if parameters are invalid
27
+ #
28
+ # @example Create a basic research task
29
+ # task = client.create_research(
30
+ # instructions: "Summarize the latest developments in AI safety research"
31
+ # )
32
+ # puts "Task ID: #{task.research_id}"
33
+ # puts "Status: #{task.status}"
34
+ #
35
+ # @example Create research with structured output
36
+ # schema = {
37
+ # type: "object",
38
+ # properties: {
39
+ # companies: {
40
+ # type: "array",
41
+ # items: { type: "string" }
42
+ # },
43
+ # summary: { type: "string" }
44
+ # }
45
+ # }
46
+ # task = client.create_research(
47
+ # instructions: "Find the top 5 AI startups in 2024",
48
+ # model: "exa-research-pro",
49
+ # output_schema: schema
50
+ # )
51
+ def create_research(instructions:, model: "exa-research", output_schema: nil)
52
+ validate_research_params!(instructions, model)
53
+
54
+ params = {
55
+ instructions: instructions,
56
+ model: model
57
+ }
58
+ params[:outputSchema] = output_schema if output_schema
59
+
60
+ response = post("/research/v1", params)
61
+
62
+ Resources::ResearchTask.new(
63
+ Utils::ParameterConverter.from_api_response(response)
64
+ )
65
+ end
66
+
67
+ # Get the status and results of a research task
68
+ #
69
+ # Poll this endpoint to check research progress. When status is "completed",
70
+ # the output field contains the research results.
71
+ #
72
+ # @param research_id [String] The research task ID
73
+ #
74
+ # @return [Exa::Resources::ResearchTask] Research task with current status and output
75
+ #
76
+ # @raise [Exa::NotFoundError] if research task not found
77
+ #
78
+ # @example Poll for completion
79
+ # task = client.get_research(research_id)
80
+ # case task.status
81
+ # when "completed"
82
+ # puts task.output
83
+ # when "running"
84
+ # puts "Progress: #{task.operations_completed}/#{task.operations_total}"
85
+ # when "failed"
86
+ # puts "Error: #{task.error_message}"
87
+ # end
88
+ def get_research(research_id)
89
+ raise InvalidRequestError, "research_id must be a non-empty string" if !research_id.is_a?(String) || research_id.empty?
90
+
91
+ response = get("/research/v1/#{research_id}")
92
+
93
+ Resources::ResearchTask.new(
94
+ Utils::ParameterConverter.from_api_response(response)
95
+ )
96
+ end
97
+
98
+ # List all research tasks
99
+ #
100
+ # @param cursor [String, nil] Cursor for pagination
101
+ # @param limit [Integer, nil] Number of results per page
102
+ #
103
+ # @return [Exa::Resources::ResearchListResponse] Paginated list of research tasks
104
+ #
105
+ # @example List recent research tasks
106
+ # response = client.list_research(limit: 10)
107
+ # response.data.each { |task| puts "#{task.research_id}: #{task.status}" }
108
+ def list_research(cursor: nil, limit: nil)
109
+ params = {}
110
+ params[:cursor] = cursor if cursor
111
+ params[:limit] = limit if limit
112
+
113
+ response = get("/research/v1", params)
114
+
115
+ Resources::ResearchListResponse.new(
116
+ Utils::ParameterConverter.from_api_response(response)
117
+ )
118
+ end
119
+
120
+ # Cancel a running research task
121
+ #
122
+ # @param research_id [String] The research task ID to cancel
123
+ #
124
+ # @return [Exa::Resources::ResearchTask] The canceled research task
125
+ #
126
+ # @raise [Exa::NotFoundError] if research task not found
127
+ #
128
+ # @example Cancel a research task
129
+ # task = client.cancel_research(research_id)
130
+ # puts "Canceled: #{task.status}" # => "canceled"
131
+ def cancel_research(research_id)
132
+ raise InvalidRequestError, "research_id must be a non-empty string" if !research_id.is_a?(String) || research_id.empty?
133
+
134
+ response = post("/research/v1/#{research_id}/cancel", {})
135
+
136
+ Resources::ResearchTask.new(
137
+ Utils::ParameterConverter.from_api_response(response)
138
+ )
139
+ end
140
+
141
+ private
142
+
143
+ # @param instructions [String] Research instructions
144
+ # @param model [String] Research model
145
+ def validate_research_params!(instructions, model)
146
+ raise InvalidRequestError, "instructions must be a non-empty string" if !instructions.is_a?(String) || instructions.empty?
147
+
148
+ if instructions.length > 4096
149
+ raise InvalidRequestError, "instructions must be 4096 characters or less"
150
+ end
151
+
152
+ unless VALID_MODELS.include?(model)
153
+ raise InvalidRequestError, "Invalid model: #{model}. Valid models: #{VALID_MODELS.join(", ")}"
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Endpoints
7
+ module Search
8
+ VALID_SEARCH_TYPES = %i[neural auto fast deep].freeze
9
+ VALID_CATEGORIES = %i[
10
+ people company research_paper news pdf github tweet personal_site financial_report
11
+ ].freeze
12
+ VALID_LIVECRAWL_OPTIONS = %i[never fallback preferred always].freeze
13
+
14
+ # Performs an intelligent web search using Exa's embeddings-based model
15
+ #
16
+ # @param query [String] The search query string
17
+ # @param type [Symbol] Search type: :neural, :auto (default), :fast, or :deep
18
+ # @param additional_queries [Array<String>] Additional query variations for deep search
19
+ # @param category [Symbol] Data category to focus on (e.g., :people, :company, :research_paper)
20
+ # @param num_results [Integer] Number of results to return (max 100)
21
+ # @param include_domains [Array<String>] Domains to include in results
22
+ # @param exclude_domains [Array<String>] Domains to exclude from results
23
+ # @param start_crawl_date [String, Time] Results crawled after this date (ISO 8601)
24
+ # @param end_crawl_date [String, Time] Results crawled before this date (ISO 8601)
25
+ # @param start_published_date [String, Time] Results published after this date (ISO 8601)
26
+ # @param end_published_date [String, Time] Results published before this date (ISO 8601)
27
+ # @param include_text [Array<String>] Strings that must be present in page text
28
+ # @param exclude_text [Array<String>] Strings that must not be present in page text
29
+ # @param country [String] Two-letter ISO country code (e.g., "US")
30
+ # @param text [Boolean, Hash] Return text content. Hash for options: { max_characters: 1000 }
31
+ # @param highlights [Boolean, Hash] Return highlights. Hash for options: { num_sentences: 3, highlight_query: "..." }
32
+ # @param summary [Boolean, Hash] Return summary. Hash for options: { query: "..." }
33
+ # @param context [Boolean, Integer] Return context string for LLM. Integer for max characters
34
+ # @param moderation [Boolean] Enable content moderation
35
+ # @param livecrawl [Symbol] Livecrawl option: :never, :fallback, :preferred, :always
36
+ # @param livecrawl_timeout [Integer] Livecrawl timeout in milliseconds
37
+ # @param subpages [Integer] Number of subpages to crawl
38
+ # @param subpage_target [String, Array<String>] Terms to find specific subpages
39
+ #
40
+ # @return [Exa::Resources::SearchResponse] Search results
41
+ #
42
+ # @raise [Exa::InvalidRequestError] if parameters are invalid
43
+ # @raise [Exa::AuthenticationError] if API key is invalid
44
+ # @raise [Exa::RateLimitError] if rate limit is exceeded
45
+ #
46
+ # @example Basic search
47
+ # client.search("Latest developments in LLMs")
48
+ #
49
+ # @example Search with content extraction
50
+ # client.search("AI research papers", text: true, num_results: 20)
51
+ #
52
+ # @example Deep search with filters
53
+ # client.search(
54
+ # "Machine learning startups",
55
+ # type: :deep,
56
+ # category: :company,
57
+ # include_domains: ["linkedin.com", "crunchbase.com"],
58
+ # start_published_date: "2024-01-01T00:00:00.000Z"
59
+ # )
60
+ def search(query, **options)
61
+ validate_search_options!(options)
62
+
63
+ params = build_search_params(query, options)
64
+ response = post("/search", params)
65
+
66
+ Resources::SearchResponse.new(
67
+ Utils::ParameterConverter.from_api_response(response)
68
+ )
69
+ end
70
+
71
+ private
72
+
73
+ # @param options [Hash] Search options to validate
74
+ # @raise [Exa::InvalidRequestError] if options are invalid
75
+ def validate_search_options!(options)
76
+ if options[:type] && !VALID_SEARCH_TYPES.include?(options[:type])
77
+ raise InvalidRequestError, "Invalid search type: #{options[:type]}. Valid types: #{VALID_SEARCH_TYPES.join(", ")}"
78
+ end
79
+
80
+ if options[:category] && !VALID_CATEGORIES.include?(options[:category])
81
+ raise InvalidRequestError, "Invalid category: #{options[:category]}. Valid categories: #{VALID_CATEGORIES.join(", ")}"
82
+ end
83
+
84
+ if options[:livecrawl] && !VALID_LIVECRAWL_OPTIONS.include?(options[:livecrawl])
85
+ raise InvalidRequestError, "Invalid livecrawl option: #{options[:livecrawl]}. Valid options: #{VALID_LIVECRAWL_OPTIONS.join(", ")}"
86
+ end
87
+
88
+ if options[:num_results] && (options[:num_results] < 1 || options[:num_results] > 100)
89
+ raise InvalidRequestError, "num_results must be between 1 and 100"
90
+ end
91
+
92
+ if options[:additional_queries] && options[:type] != :deep
93
+ raise InvalidRequestError, "additional_queries is only supported with type: :deep"
94
+ end
95
+ end
96
+
97
+ # @param query [String] Search query
98
+ # @param options [Hash] Search options
99
+ # @return [Hash] API-formatted parameters
100
+ def build_search_params(query, options)
101
+ params = { query: query }
102
+
103
+ params[:type] = options[:type].to_s if options[:type]
104
+ params[:additionalQueries] = options[:additional_queries] if options[:additional_queries]
105
+ params[:category] = format_category(options[:category]) if options[:category]
106
+ params[:numResults] = options[:num_results] if options[:num_results]
107
+ params[:userLocation] = options[:country] if options[:country]
108
+ params[:moderation] = options[:moderation] if options.key?(:moderation)
109
+
110
+ add_domain_filters!(params, options)
111
+ add_date_filters!(params, options)
112
+ add_text_filters!(params, options)
113
+ add_content_options!(params, options)
114
+
115
+ params
116
+ end
117
+
118
+ # @param category [Symbol] Category symbol
119
+ # @return [String] API-formatted category
120
+ def format_category(category)
121
+ category.to_s.tr("_", " ")
122
+ end
123
+
124
+ # @param params [Hash] Parameters to modify
125
+ # @param options [Hash] Source options
126
+ def add_domain_filters!(params, options)
127
+ params[:includeDomains] = options[:include_domains] if options[:include_domains]
128
+ params[:excludeDomains] = options[:exclude_domains] if options[:exclude_domains]
129
+ end
130
+
131
+ # @param params [Hash] Parameters to modify
132
+ # @param options [Hash] Source options
133
+ def add_date_filters!(params, options)
134
+ params[:startCrawlDate] = format_date(options[:start_crawl_date]) if options[:start_crawl_date]
135
+ params[:endCrawlDate] = format_date(options[:end_crawl_date]) if options[:end_crawl_date]
136
+ params[:startPublishedDate] = format_date(options[:start_published_date]) if options[:start_published_date]
137
+ params[:endPublishedDate] = format_date(options[:end_published_date]) if options[:end_published_date]
138
+ end
139
+
140
+ # @param params [Hash] Parameters to modify
141
+ # @param options [Hash] Source options
142
+ def add_text_filters!(params, options)
143
+ params[:includeText] = options[:include_text] if options[:include_text]
144
+ params[:excludeText] = options[:exclude_text] if options[:exclude_text]
145
+ end
146
+
147
+ # @param params [Hash] Parameters to modify
148
+ # @param options [Hash] Source options
149
+ def add_content_options!(params, options)
150
+ params[:text] = format_content_option(options[:text]) if options.key?(:text)
151
+ params[:highlights] = format_highlights_option(options[:highlights]) if options.key?(:highlights)
152
+ params[:summary] = format_summary_option(options[:summary]) if options.key?(:summary)
153
+
154
+ if options.key?(:context)
155
+ params[:context] = options[:context].is_a?(Integer) ? { maxCharacters: options[:context] } : options[:context]
156
+ end
157
+
158
+ params[:livecrawl] = options[:livecrawl].to_s if options[:livecrawl]
159
+ params[:livecrawlTimeout] = options[:livecrawl_timeout] if options[:livecrawl_timeout]
160
+ params[:subpages] = options[:subpages] if options[:subpages]
161
+ params[:subpageTarget] = options[:subpage_target] if options[:subpage_target]
162
+ end
163
+
164
+ # @param option [Boolean, Hash] Text option
165
+ # @return [Boolean, Hash] Formatted option
166
+ def format_content_option(option)
167
+ return option if option.is_a?(TrueClass) || option.is_a?(FalseClass)
168
+
169
+ Utils::ParameterConverter.to_api_params(option)
170
+ end
171
+
172
+ # @param option [Boolean, Hash] Highlights option
173
+ # @return [Boolean, Hash] Formatted option
174
+ def format_highlights_option(option)
175
+ return option if option.is_a?(TrueClass) || option.is_a?(FalseClass)
176
+
177
+ Utils::ParameterConverter.to_api_params(option)
178
+ end
179
+
180
+ # @param option [Boolean, Hash] Summary option
181
+ # @return [Boolean, Hash] Formatted option
182
+ def format_summary_option(option)
183
+ return option if option.is_a?(TrueClass) || option.is_a?(FalseClass)
184
+
185
+ Utils::ParameterConverter.to_api_params(option)
186
+ end
187
+
188
+ # @param value [Time, Date, String, nil] Date value
189
+ # @return [String, nil] ISO 8601 formatted string
190
+ def format_date(value)
191
+ Utils::ParameterConverter.format_date(value)
192
+ end
193
+ end
194
+ end
195
+ end