voiceml 0.9.2 → 0.9.3
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 +4 -4
- data/README.md +17 -13
- data/lib/voiceml/client.rb +53 -13
- data/lib/voiceml/errors.rb +15 -2
- data/lib/voiceml/models/accounts.rb +47 -3
- data/lib/voiceml/models/applications.rb +6 -0
- data/lib/voiceml/models/assistants_v1.rb +40 -0
- data/lib/voiceml/models/calls.rb +4 -0
- data/lib/voiceml/models/common.rb +35 -4
- data/lib/voiceml/models/conferences.rb +8 -0
- data/lib/voiceml/models/conversations_v1.rb +131 -29
- data/lib/voiceml/models/diagnostics.rb +33 -12
- data/lib/voiceml/models/exports.rb +58 -0
- data/lib/voiceml/models/incoming_phone_numbers.rb +4 -0
- data/lib/voiceml/models/media.rb +4 -0
- data/lib/voiceml/models/messages.rb +29 -0
- data/lib/voiceml/models/messaging_v1.rb +35 -2
- data/lib/voiceml/models/monitor_v1.rb +54 -0
- data/lib/voiceml/models/notifications.rb +2 -0
- data/lib/voiceml/models/outgoing_caller_ids.rb +6 -0
- data/lib/voiceml/models/payments.rb +2 -0
- data/lib/voiceml/models/pricing.rb +38 -4
- data/lib/voiceml/models/queues.rb +8 -0
- data/lib/voiceml/models/recordings.rb +5 -1
- data/lib/voiceml/models/routes_v2.rb +6 -2
- data/lib/voiceml/models/sip.rb +34 -6
- data/lib/voiceml/models/sip_registrations.rb +49 -0
- data/lib/voiceml/models/siprec.rb +5 -17
- data/lib/voiceml/models/streams.rb +4 -16
- data/lib/voiceml/models/transcriptions.rb +35 -4
- data/lib/voiceml/models/usage_records.rb +48 -0
- data/lib/voiceml/models/user_defined_messages.rb +2 -0
- data/lib/voiceml/models/voice_v1.rb +29 -19
- data/lib/voiceml/request_validator.rb +125 -0
- data/lib/voiceml/resources/accounts.rb +75 -0
- data/lib/voiceml/resources/applications.rb +3 -1
- data/lib/voiceml/resources/assistants_v1.rb +53 -77
- data/lib/voiceml/resources/base.rb +138 -16
- data/lib/voiceml/resources/calls.rb +34 -49
- data/lib/voiceml/resources/conferences.rb +26 -5
- data/lib/voiceml/resources/conversations_v1.rb +147 -172
- data/lib/voiceml/resources/diagnostics.rb +20 -35
- data/lib/voiceml/resources/exports.rb +36 -0
- data/lib/voiceml/resources/incoming_phone_numbers.rb +53 -32
- data/lib/voiceml/resources/messages.rb +79 -15
- data/lib/voiceml/resources/messaging_v1.rb +47 -13
- data/lib/voiceml/resources/monitor_v1.rb +68 -0
- data/lib/voiceml/resources/notifications.rb +1 -1
- data/lib/voiceml/resources/outgoing_caller_ids.rb +77 -0
- data/lib/voiceml/resources/pricing.rb +13 -5
- data/lib/voiceml/resources/queues.rb +6 -5
- data/lib/voiceml/resources/recordings.rb +39 -5
- data/lib/voiceml/resources/routes_v2.rb +10 -4
- data/lib/voiceml/resources/sip.rb +30 -11
- data/lib/voiceml/resources/transcriptions.rb +40 -0
- data/lib/voiceml/resources/usage_records.rb +51 -0
- data/lib/voiceml/resources/voice_v1.rb +31 -38
- data/lib/voiceml/transport.rb +277 -230
- data/lib/voiceml/version.rb +1 -1
- data/lib/voiceml.rb +14 -15
- metadata +18 -6
data/lib/voiceml/transport.rb
CHANGED
|
@@ -1,323 +1,361 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'date'
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'net/http'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'time'
|
|
5
8
|
require 'uri'
|
|
6
9
|
|
|
7
10
|
require_relative 'errors'
|
|
8
11
|
require_relative 'version'
|
|
9
12
|
|
|
10
13
|
module VoiceML
|
|
11
|
-
# HTTP transport
|
|
12
|
-
#
|
|
13
|
-
# - Auth: HTTP Basic with `account_sid` (Twilio-format `AC` + 32 hex) as the username and the
|
|
14
|
-
# per-tenant API key as the password. Drop-in compatible with the Twilio SDK constructor.
|
|
15
|
-
# - Wire format: requests are form-urlencoded by default (Twilio convention). The server also
|
|
16
|
-
# accepts JSON; pass `json: <hash>` to send JSON instead. Responses are always JSON.
|
|
17
|
-
# - Retries: 429 + 5xx are retried up to `max_retries` times with exponential backoff,
|
|
18
|
-
# honoring the `Retry-After` header when the server emits one.
|
|
19
|
-
# - Binary fetch: `fetch_bytes` follows the 302 -> S3 redirect that
|
|
20
|
-
# `GET /Recordings/{sid}.wav` issues when audio has been archived. Callers usually only
|
|
21
|
-
# care about the final bytes.
|
|
22
|
-
#
|
|
14
|
+
# HTTP transport with exclusive connection use, per-host keep-alive, and
|
|
15
|
+
# method-aware retries. Caller-supplied connections remain caller-owned.
|
|
23
16
|
# @api private
|
|
24
17
|
class Transport
|
|
25
|
-
DEFAULT_BASE_URL
|
|
26
|
-
DEFAULT_TIMEOUT
|
|
18
|
+
DEFAULT_BASE_URL = 'https://voiceml.voicetel.com'
|
|
19
|
+
DEFAULT_TIMEOUT = 30
|
|
27
20
|
DEFAULT_MAX_RETRIES = 2
|
|
28
|
-
RETRYABLE_STATUSES
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
22
|
+
IDEMPOTENT_METHODS = %w[GET HEAD DELETE PUT].freeze
|
|
23
|
+
RETRY_AFTER_CAP = 60.0
|
|
24
|
+
MAX_REDIRECTS = 5
|
|
25
|
+
|
|
26
|
+
# Carries the phase separately from the exception type: an SSL error or
|
|
27
|
+
# ETIMEDOUT during a response is not proof that a POST was never sent.
|
|
28
|
+
class NetworkFailure < StandardError
|
|
29
|
+
attr_reader :original, :connecting
|
|
30
|
+
|
|
31
|
+
def initialize(original, connecting)
|
|
32
|
+
super(original.message)
|
|
33
|
+
@original = original
|
|
34
|
+
@connecting = connecting
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
private_constant :NetworkFailure
|
|
38
|
+
|
|
39
|
+
def self.strip_trailing_slashes(value)
|
|
40
|
+
i = value.length
|
|
41
|
+
i -= 1 while i.positive? && value.getbyte(i - 1) == 47
|
|
42
|
+
value[0, i]
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
attr_reader :account_sid, :base_url, :max_retries, :timeout, :user_agent
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
# `http_client:` is a Net::HTTP for one origin, or a factory
|
|
48
|
+
# `->(host, port) { Net::HTTP.new(host, port) }` for multiple origins.
|
|
49
|
+
# A one-argument factory receiving a URI is also accepted.
|
|
50
|
+
def initialize(account_sid: nil, api_key: nil, base_url: DEFAULT_BASE_URL,
|
|
41
51
|
timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
|
|
42
52
|
user_agent: nil, http_client: nil)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
if !account_sid.nil? && (!account_sid.is_a?(String) || account_sid.empty?)
|
|
54
|
+
raise ConfigurationError, 'account_sid must not be empty'
|
|
55
|
+
end
|
|
56
|
+
if !api_key.nil? && (!api_key.is_a?(String) || api_key.empty?)
|
|
57
|
+
raise ConfigurationError, 'api_key must not be empty'
|
|
58
|
+
end
|
|
59
|
+
unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
60
|
+
raise ConfigurationError, 'max_retries must be an integer >= 0'
|
|
61
|
+
end
|
|
62
|
+
unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
|
|
63
|
+
raise ConfigurationError, 'timeout must be positive and finite'
|
|
64
|
+
end
|
|
65
|
+
unless http_client.nil? || http_client.is_a?(Net::HTTP) || http_client.respond_to?(:call)
|
|
66
|
+
raise ConfigurationError, 'http_client must be a Net::HTTP or a callable'
|
|
67
|
+
end
|
|
46
68
|
|
|
47
69
|
@account_sid = account_sid
|
|
48
|
-
@
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
@credential = -> { api_key }
|
|
71
|
+
begin
|
|
72
|
+
uri = URI.parse(base_url)
|
|
73
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
|
|
74
|
+
raise ArgumentError, 'expected an HTTP(S) base URL without credentials, query, or fragment'
|
|
75
|
+
end
|
|
76
|
+
@base_url = self.class.strip_trailing_slashes(base_url)
|
|
77
|
+
rescue URI::Error, ArgumentError, TypeError => e
|
|
78
|
+
raise ConfigurationError, "invalid base_url: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
@timeout = timeout
|
|
51
81
|
@max_retries = max_retries
|
|
52
|
-
@user_agent
|
|
53
|
-
@
|
|
54
|
-
@
|
|
55
|
-
@
|
|
56
|
-
@persistent = http_client
|
|
82
|
+
@user_agent = user_agent || "voiceml-ruby/#{VoiceML::VERSION}"
|
|
83
|
+
@http_client = http_client
|
|
84
|
+
@connections = {}
|
|
85
|
+
@mutex = Mutex.new
|
|
57
86
|
end
|
|
58
87
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
# request will transparently open a fresh connection.
|
|
88
|
+
# Release SDK-owned sockets. In-flight requests finish before close runs;
|
|
89
|
+
# caller-owned clients are retained and never finished by this method.
|
|
62
90
|
def close
|
|
63
|
-
@
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@connections.delete_if do |_origin, (http, owned)|
|
|
93
|
+
finish(http) if owned
|
|
94
|
+
owned
|
|
95
|
+
end
|
|
96
|
+
end
|
|
64
97
|
end
|
|
65
98
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
# resources at their subdomain — Conversations, Messaging Service). When set, the request
|
|
71
|
-
# goes to an absolute URL so the correct host/SNI is used per request; see `ScopedTransport`.
|
|
72
|
-
#
|
|
73
|
-
# @return [Hash, Array, nil] the parsed JSON body (or `nil` for empty 2xx).
|
|
74
|
-
# @raise [VoiceML::ApiError] for non-2xx responses (subclasses by status family).
|
|
75
|
-
def request(method, path, params: nil, form: nil, json: nil, base_url: nil)
|
|
76
|
-
uri = build_uri(request_path(path, base_url), params)
|
|
77
|
-
|
|
78
|
-
attempt = 0
|
|
79
|
-
loop do
|
|
80
|
-
response = perform_request(method, uri, form: form, json: json)
|
|
81
|
-
|
|
82
|
-
if RETRYABLE_STATUSES.include?(response.code.to_i) && attempt < @max_retries
|
|
83
|
-
sleep(backoff_delay(attempt, response))
|
|
84
|
-
attempt += 1
|
|
85
|
-
next
|
|
86
|
-
end
|
|
99
|
+
def request(method, path, params: nil, form: nil, json: nil, base_url: nil, auth: true)
|
|
100
|
+
uri = build_uri(path, base_url, params)
|
|
101
|
+
parse_response(send_with_retries(method, uri, form: form, json: json, auth: auth))
|
|
102
|
+
end
|
|
87
103
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
# Raw text and JSON use the same client, timeout, auth and retry policy.
|
|
105
|
+
def request_raw(method, path, params: nil, base_url: nil, auth: true)
|
|
106
|
+
uri = build_uri(path, base_url, params)
|
|
107
|
+
response = send_with_retries(method, uri, auth: auth, accept: '*/*')
|
|
108
|
+
raise_for_response(response) unless response.is_a?(Net::HTTPSuccess)
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
attempt += 1
|
|
95
|
-
end
|
|
110
|
+
[response.code.to_i, response.body.to_s, response.each_header.to_h]
|
|
96
111
|
end
|
|
97
112
|
|
|
98
|
-
#
|
|
99
|
-
# redirect that `GET /Recordings/{sid}.wav` issues when audio has been archived.
|
|
100
|
-
#
|
|
101
|
-
# @return [Array(Integer, String, Hash)] status code, response body bytes, header hash.
|
|
113
|
+
# Authenticated binary GET with manual, origin-aware redirect handling.
|
|
102
114
|
def fetch_bytes(path, base_url: nil)
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
origin = build_uri(path, base_url, nil)
|
|
116
|
+
uri = origin
|
|
117
|
+
redirects = 0
|
|
105
118
|
loop do
|
|
106
|
-
|
|
119
|
+
response = send_with_retries(:get, uri, auth: same_origin?(origin, uri), accept: '*/*')
|
|
120
|
+
unless response.is_a?(Net::HTTPRedirection)
|
|
121
|
+
raise_for_response(response) unless response.is_a?(Net::HTTPSuccess)
|
|
107
122
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
return [response.code.to_i, response.body.to_s, response.each_header.to_h]
|
|
124
|
+
end
|
|
125
|
+
raise ConnectionError.new('too many redirects', status_code: 0) if redirects >= MAX_REDIRECTS
|
|
111
126
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
uri = URI.parse(response['location'])
|
|
116
|
-
next
|
|
117
|
-
when Net::HTTPSuccess
|
|
118
|
-
headers = response.each_header.to_h
|
|
119
|
-
return [response.code.to_i, response.body || '', headers]
|
|
120
|
-
else
|
|
121
|
-
raise_for_response(response)
|
|
127
|
+
target = resolve_redirect(uri, response['location'])
|
|
128
|
+
if uri.scheme == 'https' && target.scheme == 'http'
|
|
129
|
+
raise ConnectionError.new('refusing an https -> http redirect', status_code: 0)
|
|
122
130
|
end
|
|
131
|
+
uri = target
|
|
132
|
+
redirects += 1
|
|
123
133
|
end
|
|
124
134
|
end
|
|
125
135
|
|
|
136
|
+
def inspect
|
|
137
|
+
"#<#{self.class.name} account_sid=#{@account_sid} base_url=#{@base_url}>"
|
|
138
|
+
end
|
|
139
|
+
alias to_s inspect
|
|
140
|
+
|
|
126
141
|
private
|
|
127
142
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
def build_uri(path, override, params)
|
|
144
|
+
base = override || @base_url
|
|
145
|
+
uri = URI.parse(path)
|
|
146
|
+
uri = URI.join("#{base}/", path) unless uri.absolute?
|
|
147
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo
|
|
148
|
+
raise ArgumentError, 'expected an HTTP(S) request URL without userinfo'
|
|
149
|
+
end
|
|
150
|
+
uri.query = encode_values(params) if params && !params.empty?
|
|
151
|
+
uri
|
|
152
|
+
rescue URI::Error, ArgumentError, TypeError => e
|
|
153
|
+
raise ConnectionError.new("invalid request URI: #{e.message}", status_code: 0)
|
|
131
154
|
end
|
|
132
155
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
156
|
+
def send_with_retries(method, uri, form: nil, json: nil, auth: true, accept: 'application/json')
|
|
157
|
+
verb = method.to_s.upcase
|
|
158
|
+
idempotent = IDEMPOTENT_METHODS.include?(verb)
|
|
159
|
+
attempt = 0
|
|
160
|
+
loop do
|
|
161
|
+
req = build_net_request(verb, uri)
|
|
162
|
+
req['Accept'] = accept
|
|
163
|
+
req['User-Agent'] = @user_agent
|
|
164
|
+
if auth
|
|
165
|
+
key = @credential.call
|
|
166
|
+
unless @account_sid && key
|
|
167
|
+
raise ConfigurationError, 'account_sid and api_key are required for authenticated requests'
|
|
168
|
+
end
|
|
169
|
+
req.basic_auth(@account_sid, key)
|
|
170
|
+
end
|
|
171
|
+
if json
|
|
172
|
+
req['Content-Type'] = 'application/json'
|
|
173
|
+
req.body = JSON.generate(json)
|
|
174
|
+
elsif form
|
|
175
|
+
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
176
|
+
req.body = encode_values(form)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
response = exchange(uri, req)
|
|
180
|
+
status = response.code.to_i
|
|
181
|
+
retry_after = retry_after_seconds(response)
|
|
182
|
+
if status == 429 && retry_after && retry_after > RETRY_AFTER_CAP
|
|
183
|
+
raise_for_response(response)
|
|
184
|
+
end
|
|
185
|
+
if attempt < @max_retries && (status == 429 || (idempotent && RETRYABLE_STATUSES.include?(status)))
|
|
186
|
+
response.body # Net::HTTP buffers the complete body before releasing the connection.
|
|
187
|
+
delay = retry_after || backoff_delay(attempt)
|
|
188
|
+
sleep_for(delay) if delay.positive?
|
|
189
|
+
attempt += 1
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
return response
|
|
193
|
+
rescue NetworkFailure => e
|
|
194
|
+
# These failures can only occur while establishing the connection
|
|
195
|
+
# (DNS, routing, TCP connect, TLS handshake), so the request was
|
|
196
|
+
# provably never sent — safe to retry even for POST.
|
|
197
|
+
safe_connect = [SocketError, Net::OpenTimeout, OpenSSL::SSL::SSLError,
|
|
198
|
+
Errno::ECONNREFUSED, Errno::ETIMEDOUT,
|
|
199
|
+
Errno::ENETUNREACH, Errno::EHOSTUNREACH].any? { |c| e.original.is_a?(c) }
|
|
200
|
+
if attempt < @max_retries && (idempotent || safe_connect)
|
|
201
|
+
sleep_for(backoff_delay(attempt))
|
|
202
|
+
attempt += 1
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
raise ConnectionError.new("transport error after #{attempt + 1} attempt(s): #{e.original.message}",
|
|
206
|
+
status_code: 0), cause: e.original
|
|
207
|
+
end
|
|
208
|
+
end
|
|
137
209
|
|
|
138
|
-
|
|
210
|
+
# The lock covers both connection selection and the entire response read.
|
|
211
|
+
# Host changes do not finish connections belonging to another origin.
|
|
212
|
+
def exchange(uri, req)
|
|
213
|
+
@mutex.synchronize do
|
|
214
|
+
origin = [uri.scheme, uri.host, uri.port]
|
|
215
|
+
connecting = true
|
|
216
|
+
http, owned = @connections[origin] ||= connection_for(uri)
|
|
217
|
+
http.max_retries = 0 # Only the counted SDK retry loop may resend a request.
|
|
218
|
+
http.start unless http.started?
|
|
219
|
+
connecting = false
|
|
220
|
+
http.request(req)
|
|
221
|
+
rescue Error
|
|
222
|
+
raise
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
@connections.delete(origin)
|
|
225
|
+
finish(http) if http && owned
|
|
226
|
+
raise NetworkFailure.new(e, connecting), cause: e
|
|
227
|
+
end
|
|
139
228
|
end
|
|
140
229
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
230
|
+
def connection_for(uri)
|
|
231
|
+
owned = @http_client.nil?
|
|
232
|
+
http = if owned
|
|
233
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
234
|
+
elsif @http_client.is_a?(Net::HTTP)
|
|
235
|
+
@http_client
|
|
236
|
+
elsif @http_client.method(:call).arity == 1 || (@http_client.respond_to?(:arity) && @http_client.arity == 1)
|
|
237
|
+
@http_client.call(uri)
|
|
238
|
+
else
|
|
239
|
+
@http_client.call(uri.host, uri.port)
|
|
240
|
+
end
|
|
241
|
+
unless http.is_a?(Net::HTTP) && http.address == uri.host && http.port == uri.port
|
|
242
|
+
raise ConfigurationError, 'http_client must match the requested host/port; use a factory for multiple hosts'
|
|
150
243
|
end
|
|
151
|
-
|
|
244
|
+
if !http.started?
|
|
245
|
+
http.use_ssl = uri.scheme == 'https'
|
|
246
|
+
elsif http.use_ssl? != (uri.scheme == 'https')
|
|
247
|
+
raise ConfigurationError, 'http_client TLS scheme does not match the requested origin'
|
|
248
|
+
end
|
|
249
|
+
if owned
|
|
250
|
+
http.open_timeout = @timeout
|
|
251
|
+
http.read_timeout = @timeout
|
|
252
|
+
http.write_timeout = @timeout
|
|
253
|
+
http.keep_alive_timeout = 30
|
|
254
|
+
end
|
|
255
|
+
[http, owned]
|
|
152
256
|
end
|
|
153
257
|
|
|
154
|
-
def
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
258
|
+
def finish(http)
|
|
259
|
+
http.finish if http.started?
|
|
260
|
+
rescue IOError, SystemCallError
|
|
261
|
+
nil # A closed socket already satisfies close's postcondition.
|
|
262
|
+
end
|
|
158
263
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
URI.encode_www_form(pairs)
|
|
264
|
+
def build_net_request(verb, uri)
|
|
265
|
+
klass = { 'GET' => Net::HTTP::Get, 'HEAD' => Net::HTTP::Head, 'POST' => Net::HTTP::Post,
|
|
266
|
+
'PUT' => Net::HTTP::Put, 'PATCH' => Net::HTTP::Patch, 'DELETE' => Net::HTTP::Delete }[verb]
|
|
267
|
+
raise ArgumentError, "unsupported HTTP method: #{verb}" unless klass
|
|
268
|
+
|
|
269
|
+
klass.new(uri)
|
|
166
270
|
end
|
|
167
271
|
|
|
168
|
-
def
|
|
272
|
+
def encode_values(values)
|
|
169
273
|
pairs = []
|
|
170
|
-
|
|
171
|
-
next if
|
|
274
|
+
values.each do |key, value|
|
|
275
|
+
next if value.nil?
|
|
172
276
|
|
|
173
|
-
|
|
174
|
-
v.each { |entry| pairs << [k.to_s, format_scalar(entry)] }
|
|
175
|
-
else
|
|
176
|
-
pairs << [k.to_s, format_scalar(v)]
|
|
177
|
-
end
|
|
277
|
+
(value.is_a?(Array) ? value : [value]).each { |v| pairs << [key.to_s, format_scalar(v)] }
|
|
178
278
|
end
|
|
179
279
|
URI.encode_www_form(pairs)
|
|
180
280
|
end
|
|
181
281
|
|
|
182
282
|
def format_scalar(value)
|
|
183
283
|
case value
|
|
184
|
-
when
|
|
185
|
-
when
|
|
284
|
+
when Time then value.getutc.iso8601
|
|
285
|
+
when DateTime then value.new_offset(0).iso8601
|
|
286
|
+
when Date then value.iso8601
|
|
287
|
+
when Hash then JSON.generate(value)
|
|
186
288
|
else value.to_s
|
|
187
289
|
end
|
|
188
290
|
end
|
|
189
291
|
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if !json.nil?
|
|
195
|
-
req['Content-Type'] = 'application/json'
|
|
196
|
-
req.body = JSON.generate(json)
|
|
197
|
-
elsif !form.nil? && method.to_s.upcase != 'GET' && method.to_s.upcase != 'DELETE'
|
|
198
|
-
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
199
|
-
req.body = encode_form(form)
|
|
200
|
-
end
|
|
292
|
+
def retry_after_seconds(response)
|
|
293
|
+
text = response['retry-after']
|
|
294
|
+
return nil unless text
|
|
295
|
+
return text.to_f if text.match?(/\A\d+(?:\.\d+)?\z/)
|
|
201
296
|
|
|
202
|
-
|
|
297
|
+
[Time.httpdate(text) - Time.now, 0.0].max
|
|
298
|
+
rescue ArgumentError
|
|
299
|
+
nil
|
|
203
300
|
end
|
|
204
301
|
|
|
205
|
-
def
|
|
206
|
-
|
|
207
|
-
when 'GET' then Net::HTTP::Get.new(uri)
|
|
208
|
-
when 'POST' then Net::HTTP::Post.new(uri)
|
|
209
|
-
when 'PUT' then Net::HTTP::Put.new(uri)
|
|
210
|
-
when 'DELETE' then Net::HTTP::Delete.new(uri)
|
|
211
|
-
when 'PATCH' then Net::HTTP::Patch.new(uri)
|
|
212
|
-
else
|
|
213
|
-
raise ArgumentError, "unsupported HTTP method: #{method}"
|
|
214
|
-
end
|
|
302
|
+
def backoff_delay(attempt)
|
|
303
|
+
rand * [8.0, 0.5 * (2**[attempt, 5].min)].min
|
|
215
304
|
end
|
|
216
305
|
|
|
217
|
-
def
|
|
218
|
-
|
|
219
|
-
req['User-Agent'] = @user_agent
|
|
220
|
-
req.basic_auth(@account_sid, @api_key) if auth
|
|
306
|
+
def sleep_for(seconds)
|
|
307
|
+
sleep(seconds)
|
|
221
308
|
end
|
|
222
309
|
|
|
223
|
-
def
|
|
224
|
-
|
|
225
|
-
conn.request(req)
|
|
226
|
-
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
227
|
-
@conn_mutex.synchronize { finish_connection }
|
|
228
|
-
persistent_connection(uri).request(req)
|
|
229
|
-
end
|
|
310
|
+
def resolve_redirect(uri, location)
|
|
311
|
+
raise ArgumentError, 'redirect missing Location' if location.nil? || location.empty?
|
|
230
312
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
end
|
|
238
|
-
finish_connection
|
|
239
|
-
h = Net::HTTP.new(uri.host, uri.port)
|
|
240
|
-
h.use_ssl = uri.scheme == 'https'
|
|
241
|
-
h.open_timeout = @timeout
|
|
242
|
-
h.read_timeout = @timeout
|
|
243
|
-
h.keep_alive_timeout = 30
|
|
244
|
-
h.start
|
|
245
|
-
@persistent = h
|
|
246
|
-
end
|
|
313
|
+
target = URI.join(uri.to_s, location)
|
|
314
|
+
raise ArgumentError, 'redirect must use HTTP(S) without userinfo' unless target.is_a?(URI::HTTP) && target.host && !target.userinfo
|
|
315
|
+
|
|
316
|
+
target
|
|
317
|
+
rescue URI::Error, ArgumentError => e
|
|
318
|
+
raise ConnectionError.new("invalid redirect: #{e.message}", status_code: 0)
|
|
247
319
|
end
|
|
248
320
|
|
|
249
|
-
def
|
|
250
|
-
|
|
251
|
-
@persistent = nil
|
|
321
|
+
def same_origin?(a, b)
|
|
322
|
+
a.scheme == b.scheme && a.host == b.host && a.port == b.port
|
|
252
323
|
end
|
|
253
324
|
|
|
254
325
|
def parse_response(response)
|
|
255
|
-
|
|
256
|
-
return nil if
|
|
257
|
-
|
|
258
|
-
if status >= 200 && status < 300
|
|
259
|
-
begin
|
|
260
|
-
return JSON.parse(response.body)
|
|
261
|
-
rescue JSON::ParserError => e
|
|
262
|
-
raise ApiError.new("non-JSON success response (#{e.message}): #{response.body.to_s[0, 200]}",
|
|
263
|
-
status_code: status, body: response.body)
|
|
264
|
-
end
|
|
265
|
-
end
|
|
326
|
+
raise_for_response(response) unless response.is_a?(Net::HTTPSuccess)
|
|
327
|
+
return nil if response.body.nil? || response.body.empty?
|
|
266
328
|
|
|
267
|
-
|
|
329
|
+
JSON.parse(response.body)
|
|
330
|
+
rescue JSON::ParserError => e
|
|
331
|
+
raise ApiError.new("could not decode success response as JSON: #{e.message}",
|
|
332
|
+
status_code: response.code.to_i, body: response.body)
|
|
268
333
|
end
|
|
269
334
|
|
|
270
335
|
def raise_for_response(response)
|
|
271
|
-
|
|
272
|
-
raw_body = response.body
|
|
336
|
+
raw = response.body
|
|
273
337
|
body = begin
|
|
274
|
-
|
|
338
|
+
raw.nil? || raw.empty? ? raw : JSON.parse(raw)
|
|
275
339
|
rescue JSON::ParserError
|
|
276
|
-
|
|
340
|
+
raw
|
|
277
341
|
end
|
|
278
|
-
|
|
279
|
-
code =
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
m = body['message']
|
|
286
|
-
message = m if m.is_a?(String) && !m.empty?
|
|
287
|
-
mi = body['more_info']
|
|
288
|
-
more_info = mi if mi.is_a?(String) && !mi.empty?
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
raise VoiceML.error_from_response(status, message, code: code, body: body, more_info: more_info)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def backoff_delay(attempt, response = nil)
|
|
295
|
-
if response
|
|
296
|
-
ra = response['retry-after']
|
|
297
|
-
if ra
|
|
298
|
-
begin
|
|
299
|
-
return [Float(ra), 0.0].max
|
|
300
|
-
rescue ArgumentError, TypeError
|
|
301
|
-
# fall through
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
[8.0, 0.5 * (2**attempt)].min
|
|
342
|
+
data = body.is_a?(Hash) ? body : {}
|
|
343
|
+
code = data['code']
|
|
344
|
+
code = code.to_i if code.is_a?(String) && code.match?(/\A\d+\z/)
|
|
345
|
+
error = VoiceML.error_from_response(response.code.to_i, data['message'] || "HTTP #{response.code}",
|
|
346
|
+
code: code, body: body, more_info: data['more_info'])
|
|
347
|
+
error.retry_after = retry_after_seconds(response) if error.is_a?(RateLimitError)
|
|
348
|
+
raise error
|
|
306
349
|
end
|
|
307
350
|
end
|
|
308
351
|
|
|
309
|
-
# A transport view pinned to
|
|
310
|
-
#
|
|
311
|
-
# Wraps a real `Transport` and forwards every call, injecting a `base_url` override so an
|
|
312
|
-
# entire resource group lands on its product subdomain (`conversations.voicetel.com` /
|
|
313
|
-
# `messaging.voicetel.com`) without each resource needing to know its own host.
|
|
314
|
-
#
|
|
352
|
+
# A redacting transport view pinned to one product origin.
|
|
315
353
|
# @api private
|
|
316
354
|
class ScopedTransport
|
|
317
355
|
attr_reader :base_url
|
|
318
356
|
|
|
319
357
|
def initialize(inner, base_url)
|
|
320
|
-
@inner
|
|
358
|
+
@inner = inner
|
|
321
359
|
@base_url = Transport.strip_trailing_slashes(base_url)
|
|
322
360
|
end
|
|
323
361
|
|
|
@@ -325,12 +363,21 @@ module VoiceML
|
|
|
325
363
|
@inner.account_sid
|
|
326
364
|
end
|
|
327
365
|
|
|
328
|
-
def request(method, path,
|
|
329
|
-
@inner.request(method, path,
|
|
366
|
+
def request(method, path, **options)
|
|
367
|
+
@inner.request(method, path, **options, base_url: @base_url)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def request_raw(method, path, **options)
|
|
371
|
+
@inner.request_raw(method, path, **options, base_url: @base_url)
|
|
330
372
|
end
|
|
331
373
|
|
|
332
374
|
def fetch_bytes(path)
|
|
333
375
|
@inner.fetch_bytes(path, base_url: @base_url)
|
|
334
376
|
end
|
|
377
|
+
|
|
378
|
+
def inspect
|
|
379
|
+
"#<#{self.class.name} base_url=#{@base_url}>"
|
|
380
|
+
end
|
|
381
|
+
alias to_s inspect
|
|
335
382
|
end
|
|
336
383
|
end
|
data/lib/voiceml/version.rb
CHANGED