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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/exaonruby.gemspec +37 -0
- data/exe/exa +7 -0
- data/lib/exa/cli.rb +458 -0
- data/lib/exa/client.rb +210 -0
- data/lib/exa/configuration.rb +81 -0
- data/lib/exa/endpoints/answer.rb +109 -0
- data/lib/exa/endpoints/contents.rb +141 -0
- data/lib/exa/endpoints/events.rb +71 -0
- data/lib/exa/endpoints/find_similar.rb +154 -0
- data/lib/exa/endpoints/imports.rb +145 -0
- data/lib/exa/endpoints/monitors.rb +193 -0
- data/lib/exa/endpoints/research.rb +158 -0
- data/lib/exa/endpoints/search.rb +195 -0
- data/lib/exa/endpoints/webhooks.rb +161 -0
- data/lib/exa/endpoints/webset_enrichments.rb +162 -0
- data/lib/exa/endpoints/webset_items.rb +90 -0
- data/lib/exa/endpoints/webset_searches.rb +137 -0
- data/lib/exa/endpoints/websets.rb +214 -0
- data/lib/exa/errors.rb +180 -0
- data/lib/exa/resources/answer_response.rb +101 -0
- data/lib/exa/resources/base.rb +56 -0
- data/lib/exa/resources/contents_response.rb +123 -0
- data/lib/exa/resources/event.rb +84 -0
- data/lib/exa/resources/import.rb +137 -0
- data/lib/exa/resources/monitor.rb +205 -0
- data/lib/exa/resources/paginated_response.rb +87 -0
- data/lib/exa/resources/research_task.rb +165 -0
- data/lib/exa/resources/search_response.rb +111 -0
- data/lib/exa/resources/search_result.rb +95 -0
- data/lib/exa/resources/webhook.rb +152 -0
- data/lib/exa/resources/webset.rb +491 -0
- data/lib/exa/resources/webset_item.rb +256 -0
- data/lib/exa/utils/parameter_converter.rb +159 -0
- data/lib/exa/utils/webhook_handler.rb +239 -0
- data/lib/exa/version.rb +7 -0
- data/lib/exa.rb +130 -0
- metadata +146 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Resources
|
|
7
|
+
class WebsetItem < Base
|
|
8
|
+
# @return [String] Unique identifier
|
|
9
|
+
def id
|
|
10
|
+
get(:id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [String] Object type (always "webset_item")
|
|
14
|
+
def object
|
|
15
|
+
get(:object)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] Source type: search, import
|
|
19
|
+
def source
|
|
20
|
+
get(:source)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] Source identifier
|
|
24
|
+
def source_id
|
|
25
|
+
get(:source_id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String] Parent webset ID
|
|
29
|
+
def webset_id
|
|
30
|
+
get(:webset_id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [WebsetItemProperties, nil] Item properties
|
|
34
|
+
def properties
|
|
35
|
+
props = get(:properties)
|
|
36
|
+
return nil unless props
|
|
37
|
+
|
|
38
|
+
WebsetItemProperties.new(props)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<WebsetItemEvaluation>] Evaluation results
|
|
42
|
+
def evaluations
|
|
43
|
+
@evaluations ||= (get(:evaluations, []) || []).map { |data| WebsetItemEvaluation.new(data) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<WebsetItemEnrichmentResult>] Enrichment results
|
|
47
|
+
def enrichments
|
|
48
|
+
@enrichments ||= (get(:enrichments, []) || []).map { |data| WebsetItemEnrichmentResult.new(data) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Time, nil] Creation timestamp
|
|
52
|
+
def created_at
|
|
53
|
+
parse_time(get(:created_at))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Time, nil] Last update timestamp
|
|
57
|
+
def updated_at
|
|
58
|
+
parse_time(get(:updated_at))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String, nil] Item type from properties
|
|
62
|
+
def type
|
|
63
|
+
dig(:properties, :type)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String, nil] URL from properties
|
|
67
|
+
def url
|
|
68
|
+
dig(:properties, :url)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String, nil] Description from properties
|
|
72
|
+
def description
|
|
73
|
+
dig(:properties, :description)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] Whether all evaluations passed
|
|
77
|
+
def all_criteria_satisfied?
|
|
78
|
+
evaluations.all?(&:satisfied?)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Array<WebsetItemEvaluation>] Failed evaluations
|
|
82
|
+
def failed_evaluations
|
|
83
|
+
evaluations.reject(&:satisfied?)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def parse_time(value)
|
|
89
|
+
return nil unless value
|
|
90
|
+
|
|
91
|
+
Time.parse(value)
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def inspectable_attributes
|
|
97
|
+
{ id: id, type: type, url: url }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class WebsetItemProperties < Base
|
|
102
|
+
# @return [String, nil] Entity type: person, company, research_paper
|
|
103
|
+
def type
|
|
104
|
+
get(:type)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String, nil] URL
|
|
108
|
+
def url
|
|
109
|
+
get(:url)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String, nil] Description
|
|
113
|
+
def description
|
|
114
|
+
get(:description)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Hash, nil] Person information if type is person
|
|
118
|
+
def person
|
|
119
|
+
get(:person)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Hash, nil] Company information if type is company
|
|
123
|
+
def company
|
|
124
|
+
get(:company)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Hash, nil] Research paper information if type is research_paper
|
|
128
|
+
def research_paper
|
|
129
|
+
get(:research_paper)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Person attributes (when type is person)
|
|
133
|
+
|
|
134
|
+
# @return [String, nil] Person's name
|
|
135
|
+
def person_name
|
|
136
|
+
dig(:person, :name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [String, nil] Person's location
|
|
140
|
+
def person_location
|
|
141
|
+
dig(:person, :location)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [String, nil] Person's position/title
|
|
145
|
+
def person_position
|
|
146
|
+
dig(:person, :position)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [String, nil] Person's company name
|
|
150
|
+
def person_company_name
|
|
151
|
+
dig(:person, :company, :name)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [String, nil] Person's picture URL
|
|
155
|
+
def person_picture_url
|
|
156
|
+
dig(:person, :picture_url)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Company attributes (when type is company)
|
|
160
|
+
|
|
161
|
+
# @return [String, nil] Company name
|
|
162
|
+
def company_name
|
|
163
|
+
dig(:company, :name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @return [String, nil] Company location
|
|
167
|
+
def company_location
|
|
168
|
+
dig(:company, :location)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class WebsetItemEvaluation < Base
|
|
173
|
+
# @return [String, nil] Criterion description
|
|
174
|
+
def criterion
|
|
175
|
+
get(:criterion)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @return [String, nil] Reasoning for the evaluation
|
|
179
|
+
def reasoning
|
|
180
|
+
get(:reasoning)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @return [String, nil] Satisfaction status: yes, no, unknown
|
|
184
|
+
def satisfied_status
|
|
185
|
+
get(:satisfied)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @return [Boolean] Whether the criterion was satisfied
|
|
189
|
+
def satisfied?
|
|
190
|
+
satisfied_status == "yes"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @return [Boolean] Whether satisfaction is unknown
|
|
194
|
+
def unknown?
|
|
195
|
+
satisfied_status == "unknown"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @return [Array<Hash>] References supporting the evaluation
|
|
199
|
+
def references
|
|
200
|
+
get(:references, [])
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class WebsetItemEnrichmentResult < Base
|
|
205
|
+
# @return [String] Object type
|
|
206
|
+
def object
|
|
207
|
+
get(:object)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @return [String] Status: pending, completed, failed
|
|
211
|
+
def status
|
|
212
|
+
get(:status)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [String, nil] Format: text, enum, number, boolean, date
|
|
216
|
+
def format
|
|
217
|
+
get(:format)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [Array, String, Integer, Boolean, nil] Result value(s)
|
|
221
|
+
def result
|
|
222
|
+
get(:result)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @return [String, nil] Reasoning for the result
|
|
226
|
+
def reasoning
|
|
227
|
+
get(:reasoning)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @return [Array<Hash>] References used
|
|
231
|
+
def references
|
|
232
|
+
get(:references, [])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @return [String, nil] Parent enrichment ID
|
|
236
|
+
def enrichment_id
|
|
237
|
+
get(:enrichment_id)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @return [Boolean] Whether completed successfully
|
|
241
|
+
def completed?
|
|
242
|
+
status == "completed"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Boolean] Whether still pending
|
|
246
|
+
def pending?
|
|
247
|
+
status == "pending"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# @return [Boolean] Whether failed
|
|
251
|
+
def failed?
|
|
252
|
+
status == "failed"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Utils
|
|
7
|
+
class ParameterConverter
|
|
8
|
+
SNAKE_TO_CAMEL_MAP = {
|
|
9
|
+
api_key: :apiKey,
|
|
10
|
+
num_results: :numResults,
|
|
11
|
+
include_domains: :includeDomains,
|
|
12
|
+
exclude_domains: :excludeDomains,
|
|
13
|
+
start_crawl_date: :startCrawlDate,
|
|
14
|
+
end_crawl_date: :endCrawlDate,
|
|
15
|
+
start_published_date: :startPublishedDate,
|
|
16
|
+
end_published_date: :endPublishedDate,
|
|
17
|
+
include_text: :includeText,
|
|
18
|
+
exclude_text: :excludeText,
|
|
19
|
+
additional_queries: :additionalQueries,
|
|
20
|
+
user_location: :userLocation,
|
|
21
|
+
livecrawl_timeout: :livecrawlTimeout,
|
|
22
|
+
subpage_target: :subpageTarget,
|
|
23
|
+
highlight_query: :highlightQuery,
|
|
24
|
+
num_sentences: :numSentences,
|
|
25
|
+
max_characters: :maxCharacters,
|
|
26
|
+
external_id: :externalId,
|
|
27
|
+
source_id: :sourceId,
|
|
28
|
+
webset_id: :websetId,
|
|
29
|
+
search_id: :searchId,
|
|
30
|
+
enrichment_id: :enrichmentId,
|
|
31
|
+
item_id: :itemId,
|
|
32
|
+
success_rate: :successRate,
|
|
33
|
+
time_left: :timeLeft,
|
|
34
|
+
next_cursor: :nextCursor,
|
|
35
|
+
has_more: :hasMore,
|
|
36
|
+
created_at: :createdAt,
|
|
37
|
+
updated_at: :updatedAt,
|
|
38
|
+
canceled_at: :canceledAt,
|
|
39
|
+
canceled_reason: :canceledReason,
|
|
40
|
+
failed_at: :failedAt,
|
|
41
|
+
failed_reason: :failedReason,
|
|
42
|
+
failed_message: :failedMessage,
|
|
43
|
+
last_run: :lastRun,
|
|
44
|
+
next_run_at: :nextRunAt,
|
|
45
|
+
picture_url: :pictureUrl,
|
|
46
|
+
cost_dollars: :costDollars,
|
|
47
|
+
request_id: :requestId,
|
|
48
|
+
resolved_search_type: :resolvedSearchType,
|
|
49
|
+
search_type: :searchType,
|
|
50
|
+
published_date: :publishedDate,
|
|
51
|
+
highlight_scores: :highlightScores
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
CAMEL_TO_SNAKE_MAP = SNAKE_TO_CAMEL_MAP.invert.freeze
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# Converts Ruby-style snake_case keys to API-style camelCase
|
|
58
|
+
# @param hash [Hash] Hash with snake_case keys
|
|
59
|
+
# @return [Hash] Hash with camelCase keys
|
|
60
|
+
def to_api_params(hash)
|
|
61
|
+
return hash unless hash.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
64
|
+
api_key = convert_key_to_camel(key)
|
|
65
|
+
result[api_key] = convert_value_to_api(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Converts API-style camelCase keys to Ruby-style snake_case
|
|
70
|
+
# @param hash [Hash] Hash with camelCase keys
|
|
71
|
+
# @return [Hash] Hash with snake_case keys
|
|
72
|
+
def from_api_response(hash)
|
|
73
|
+
return hash unless hash.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
76
|
+
ruby_key = convert_key_to_snake(key)
|
|
77
|
+
result[ruby_key] = convert_value_from_api(value)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Formats a Time or Date object to ISO 8601 string
|
|
82
|
+
# @param value [Time, Date, String, nil] Value to format
|
|
83
|
+
# @return [String, nil] ISO 8601 formatted string
|
|
84
|
+
def format_date(value)
|
|
85
|
+
case value
|
|
86
|
+
when Time
|
|
87
|
+
value.utc.iso8601(3)
|
|
88
|
+
when Date
|
|
89
|
+
value.to_time.utc.iso8601(3)
|
|
90
|
+
when String
|
|
91
|
+
value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Parses an ISO 8601 string to Time object
|
|
96
|
+
# @param value [String, nil] ISO 8601 string
|
|
97
|
+
# @return [Time, nil] Parsed Time object
|
|
98
|
+
def parse_date(value)
|
|
99
|
+
return nil unless value.is_a?(String)
|
|
100
|
+
|
|
101
|
+
Time.parse(value)
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# @param key [Symbol, String] Key to convert
|
|
109
|
+
# @return [String] camelCase key
|
|
110
|
+
def convert_key_to_camel(key)
|
|
111
|
+
sym_key = key.to_sym
|
|
112
|
+
return SNAKE_TO_CAMEL_MAP[sym_key].to_s if SNAKE_TO_CAMEL_MAP.key?(sym_key)
|
|
113
|
+
|
|
114
|
+
key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param key [Symbol, String] Key to convert
|
|
118
|
+
# @return [Symbol] snake_case key
|
|
119
|
+
def convert_key_to_snake(key)
|
|
120
|
+
str_key = key.to_s
|
|
121
|
+
sym_key = key.to_sym
|
|
122
|
+
return CAMEL_TO_SNAKE_MAP[sym_key] if CAMEL_TO_SNAKE_MAP.key?(sym_key)
|
|
123
|
+
|
|
124
|
+
str_key.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }.to_sym
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param value [Object] Value to convert for API
|
|
128
|
+
# @return [Object] Converted value
|
|
129
|
+
def convert_value_to_api(value)
|
|
130
|
+
case value
|
|
131
|
+
when Hash
|
|
132
|
+
to_api_params(value)
|
|
133
|
+
when Array
|
|
134
|
+
value.map { |v| convert_value_to_api(v) }
|
|
135
|
+
when Time, Date
|
|
136
|
+
format_date(value)
|
|
137
|
+
when Symbol
|
|
138
|
+
value.to_s.gsub("_", " ")
|
|
139
|
+
else
|
|
140
|
+
value
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param value [Object] Value from API response
|
|
145
|
+
# @return [Object] Converted value
|
|
146
|
+
def convert_value_from_api(value)
|
|
147
|
+
case value
|
|
148
|
+
when Hash
|
|
149
|
+
from_api_response(value)
|
|
150
|
+
when Array
|
|
151
|
+
value.map { |v| convert_value_from_api(v) }
|
|
152
|
+
else
|
|
153
|
+
value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Exa
|
|
9
|
+
module Utils
|
|
10
|
+
# Webhook signature verification and payload handling for n8n, Zapier, and direct integrations
|
|
11
|
+
#
|
|
12
|
+
# This module provides utilities for verifying Exa webhook signatures and
|
|
13
|
+
# parsing webhook payloads. It can be used with any webhook receiver including
|
|
14
|
+
# Rails, Sinatra, n8n, Zapier, or custom Ruby applications.
|
|
15
|
+
#
|
|
16
|
+
# @example Verify a webhook in Rails
|
|
17
|
+
# class WebhooksController < ApplicationController
|
|
18
|
+
# skip_before_action :verify_authenticity_token
|
|
19
|
+
#
|
|
20
|
+
# def exa
|
|
21
|
+
# raw_body = request.raw_post
|
|
22
|
+
# signature = request.headers["X-Exa-Signature"]
|
|
23
|
+
# timestamp = request.headers["X-Exa-Timestamp"]
|
|
24
|
+
#
|
|
25
|
+
# if Exa::Utils::WebhookHandler.verify_signature(raw_body, signature, secret: ENV["EXA_WEBHOOK_SECRET"])
|
|
26
|
+
# event = Exa::Utils::WebhookHandler.parse_event(raw_body)
|
|
27
|
+
# process_event(event)
|
|
28
|
+
# head :ok
|
|
29
|
+
# else
|
|
30
|
+
# head :unauthorized
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @example Verify in Sinatra
|
|
36
|
+
# post "/webhooks/exa" do
|
|
37
|
+
# raw_body = request.body.read
|
|
38
|
+
# signature = request.env["HTTP_X_EXA_SIGNATURE"]
|
|
39
|
+
#
|
|
40
|
+
# if Exa::Utils::WebhookHandler.verify_signature(raw_body, signature, secret: ENV["EXA_WEBHOOK_SECRET"])
|
|
41
|
+
# event = Exa::Utils::WebhookHandler.parse_event(raw_body)
|
|
42
|
+
# # Process event
|
|
43
|
+
# status 200
|
|
44
|
+
# else
|
|
45
|
+
# status 401
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
class WebhookHandler
|
|
49
|
+
# Default tolerance for timestamp validation (5 minutes)
|
|
50
|
+
DEFAULT_TOLERANCE = 300
|
|
51
|
+
|
|
52
|
+
# Signature header name
|
|
53
|
+
SIGNATURE_HEADER = "X-Exa-Signature"
|
|
54
|
+
|
|
55
|
+
# Timestamp header name
|
|
56
|
+
TIMESTAMP_HEADER = "X-Exa-Timestamp"
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Verifies the webhook signature using HMAC-SHA256
|
|
60
|
+
#
|
|
61
|
+
# @param payload [String] Raw request body
|
|
62
|
+
# @param signature [String] Signature from X-Exa-Signature header
|
|
63
|
+
# @param secret [String] Webhook secret from webhook creation
|
|
64
|
+
# @param timestamp [String, nil] Optional timestamp from X-Exa-Timestamp header
|
|
65
|
+
# @param tolerance [Integer] Maximum age of webhook in seconds (default: 300)
|
|
66
|
+
#
|
|
67
|
+
# @return [Boolean] true if signature is valid
|
|
68
|
+
#
|
|
69
|
+
# @example Basic verification
|
|
70
|
+
# WebhookHandler.verify_signature(payload, signature, secret: "whsec_...")
|
|
71
|
+
#
|
|
72
|
+
# @example With timestamp validation
|
|
73
|
+
# WebhookHandler.verify_signature(
|
|
74
|
+
# payload,
|
|
75
|
+
# signature,
|
|
76
|
+
# secret: "whsec_...",
|
|
77
|
+
# timestamp: request.headers["X-Exa-Timestamp"],
|
|
78
|
+
# tolerance: 300
|
|
79
|
+
# )
|
|
80
|
+
def verify_signature(payload, signature, secret:, timestamp: nil, tolerance: DEFAULT_TOLERANCE)
|
|
81
|
+
return false if payload.nil? || payload.empty?
|
|
82
|
+
return false if signature.nil? || signature.empty?
|
|
83
|
+
return false if secret.nil? || secret.empty?
|
|
84
|
+
|
|
85
|
+
# Validate timestamp if provided
|
|
86
|
+
if timestamp
|
|
87
|
+
return false unless valid_timestamp?(timestamp, tolerance)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Calculate expected signature
|
|
91
|
+
expected = compute_signature(payload, secret, timestamp)
|
|
92
|
+
|
|
93
|
+
# Use secure comparison to prevent timing attacks
|
|
94
|
+
secure_compare(expected, signature)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Computes HMAC-SHA256 signature for a payload
|
|
98
|
+
#
|
|
99
|
+
# @param payload [String] Raw request body
|
|
100
|
+
# @param secret [String] Webhook secret
|
|
101
|
+
# @param timestamp [String, nil] Optional timestamp to include
|
|
102
|
+
#
|
|
103
|
+
# @return [String] Hex-encoded signature
|
|
104
|
+
def compute_signature(payload, secret, timestamp = nil)
|
|
105
|
+
# If timestamp provided, prepend it to payload (common pattern)
|
|
106
|
+
signed_payload = timestamp ? "#{timestamp}.#{payload}" : payload
|
|
107
|
+
|
|
108
|
+
OpenSSL::HMAC.hexdigest(
|
|
109
|
+
OpenSSL::Digest.new("sha256"),
|
|
110
|
+
secret,
|
|
111
|
+
signed_payload
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Parses a webhook payload into an Event resource
|
|
116
|
+
#
|
|
117
|
+
# @param payload [String] Raw JSON request body
|
|
118
|
+
# @return [Exa::Resources::Event] Parsed event
|
|
119
|
+
#
|
|
120
|
+
# @raise [Exa::InvalidRequestError] if payload is invalid JSON
|
|
121
|
+
def parse_event(payload)
|
|
122
|
+
data = JSON.parse(payload)
|
|
123
|
+
data_symbolized = symbolize_keys(data)
|
|
124
|
+
|
|
125
|
+
Resources::Event.new(
|
|
126
|
+
ParameterConverter.from_api_response(data_symbolized)
|
|
127
|
+
)
|
|
128
|
+
rescue JSON::ParserError => e
|
|
129
|
+
raise InvalidRequestError, "Invalid webhook payload: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Parses headers from different framework formats
|
|
133
|
+
#
|
|
134
|
+
# @param headers [Hash] Request headers
|
|
135
|
+
# @return [Hash] Normalized headers with signature and timestamp
|
|
136
|
+
#
|
|
137
|
+
# @example Rails headers
|
|
138
|
+
# WebhookHandler.extract_headers(request.headers)
|
|
139
|
+
# # => { signature: "abc123", timestamp: "1703001234" }
|
|
140
|
+
#
|
|
141
|
+
# @example Rack headers
|
|
142
|
+
# WebhookHandler.extract_headers(env)
|
|
143
|
+
# # => { signature: "abc123", timestamp: "1703001234" }
|
|
144
|
+
def extract_headers(headers)
|
|
145
|
+
signature = headers["X-Exa-Signature"] ||
|
|
146
|
+
headers["HTTP_X_EXA_SIGNATURE"] ||
|
|
147
|
+
headers["x-exa-signature"] ||
|
|
148
|
+
headers[:x_exa_signature]
|
|
149
|
+
|
|
150
|
+
timestamp = headers["X-Exa-Timestamp"] ||
|
|
151
|
+
headers["HTTP_X_EXA_TIMESTAMP"] ||
|
|
152
|
+
headers["x-exa-timestamp"] ||
|
|
153
|
+
headers[:x_exa_timestamp]
|
|
154
|
+
|
|
155
|
+
{ signature: signature, timestamp: timestamp }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Convenience method to verify and parse in one call
|
|
159
|
+
#
|
|
160
|
+
# @param payload [String] Raw request body
|
|
161
|
+
# @param headers [Hash] Request headers
|
|
162
|
+
# @param secret [String] Webhook secret
|
|
163
|
+
# @param tolerance [Integer] Maximum age of webhook in seconds
|
|
164
|
+
#
|
|
165
|
+
# @return [Exa::Resources::Event] Parsed event if verification succeeds
|
|
166
|
+
#
|
|
167
|
+
# @raise [Exa::AuthenticationError] if signature verification fails
|
|
168
|
+
# @raise [Exa::InvalidRequestError] if payload is invalid
|
|
169
|
+
def construct_event(payload, headers, secret:, tolerance: DEFAULT_TOLERANCE)
|
|
170
|
+
extracted = extract_headers(headers)
|
|
171
|
+
|
|
172
|
+
unless verify_signature(
|
|
173
|
+
payload,
|
|
174
|
+
extracted[:signature],
|
|
175
|
+
secret: secret,
|
|
176
|
+
timestamp: extracted[:timestamp],
|
|
177
|
+
tolerance: tolerance
|
|
178
|
+
)
|
|
179
|
+
raise AuthenticationError, "Webhook signature verification failed"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
parse_event(payload)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# Validates that timestamp is within tolerance
|
|
188
|
+
#
|
|
189
|
+
# @param timestamp [String] Unix timestamp string
|
|
190
|
+
# @param tolerance [Integer] Maximum age in seconds
|
|
191
|
+
# @return [Boolean] true if timestamp is valid
|
|
192
|
+
def valid_timestamp?(timestamp, tolerance)
|
|
193
|
+
ts = Integer(timestamp)
|
|
194
|
+
age = Time.now.to_i - ts
|
|
195
|
+
age.abs <= tolerance
|
|
196
|
+
rescue ArgumentError, TypeError
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Secure string comparison to prevent timing attacks
|
|
201
|
+
#
|
|
202
|
+
# @param a [String] First string
|
|
203
|
+
# @param b [String] Second string
|
|
204
|
+
# @return [Boolean] true if strings are equal
|
|
205
|
+
def secure_compare(a, b)
|
|
206
|
+
return false if a.nil? || b.nil?
|
|
207
|
+
return false unless a.bytesize == b.bytesize
|
|
208
|
+
|
|
209
|
+
# Use OpenSSL's secure comparison if available (Ruby 2.5+)
|
|
210
|
+
if defined?(OpenSSL.secure_compare)
|
|
211
|
+
OpenSSL.secure_compare(a, b)
|
|
212
|
+
else
|
|
213
|
+
# Fallback for older Ruby
|
|
214
|
+
result = 0
|
|
215
|
+
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
|
216
|
+
result.zero?
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Recursively symbolizes hash keys
|
|
221
|
+
#
|
|
222
|
+
# @param obj [Object] Object to process
|
|
223
|
+
# @return [Object] Object with symbolized keys
|
|
224
|
+
def symbolize_keys(obj)
|
|
225
|
+
case obj
|
|
226
|
+
when Hash
|
|
227
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
228
|
+
result[key.to_sym] = symbolize_keys(value)
|
|
229
|
+
end
|
|
230
|
+
when Array
|
|
231
|
+
obj.map { |item| symbolize_keys(item) }
|
|
232
|
+
else
|
|
233
|
+
obj
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|