synthient 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 +202 -0
- data/README.md +420 -0
- data/lib/synthient/client.rb +188 -0
- data/lib/synthient/errors.rb +60 -0
- data/lib/synthient/feed.rb +135 -0
- data/lib/synthient/grpcschema.rb +231 -0
- data/lib/synthient/model.rb +93 -0
- data/lib/synthient/models/account.rb +67 -0
- data/lib/synthient/models/domain.rb +100 -0
- data/lib/synthient/models/events.rb +202 -0
- data/lib/synthient/models/export.rb +65 -0
- data/lib/synthient/models/lookup.rb +86 -0
- data/lib/synthient/response_metadata.rb +16 -0
- data/lib/synthient/transport.rb +340 -0
- data/lib/synthient/version.rb +5 -0
- data/lib/synthient.rb +30 -0
- data/sig/manifest.yaml +4 -0
- data/sig/synthient/models.rbs +555 -0
- data/sig/synthient.rbs +115 -0
- metadata +62 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Synthient
|
|
4
|
+
# Net::HTTP's own retry is disabled on every session, so a stale keep-alive
|
|
5
|
+
# connection never replays a billed lookup. Retries happen only here:
|
|
6
|
+
# :rate_limit retries a 429 that carries Retry-After, and :transient also
|
|
7
|
+
# retries RETRY_STATUSES and failures other than TLS errors before a
|
|
8
|
+
# response arrives.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class Transport
|
|
12
|
+
MAX_RESPONSE_SIZE = 16 << 20
|
|
13
|
+
MAX_ERROR_SIZE = 64 << 10
|
|
14
|
+
MAX_EVENT_SIZE = 16 << 20
|
|
15
|
+
MAX_IDLE_SESSIONS = 8
|
|
16
|
+
MAX_RETRY_AFTER = 60
|
|
17
|
+
MAX_REDIRECTS = 10
|
|
18
|
+
RETRY_STATUSES = %w[500 502 503 504].freeze
|
|
19
|
+
RETRY = Object.new.freeze
|
|
20
|
+
private_constant :RETRY
|
|
21
|
+
# Net::ProtocolError is what Net::HTTP raises when a proxy refuses CONNECT.
|
|
22
|
+
TRANSPORT_ERRORS = [
|
|
23
|
+
IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
24
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
def self.parse_retry_after(value)
|
|
28
|
+
value = value.to_s.strip
|
|
29
|
+
return nil if value.empty?
|
|
30
|
+
return value.to_i.clamp(0, 2**31) if value.match?(/\A[-+]?\d+\z/)
|
|
31
|
+
|
|
32
|
+
[Time.httpdate(value) - Time.now, 0].max
|
|
33
|
+
rescue ArgumentError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(base_url:, api_key:, user_agent:, max_retries:, timeout:, open_timeout:, response_hook:)
|
|
38
|
+
@base_url = base_url
|
|
39
|
+
@base_scheme = URI(base_url).scheme
|
|
40
|
+
@api_key = api_key
|
|
41
|
+
@user_agent = user_agent
|
|
42
|
+
@max_retries = max_retries
|
|
43
|
+
@timeout = timeout
|
|
44
|
+
@open_timeout = open_timeout
|
|
45
|
+
@response_hook = response_hook
|
|
46
|
+
@idle = []
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
@pid = Process.pid
|
|
49
|
+
@closed = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def json(method, path, policy:, query: nil, body: nil)
|
|
53
|
+
execute(method, path, policy:, query:, body:) do |response|
|
|
54
|
+
raise_api_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
|
|
56
|
+
data = "".b
|
|
57
|
+
read_body(response) do |chunk|
|
|
58
|
+
data << chunk
|
|
59
|
+
raise DecodeError, "response body exceeds #{MAX_RESPONSE_SIZE} bytes" if data.bytesize > MAX_RESPONSE_SIZE
|
|
60
|
+
end
|
|
61
|
+
parse_object(data)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Blank keepalive lines are skipped, and a final line without a newline
|
|
66
|
+
# is still decoded.
|
|
67
|
+
def stream(path)
|
|
68
|
+
execute("GET", path, policy: :transient, accept: "application/x-ndjson", dedicated: true) do |response|
|
|
69
|
+
raise_api_error(response) unless response.code == "200"
|
|
70
|
+
|
|
71
|
+
pending = "".b
|
|
72
|
+
read_body(response) do |chunk|
|
|
73
|
+
pending << chunk
|
|
74
|
+
while (newline = pending.index("\n"))
|
|
75
|
+
line = pending.slice!(0, newline + 1)
|
|
76
|
+
raise DecodeError, "stream event exceeds #{MAX_EVENT_SIZE} bytes" if line.bytesize > MAX_EVENT_SIZE
|
|
77
|
+
|
|
78
|
+
line.strip!
|
|
79
|
+
yield parse_object(line) unless line.empty?
|
|
80
|
+
end
|
|
81
|
+
raise DecodeError, "stream event exceeds #{MAX_EVENT_SIZE} bytes" if pending.bytesize > MAX_EVENT_SIZE
|
|
82
|
+
end
|
|
83
|
+
pending.strip!
|
|
84
|
+
yield parse_object(pending) unless pending.empty?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def redirect_location(path)
|
|
89
|
+
execute("GET", path, policy: :transient) do |response|
|
|
90
|
+
raise_api_error(response) if response.code.to_i >= 400
|
|
91
|
+
unless response.is_a?(Net::HTTPRedirection)
|
|
92
|
+
raise DecodeError, "expected a redirect to the export, got status #{response.code}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
location = resolve_location(response)
|
|
96
|
+
raise DecodeError, "export redirect has no valid location" unless location
|
|
97
|
+
raise DecodeError, "export redirect does not use https" unless ["https", @base_scheme].include?(location.scheme)
|
|
98
|
+
|
|
99
|
+
size = 0
|
|
100
|
+
read_body(response) do |chunk|
|
|
101
|
+
size += chunk.bytesize
|
|
102
|
+
raise DecodeError, "export redirect body exceeds #{MAX_ERROR_SIZE} bytes" if size > MAX_ERROR_SIZE
|
|
103
|
+
end
|
|
104
|
+
location
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Never sends the API key to storage. Accept-Encoding: identity yields the
|
|
109
|
+
# bytes as stored.
|
|
110
|
+
def download(uri, &block)
|
|
111
|
+
redirects = 0
|
|
112
|
+
while redirects <= MAX_REDIRECTS
|
|
113
|
+
session = nil
|
|
114
|
+
response = nil
|
|
115
|
+
next_uri = nil
|
|
116
|
+
begin
|
|
117
|
+
session = start_session(uri)
|
|
118
|
+
request = Net::HTTP::Get.new(uri, "User-Agent" => @user_agent, "Accept-Encoding" => "identity")
|
|
119
|
+
session.request(request) do |res|
|
|
120
|
+
response = res
|
|
121
|
+
if res.is_a?(Net::HTTPRedirection)
|
|
122
|
+
next_uri = resolve_location(res) || raise(Error, "export redirect has no valid location")
|
|
123
|
+
break
|
|
124
|
+
end
|
|
125
|
+
raise_api_error(res) unless res.code == "200"
|
|
126
|
+
|
|
127
|
+
read_body(res, &block)
|
|
128
|
+
end
|
|
129
|
+
rescue *TRANSPORT_ERRORS => e
|
|
130
|
+
raise unless response.nil?
|
|
131
|
+
|
|
132
|
+
raise connection_error(e)
|
|
133
|
+
ensure
|
|
134
|
+
discard(session)
|
|
135
|
+
end
|
|
136
|
+
return unless next_uri
|
|
137
|
+
raise Error, "export redirect downgrades to http" if uri.scheme == "https" && next_uri.scheme == "http"
|
|
138
|
+
|
|
139
|
+
redirects += 1
|
|
140
|
+
uri = next_uri
|
|
141
|
+
end
|
|
142
|
+
raise Error, "too many export redirects"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def close
|
|
146
|
+
sessions = @mutex.synchronize do
|
|
147
|
+
@closed = true
|
|
148
|
+
@pid == Process.pid ? @idle.slice!(0..) : []
|
|
149
|
+
end
|
|
150
|
+
sessions.each { |session| discard(session) }
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def inspect
|
|
155
|
+
"#<#{self.class.name} #{@base_url}>"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def execute(method, path, policy:, query: nil, body: nil, accept: "application/json", dedicated: false, &)
|
|
161
|
+
raise IOError, "client is closed" if @closed
|
|
162
|
+
|
|
163
|
+
uri = URI("#{@base_url}#{path}#{"?#{URI.encode_www_form(query.sort)}" unless query.nil? || query.empty?}")
|
|
164
|
+
(0..@max_retries).each do |attempt|
|
|
165
|
+
delay = catch(RETRY) { return attempt_request(uri, method, body, accept, policy, attempt, dedicated, &) }
|
|
166
|
+
sleep(delay)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def attempt_request(uri, method, body, accept, policy, attempt, dedicated)
|
|
171
|
+
response = nil
|
|
172
|
+
reusable = false
|
|
173
|
+
session = dedicated ? start_session(uri) : checkout(uri)
|
|
174
|
+
headers = { "X-Api-Key" => @api_key, "User-Agent" => @user_agent, "Accept" => accept }
|
|
175
|
+
headers["Content-Type"] = "application/json" if body
|
|
176
|
+
result = nil
|
|
177
|
+
session.request(Net::HTTPGenericRequest.new(method, !body.nil?, true, uri, headers), body) do |res|
|
|
178
|
+
response = res
|
|
179
|
+
report(res) if @response_hook
|
|
180
|
+
retryable = if res.code == "429"
|
|
181
|
+
!res["retry-after"].to_s.strip.empty?
|
|
182
|
+
else
|
|
183
|
+
policy == :transient && RETRY_STATUSES.include?(res.code)
|
|
184
|
+
end
|
|
185
|
+
retry_after = Transport.parse_retry_after(res["retry-after"])
|
|
186
|
+
if retryable && attempt < @max_retries && (retry_after.nil? || retry_after <= MAX_RETRY_AFTER)
|
|
187
|
+
throw RETRY, [backoff(attempt), retry_after || 0].max
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
result = yield res
|
|
191
|
+
end
|
|
192
|
+
reusable = !dedicated
|
|
193
|
+
result
|
|
194
|
+
rescue *TRANSPORT_ERRORS => e
|
|
195
|
+
raise unless response.nil?
|
|
196
|
+
|
|
197
|
+
throw RETRY, backoff(attempt) if policy == :transient && attempt < @max_retries && !e.is_a?(OpenSSL::SSL::SSLError)
|
|
198
|
+
|
|
199
|
+
raise connection_error(e)
|
|
200
|
+
ensure
|
|
201
|
+
reusable ? checkin(session) : discard(session)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def backoff(attempt)
|
|
205
|
+
[2**[attempt, 5].min, 30].min * (0.75 + (rand / 2))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def report(response)
|
|
209
|
+
headers = response.each_header.to_h { |name, value| [name.freeze, value.freeze] }.freeze
|
|
210
|
+
limits = %w[ratelimit-limit ratelimit-remaining ratelimit-reset].map { |name| response[name]&.strip }
|
|
211
|
+
if limits.all? { |value| value&.match?(/\A\d+\z/) }
|
|
212
|
+
limit, remaining, reset = limits.map(&:to_i)
|
|
213
|
+
rate_limit = RateLimit.new(limit:, remaining:, reset: [reset, 2**31].min)
|
|
214
|
+
end
|
|
215
|
+
@response_hook.call(
|
|
216
|
+
ResponseMetadata.new(status: response.code.to_i, request_id: response["x-request-id"], rate_limit:, headers:)
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Exceptions from the block pass through unchanged, so an IOError raised
|
|
221
|
+
# by a caller's block is not reported as a ConnectionError.
|
|
222
|
+
def read_body(response)
|
|
223
|
+
in_block = false
|
|
224
|
+
response.read_body do |chunk|
|
|
225
|
+
in_block = true
|
|
226
|
+
yield chunk
|
|
227
|
+
in_block = false
|
|
228
|
+
end
|
|
229
|
+
rescue *TRANSPORT_ERRORS => e
|
|
230
|
+
raise if in_block
|
|
231
|
+
|
|
232
|
+
raise connection_error(e)
|
|
233
|
+
rescue Zlib::Error
|
|
234
|
+
raise if in_block
|
|
235
|
+
|
|
236
|
+
raise DecodeError, "response body has invalid compression"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def connection_error(error)
|
|
240
|
+
timeout = error.is_a?(Timeout::Error) || error.is_a?(Errno::ETIMEDOUT)
|
|
241
|
+
(timeout ? TimeoutError : ConnectionError).new(error.message)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_object(json)
|
|
245
|
+
object = JSON.parse(json, freeze: true)
|
|
246
|
+
raise DecodeError, "response is not a JSON object" unless object.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
object
|
|
249
|
+
rescue JSON::ParserError
|
|
250
|
+
raise DecodeError, "response is not valid JSON"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def raise_api_error(response)
|
|
254
|
+
status = response.code.to_i
|
|
255
|
+
retry_after = Transport.parse_retry_after(response["retry-after"])
|
|
256
|
+
request_id = response["x-request-id"]
|
|
257
|
+
data = "".b
|
|
258
|
+
begin
|
|
259
|
+
read_body(response) do |chunk|
|
|
260
|
+
data << chunk
|
|
261
|
+
break if data.bytesize >= MAX_ERROR_SIZE
|
|
262
|
+
end
|
|
263
|
+
rescue ConnectionError
|
|
264
|
+
raise APIError.new(status:, request_id:, retry_after:)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
problem = begin
|
|
268
|
+
JSON.parse(data, freeze: true)
|
|
269
|
+
rescue JSON::ParserError
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
fields = {}
|
|
273
|
+
if problem.is_a?(Hash)
|
|
274
|
+
%w[type title detail].each { |key| fields[key.to_sym] = problem[key] if problem[key].is_a?(String) }
|
|
275
|
+
errors = problem["errors"]
|
|
276
|
+
if errors.is_a?(Hash) && errors.each_value.all? { |messages| messages.is_a?(Array) && messages.all?(String) }
|
|
277
|
+
fields[:errors] = errors
|
|
278
|
+
end
|
|
279
|
+
request_id = problem["instance"] if (request_id.nil? || request_id.empty?) && problem["instance"].is_a?(String)
|
|
280
|
+
elsif response.content_type == "text/plain"
|
|
281
|
+
fields[:detail] = data.force_encoding(Encoding::UTF_8).scrub.strip
|
|
282
|
+
end
|
|
283
|
+
raise APIError.new(status:, request_id:, retry_after:, **fields)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def resolve_location(response)
|
|
287
|
+
location = response["location"].to_s.strip
|
|
288
|
+
return nil if location.empty?
|
|
289
|
+
|
|
290
|
+
uri = URI.join(response.uri.to_s, location)
|
|
291
|
+
uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
|
|
292
|
+
rescue URI::Error
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def checkout(uri)
|
|
297
|
+
session = @mutex.synchronize do
|
|
298
|
+
if @pid != Process.pid
|
|
299
|
+
@idle.clear
|
|
300
|
+
@pid = Process.pid
|
|
301
|
+
end
|
|
302
|
+
@idle.pop
|
|
303
|
+
end
|
|
304
|
+
session || start_session(uri)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def checkin(session)
|
|
308
|
+
kept = @mutex.synchronize do
|
|
309
|
+
next false if @closed || @pid != Process.pid || @idle.size >= MAX_IDLE_SESSIONS
|
|
310
|
+
|
|
311
|
+
@idle.push(session)
|
|
312
|
+
end
|
|
313
|
+
discard(session) unless kept
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def discard(session)
|
|
317
|
+
session.finish if session&.started?
|
|
318
|
+
rescue IOError
|
|
319
|
+
nil
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def start_session(uri)
|
|
323
|
+
session = Net::HTTP.new(uri.hostname, uri.port)
|
|
324
|
+
session.use_ssl = uri.scheme == "https"
|
|
325
|
+
session.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
326
|
+
session.verify_hostname = true
|
|
327
|
+
session.open_timeout = @open_timeout
|
|
328
|
+
session.read_timeout = @timeout
|
|
329
|
+
session.write_timeout = @timeout
|
|
330
|
+
session.keep_alive_timeout = 2
|
|
331
|
+
session.max_retries = 0
|
|
332
|
+
# A body cut short of its Content-Length raises EOFError instead of
|
|
333
|
+
# reading as complete.
|
|
334
|
+
session.ignore_eof = false
|
|
335
|
+
session.response_body_encoding = false
|
|
336
|
+
session.set_debug_output(nil)
|
|
337
|
+
session.start
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
data/lib/synthient.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "time"
|
|
9
|
+
require "uri"
|
|
10
|
+
require "zlib"
|
|
11
|
+
|
|
12
|
+
# The official Ruby client for the Synthient API: IP and domain intelligence,
|
|
13
|
+
# account details, and real-time and bulk data feeds. Start with
|
|
14
|
+
# Synthient::Client.
|
|
15
|
+
module Synthient
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.synthient.com/api/v4"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "synthient/version"
|
|
20
|
+
require_relative "synthient/errors"
|
|
21
|
+
require_relative "synthient/response_metadata"
|
|
22
|
+
require_relative "synthient/model"
|
|
23
|
+
require_relative "synthient/models/lookup"
|
|
24
|
+
require_relative "synthient/models/domain"
|
|
25
|
+
require_relative "synthient/models/account"
|
|
26
|
+
require_relative "synthient/models/export"
|
|
27
|
+
require_relative "synthient/models/events"
|
|
28
|
+
require_relative "synthient/transport"
|
|
29
|
+
require_relative "synthient/feed"
|
|
30
|
+
require_relative "synthient/client"
|
data/sig/manifest.yaml
ADDED