tina4ruby 3.13.68 → 3.13.69
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/lib/tina4/api.rb +429 -41
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e96ce1bc6835280fdee7d6acc8d22af2fd4d013fbd658501aa189cf01266dabc
|
|
4
|
+
data.tar.gz: 236dbc434352d4210956b45e2eedcfc5129b849d47b636edc915084695098c3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39d2f2a93cbee0c2b8d95fb8f4da308e1809e8ffb30100320a39143f07f88fcaa020649c802f7cf753289884f4984940f666cc81c4e0e9ab274a1fcbf5aca3ec
|
|
7
|
+
data.tar.gz: 057a277a69d9768542103f8459d3765819e9c6f5d2418c676977dbb59d865126f21d490d8de7b6a36f55f0849b376a71fac45df3946448f62d33a94e1e5c1d12
|
data/lib/tina4/api.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "net/http"
|
|
3
4
|
require "openssl"
|
|
4
5
|
require "uri"
|
|
5
6
|
require "json"
|
|
6
7
|
require "base64"
|
|
8
|
+
require "securerandom"
|
|
7
9
|
|
|
8
10
|
module Tina4
|
|
9
11
|
# Statuses that warrant an automatic retry when max_retries > 0: rate-limit
|
|
@@ -12,6 +14,51 @@ module Tina4
|
|
|
12
14
|
# master's _RETRY_STATUSES.
|
|
13
15
|
API_RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
16
|
|
|
17
|
+
# Streaming download reads/writes this many bytes per chunk so a multi-megabyte
|
|
18
|
+
# body never lands in memory in one piece. Parity with the Python master's
|
|
19
|
+
# _DOWNLOAD_CHUNK_SIZE.
|
|
20
|
+
API_DOWNLOAD_CHUNK_SIZE = 64 * 1024
|
|
21
|
+
|
|
22
|
+
# Redirect codes we follow (301/302/303/307/308). Net::HTTP does NOT auto-follow,
|
|
23
|
+
# so the client follows them itself in a bounded loop (see #network_call).
|
|
24
|
+
API_REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
25
|
+
|
|
26
|
+
# Upper bound on redirect hops before we stop following and return the last
|
|
27
|
+
# 3xx response. Matches urllib's default (Python master).
|
|
28
|
+
API_MAX_REDIRECTS = 10
|
|
29
|
+
|
|
30
|
+
# Headers dropped when a redirect crosses to a different origin — a bearer
|
|
31
|
+
# token or a session cookie must never be handed to a host you didn't
|
|
32
|
+
# authenticate to. Compared case-insensitively. Parity with the Python
|
|
33
|
+
# master's _STRIP_ON_CROSS_ORIGIN.
|
|
34
|
+
API_STRIP_ON_CROSS_ORIGIN = %w[authorization cookie].freeze
|
|
35
|
+
|
|
36
|
+
# A curated extension -> MIME map for guessing a multipart part's Content-Type
|
|
37
|
+
# from its filename (Ruby core ships no mimetypes db, and pulling one in would
|
|
38
|
+
# break the zero-dependency promise). tina4: a small built-in map covering the
|
|
39
|
+
# common upload types; anything unlisted falls back to application/octet-stream.
|
|
40
|
+
# Mirrors the guess-with-fallback the Python master gets from mimetypes.
|
|
41
|
+
API_MIME_BY_EXTENSION = {
|
|
42
|
+
"png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg",
|
|
43
|
+
"gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml",
|
|
44
|
+
"bmp" => "image/bmp", "ico" => "image/x-icon", "tif" => "image/tiff",
|
|
45
|
+
"tiff" => "image/tiff",
|
|
46
|
+
"pdf" => "application/pdf", "json" => "application/json",
|
|
47
|
+
"xml" => "application/xml", "zip" => "application/zip",
|
|
48
|
+
"gz" => "application/gzip", "tar" => "application/x-tar",
|
|
49
|
+
"csv" => "text/csv", "txt" => "text/plain", "html" => "text/html",
|
|
50
|
+
"htm" => "text/html", "css" => "text/css", "js" => "text/javascript",
|
|
51
|
+
"md" => "text/markdown",
|
|
52
|
+
"mp3" => "audio/mpeg", "wav" => "audio/wav", "ogg" => "audio/ogg",
|
|
53
|
+
"mp4" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm",
|
|
54
|
+
"doc" => "application/msword",
|
|
55
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
56
|
+
"xls" => "application/vnd.ms-excel",
|
|
57
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
58
|
+
"ppt" => "application/vnd.ms-powerpoint",
|
|
59
|
+
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
15
62
|
class API
|
|
16
63
|
attr_reader :base_url, :headers
|
|
17
64
|
|
|
@@ -32,9 +79,33 @@ module Tina4
|
|
|
32
79
|
# (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be
|
|
33
80
|
# re-sent — retries are opt-in for exactly that reason. Parity with the
|
|
34
81
|
# Python master.
|
|
82
|
+
#
|
|
83
|
+
# 3.13.69: +transport (an injectable seam) and +cookies (an opt-in per-client
|
|
84
|
+
# cookie jar), both parity with the Python master.
|
|
85
|
+
#
|
|
86
|
+
# `transport` (default nil = the real Net::HTTP network path) is an injectable
|
|
87
|
+
# seam so that APPLICATION developers can unit-test their own code without a
|
|
88
|
+
# live server. When supplied it must respond to #call with the signature
|
|
89
|
+
# `call(method, url, headers, body, timeout)` and return a Hash shaped like
|
|
90
|
+
# `{http_code:/status:, body:, headers:, error:}` (string or symbol keys). It
|
|
91
|
+
# fully REPLACES the network call. NOTE: Tina4's own suite must NEVER inject a
|
|
92
|
+
# fake/canned transport — the no-mock rule stands, so framework tests always
|
|
93
|
+
# exercise the real network path against a real local server. The seam exists
|
|
94
|
+
# purely for application-developer testing.
|
|
95
|
+
#
|
|
96
|
+
# `cookies` (default false = off, zero behaviour change) turns on a
|
|
97
|
+
# per-client, in-memory cookie jar: `Set-Cookie` headers on responses are
|
|
98
|
+
# parsed (leading name=value only, last write wins) and the accumulated
|
|
99
|
+
# `Cookie` header is sent on subsequent requests. Not persisted; scoped to
|
|
100
|
+
# this instance.
|
|
35
101
|
def initialize(base_url, headers: {}, timeout: 30,
|
|
36
102
|
bearer_token: nil, username: nil, password: nil,
|
|
37
|
-
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5
|
|
103
|
+
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5,
|
|
104
|
+
transport: nil, cookies: false)
|
|
105
|
+
if !transport.nil? && !transport.respond_to?(:call)
|
|
106
|
+
raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)"
|
|
107
|
+
end
|
|
108
|
+
|
|
38
109
|
@base_url = base_url.chomp("/")
|
|
39
110
|
@headers = {
|
|
40
111
|
"Content-Type" => "application/json",
|
|
@@ -44,6 +115,9 @@ module Tina4
|
|
|
44
115
|
@verify_ssl = verify_ssl
|
|
45
116
|
@max_retries = [0, max_retries.to_i].max
|
|
46
117
|
@retry_backoff = retry_backoff.to_f
|
|
118
|
+
@transport = transport
|
|
119
|
+
@cookies_enabled = cookies ? true : false
|
|
120
|
+
@cookies = {}
|
|
47
121
|
|
|
48
122
|
# Bearer wins over basic-auth when both passed
|
|
49
123
|
if bearer_token
|
|
@@ -101,19 +175,102 @@ module Tina4
|
|
|
101
175
|
execute(uri, request)
|
|
102
176
|
end
|
|
103
177
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
178
|
+
# POST a multipart/form-data body — a file plus optional text fields.
|
|
179
|
+
#
|
|
180
|
+
# BREAKING (3.13.69): reconciled to the canonical cross-framework shape.
|
|
181
|
+
# `file_path` was a REQUIRED positional; it is now a keyword. Two ways to
|
|
182
|
+
# supply the file, so a caller never needs a temp file:
|
|
183
|
+
#
|
|
184
|
+
# - file_path: — a file on disk. filename: defaults to its basename.
|
|
185
|
+
# - file_bytes: + filename: — an in-memory payload (String bytes).
|
|
186
|
+
#
|
|
187
|
+
# field_name: is the form field the file is sent under (default "file").
|
|
188
|
+
# extra_fields: (Hash) become additional text parts. headers: (Hash) are
|
|
189
|
+
# extra per-call headers merged onto the request. The part's Content-Type is
|
|
190
|
+
# guessed from the filename (falling back to application/octet-stream) — this
|
|
191
|
+
# replaces the old hard-coded application/octet-stream. The client's default
|
|
192
|
+
# headers (including any Authorization) are sent too, unlike the old upload().
|
|
193
|
+
#
|
|
194
|
+
# Returns an APIResponse. A missing file or no source given returns a clean
|
|
195
|
+
# error response (status 0, error set) — it does NOT raise, and nothing is
|
|
196
|
+
# sent over the wire.
|
|
197
|
+
#
|
|
198
|
+
# api.upload("/avatars", file_path: "/tmp/me.png")
|
|
199
|
+
# api.upload("/avatars", file_bytes: raw, filename: "me.png",
|
|
200
|
+
# extra_fields: { "user_id" => "42" })
|
|
201
|
+
def upload(path, file_path: nil, field_name: "file", extra_fields: {},
|
|
202
|
+
headers: {}, file_bytes: nil, filename: nil)
|
|
203
|
+
unless file_bytes.nil?
|
|
204
|
+
content = file_bytes.is_a?(String) ? file_bytes : file_bytes.to_s
|
|
205
|
+
upload_name = filename || "upload.bin"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if content.nil? && file_path
|
|
209
|
+
return error_response("file not found: #{file_path}") unless File.file?(file_path)
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
content = File.binread(file_path)
|
|
213
|
+
rescue SystemCallError => e
|
|
214
|
+
return error_response(e.message)
|
|
215
|
+
end
|
|
216
|
+
upload_name = filename || File.basename(file_path)
|
|
217
|
+
end
|
|
107
218
|
|
|
108
|
-
|
|
219
|
+
return error_response("upload requires file_path or file_bytes") if content.nil?
|
|
109
220
|
|
|
221
|
+
part_content_type = guess_content_type(upload_name)
|
|
222
|
+
boundary = "----Tina4Boundary#{SecureRandom.hex(16)}"
|
|
223
|
+
body = build_multipart_body(boundary, field_name, upload_name, content,
|
|
224
|
+
part_content_type, extra_fields)
|
|
225
|
+
|
|
226
|
+
uri = build_uri(path)
|
|
110
227
|
request = Net::HTTP::Post.new(uri)
|
|
111
228
|
request.body = body
|
|
229
|
+
# Apply the client default headers (auth, etc.) FIRST, then force the
|
|
230
|
+
# multipart Content-Type so it wins over the default application/json,
|
|
231
|
+
# then any per-call headers last (parity with the Python master's order).
|
|
232
|
+
apply_headers(request, {})
|
|
112
233
|
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
113
|
-
headers.each { |
|
|
234
|
+
headers.each { |key, value| request[key] = value }
|
|
114
235
|
execute(uri, request)
|
|
115
236
|
end
|
|
116
237
|
|
|
238
|
+
# Stream a GET response body to dest_path in chunks.
|
|
239
|
+
#
|
|
240
|
+
# The body is written to disk API_DOWNLOAD_CHUNK_SIZE bytes at a time instead
|
|
241
|
+
# of being buffered whole in memory — safe for large payloads. Follows
|
|
242
|
+
# redirects (with the same cross-origin auth/cookie strip as every verb) and
|
|
243
|
+
# honours verify_ssl.
|
|
244
|
+
#
|
|
245
|
+
# Returns an APIResponse carrying `path` (and no body — it went to disk).
|
|
246
|
+
# `path` is dest_path on success and nil on any error (missing dest, HTTP
|
|
247
|
+
# error status, or a transport failure); the destination file is not written
|
|
248
|
+
# on a pre-flight or HTTP-status error. status is 0 on a transport failure.
|
|
249
|
+
def download(path, dest_path: nil, params: {})
|
|
250
|
+
return error_response("download requires dest_path", path: nil) unless dest_path
|
|
251
|
+
|
|
252
|
+
uri = build_uri(path, params)
|
|
253
|
+
headers = @headers.dup
|
|
254
|
+
cookie = cookie_header
|
|
255
|
+
headers["Cookie"] = cookie if @cookies_enabled && cookie
|
|
256
|
+
|
|
257
|
+
return download_via_transport(uri, headers, dest_path) if @transport
|
|
258
|
+
|
|
259
|
+
begin
|
|
260
|
+
result = network_call("GET", uri, headers, nil, stream_to: dest_path)
|
|
261
|
+
code = result[:status]
|
|
262
|
+
if result[:path]
|
|
263
|
+
APIResponse.new(status: code, body: nil, headers: result[:headers],
|
|
264
|
+
error: nil, path: result[:path])
|
|
265
|
+
else
|
|
266
|
+
APIResponse.new(status: code, body: nil, headers: result[:headers],
|
|
267
|
+
error: "download failed (HTTP #{code})", path: nil)
|
|
268
|
+
end
|
|
269
|
+
rescue StandardError => e
|
|
270
|
+
APIResponse.new(status: 0, body: nil, headers: {}, error: e.message, path: nil)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
117
274
|
def set_basic_auth(username, password)
|
|
118
275
|
@headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
|
|
119
276
|
self
|
|
@@ -179,59 +336,290 @@ module Tina4
|
|
|
179
336
|
response
|
|
180
337
|
end
|
|
181
338
|
|
|
182
|
-
# A single HTTP attempt. Returns the standardized APIResponse.
|
|
339
|
+
# A single HTTP attempt. Returns the standardized APIResponse. Dispatches to
|
|
340
|
+
# the injected transport seam when one is set, else the real network path.
|
|
183
341
|
def attempt_request(uri, request)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
http.open_timeout = @timeout
|
|
191
|
-
http.read_timeout = @timeout
|
|
192
|
-
|
|
193
|
-
response = http.request(request)
|
|
342
|
+
method = request.method
|
|
343
|
+
headers = {}
|
|
344
|
+
request.each_header { |key, value| headers[key] = value }
|
|
345
|
+
cookie = cookie_header
|
|
346
|
+
headers["cookie"] = cookie if @cookies_enabled && cookie
|
|
347
|
+
body = request.body
|
|
194
348
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
headers
|
|
199
|
-
|
|
349
|
+
if @transport
|
|
350
|
+
transport_response(method, uri.to_s, headers, body)
|
|
351
|
+
else
|
|
352
|
+
result = network_call(method, uri, headers, body)
|
|
353
|
+
APIResponse.new(status: result[:status], body: result[:body] || "",
|
|
354
|
+
headers: result[:headers], error: nil)
|
|
355
|
+
end
|
|
200
356
|
rescue StandardError => e
|
|
357
|
+
APIResponse.new(status: 0, body: "", headers: {}, error: e.message)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Perform a real HTTP round-trip via Net::HTTP, following redirects in a
|
|
361
|
+
# bounded loop and stripping Authorization/Cookie on a cross-origin hop.
|
|
362
|
+
# When stream_to is given, a 2xx body is streamed to that path in chunks
|
|
363
|
+
# (never buffered whole) and no file is written on a non-2xx status.
|
|
364
|
+
#
|
|
365
|
+
# Returns { status:, headers:, body:, path: } (body nil when streamed; path
|
|
366
|
+
# set only when a file was actually written). Raises on a transport failure
|
|
367
|
+
# (caller turns that into a status-0 error response).
|
|
368
|
+
def network_call(method, uri, headers, body, stream_to: nil)
|
|
369
|
+
remaining = API_MAX_REDIRECTS
|
|
370
|
+
current_method = method.to_s.upcase
|
|
371
|
+
current_uri = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
|
|
372
|
+
current_headers = headers.dup
|
|
373
|
+
current_body = body
|
|
374
|
+
|
|
375
|
+
loop do
|
|
376
|
+
http = Net::HTTP.new(current_uri.host, current_uri.port)
|
|
377
|
+
http.use_ssl = current_uri.scheme == "https"
|
|
378
|
+
# Only disable verification when EXPLICITLY false — nil/true keep the
|
|
379
|
+
# secure default (OpenSSL::SSL::VERIFY_PEER).
|
|
380
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
381
|
+
http.open_timeout = @timeout
|
|
382
|
+
http.read_timeout = @timeout
|
|
383
|
+
|
|
384
|
+
request = build_net_request(current_method, current_uri, current_headers, current_body)
|
|
385
|
+
|
|
386
|
+
status = nil
|
|
387
|
+
resp_headers = {}
|
|
388
|
+
body_string = nil
|
|
389
|
+
wrote_file = false
|
|
390
|
+
location = nil
|
|
391
|
+
redirect_code = nil
|
|
392
|
+
|
|
393
|
+
http.start do |conn|
|
|
394
|
+
conn.request(request) do |response|
|
|
395
|
+
status = response.code.to_i
|
|
396
|
+
resp_headers = response.to_hash
|
|
397
|
+
if API_REDIRECT_STATUSES.include?(status) && remaining.positive? && response["location"]
|
|
398
|
+
response.read_body { |_chunk| } # drain (and discard) the redirect body
|
|
399
|
+
location = response["location"]
|
|
400
|
+
redirect_code = status
|
|
401
|
+
else
|
|
402
|
+
store_cookies(response.get_fields("Set-Cookie"))
|
|
403
|
+
if stream_to && (200..299).cover?(status)
|
|
404
|
+
# Stream to disk in API_DOWNLOAD_CHUNK_SIZE blocks. Net::HTTP's
|
|
405
|
+
# read_body block yields socket-buffer-sized chunks; we coalesce
|
|
406
|
+
# them and flush at the 64 KB threshold so the whole body never
|
|
407
|
+
# lands in memory at once (bounded to ~one chunk + 64 KB).
|
|
408
|
+
File.open(stream_to, "wb") do |file|
|
|
409
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
410
|
+
response.read_body do |chunk|
|
|
411
|
+
buffer << chunk
|
|
412
|
+
if buffer.bytesize >= API_DOWNLOAD_CHUNK_SIZE
|
|
413
|
+
file.write(buffer)
|
|
414
|
+
buffer.clear
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
file.write(buffer) unless buffer.empty?
|
|
418
|
+
end
|
|
419
|
+
wrote_file = true
|
|
420
|
+
elsif stream_to
|
|
421
|
+
response.read_body { |_chunk| } # error status on a download: drain, write nothing
|
|
422
|
+
else
|
|
423
|
+
body_string = String.new(encoding: Encoding::BINARY)
|
|
424
|
+
response.read_body { |chunk| body_string << chunk }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
if location
|
|
431
|
+
new_uri = URI.join(current_uri.to_s, location)
|
|
432
|
+
current_headers = strip_cross_origin(current_headers) unless same_origin?(current_uri, new_uri)
|
|
433
|
+
# 301/302/303 downgrade a non-GET/HEAD to GET and drop the body
|
|
434
|
+
# (matches urllib); 307/308 preserve method + body.
|
|
435
|
+
if [301, 302, 303].include?(redirect_code) && !%w[GET HEAD].include?(current_method)
|
|
436
|
+
current_method = "GET"
|
|
437
|
+
current_body = nil
|
|
438
|
+
end
|
|
439
|
+
current_uri = new_uri
|
|
440
|
+
remaining -= 1
|
|
441
|
+
next
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
return { status: status, headers: resp_headers, body: body_string,
|
|
445
|
+
path: (wrote_file ? stream_to : nil) }
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Build a Net::HTTP request object for one hop from a method/uri/headers/body.
|
|
450
|
+
def build_net_request(method, uri, headers, body)
|
|
451
|
+
klass = case method
|
|
452
|
+
when "GET" then Net::HTTP::Get
|
|
453
|
+
when "POST" then Net::HTTP::Post
|
|
454
|
+
when "PUT" then Net::HTTP::Put
|
|
455
|
+
when "PATCH" then Net::HTTP::Patch
|
|
456
|
+
when "DELETE" then Net::HTTP::Delete
|
|
457
|
+
when "HEAD" then Net::HTTP::Head
|
|
458
|
+
else Net::HTTP::Get
|
|
459
|
+
end
|
|
460
|
+
request = klass.new(uri)
|
|
461
|
+
headers.each do |key, value|
|
|
462
|
+
request[key] = value.is_a?(Array) ? value.first : value
|
|
463
|
+
end
|
|
464
|
+
request.body = body if body && !%w[GET HEAD].include?(method)
|
|
465
|
+
request
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Invoke a user-injected transport and normalize its result into an
|
|
469
|
+
# APIResponse. The transport fully replaces the network call.
|
|
470
|
+
def transport_response(method, url, headers, body)
|
|
471
|
+
result = @transport.call(method, url, headers, body, @timeout)
|
|
472
|
+
result = {} unless result.is_a?(Hash)
|
|
473
|
+
store_cookies(set_cookie_values(result_value(result, :headers)))
|
|
201
474
|
APIResponse.new(
|
|
202
|
-
status: 0,
|
|
203
|
-
body: "",
|
|
204
|
-
headers: {},
|
|
205
|
-
error:
|
|
475
|
+
status: (result_value(result, :http_code, :status) || 0).to_i,
|
|
476
|
+
body: result_value(result, :body) || "",
|
|
477
|
+
headers: result_value(result, :headers) || {},
|
|
478
|
+
error: result_value(result, :error)
|
|
206
479
|
)
|
|
207
480
|
end
|
|
208
481
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
482
|
+
# download() over the transport seam: the transport returns a buffered body,
|
|
483
|
+
# so write it out (only on a 2xx) instead of streaming. Mirrors the Python
|
|
484
|
+
# master's transport branch of download().
|
|
485
|
+
def download_via_transport(uri, headers, dest_path)
|
|
486
|
+
result = @transport.call("GET", uri.to_s, headers, nil, @timeout)
|
|
487
|
+
result = {} unless result.is_a?(Hash)
|
|
488
|
+
store_cookies(set_cookie_values(result_value(result, :headers)))
|
|
489
|
+
code = (result_value(result, :http_code, :status) || 0).to_i
|
|
490
|
+
error = result_value(result, :error)
|
|
491
|
+
resp_headers = result_value(result, :headers) || {}
|
|
492
|
+
|
|
493
|
+
if error.nil? && (200..299).cover?(code)
|
|
494
|
+
data = result_value(result, :body).to_s
|
|
495
|
+
begin
|
|
496
|
+
File.binwrite(dest_path, data)
|
|
497
|
+
rescue SystemCallError => e
|
|
498
|
+
return APIResponse.new(status: code, body: nil, headers: resp_headers, error: e.message, path: nil)
|
|
499
|
+
end
|
|
500
|
+
APIResponse.new(status: code, body: nil, headers: resp_headers, error: nil, path: dest_path)
|
|
501
|
+
else
|
|
502
|
+
APIResponse.new(status: code, body: nil, headers: resp_headers,
|
|
503
|
+
error: error || "download failed (HTTP #{code})", path: nil)
|
|
215
504
|
end
|
|
505
|
+
rescue StandardError => e
|
|
506
|
+
APIResponse.new(status: 0, body: nil, headers: {}, error: e.message, path: nil)
|
|
507
|
+
end
|
|
216
508
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
509
|
+
# Assemble a multipart/form-data body as raw bytes. Text fields come first,
|
|
510
|
+
# then the file part, then the closing delimiter — byte-identical to the
|
|
511
|
+
# Python master's _build_multipart_body so every framework produces the same
|
|
512
|
+
# layout. Built as a BINARY string so raw file bytes concatenate cleanly.
|
|
513
|
+
def build_multipart_body(boundary, field_name, filename, content, content_type, extra_fields)
|
|
514
|
+
crlf = "\r\n"
|
|
515
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
516
|
+
(extra_fields || {}).each do |key, value|
|
|
517
|
+
body << "--#{boundary}#{crlf}".b
|
|
518
|
+
body << %(Content-Disposition: form-data; name="#{key}"#{crlf}#{crlf}).b
|
|
519
|
+
body << "#{value}#{crlf}".b
|
|
520
|
+
end
|
|
521
|
+
body << "--#{boundary}#{crlf}".b
|
|
522
|
+
body << %(Content-Disposition: form-data; name="#{field_name}"; filename="#{filename}"#{crlf}).b
|
|
523
|
+
body << "Content-Type: #{content_type}#{crlf}#{crlf}".b
|
|
524
|
+
body << content.b
|
|
525
|
+
body << crlf.b
|
|
526
|
+
body << "--#{boundary}--#{crlf}".b
|
|
223
527
|
body
|
|
224
528
|
end
|
|
529
|
+
|
|
530
|
+
# Guess a multipart part's Content-Type from a filename extension, falling
|
|
531
|
+
# back to application/octet-stream (parity with the Python master).
|
|
532
|
+
def guess_content_type(filename)
|
|
533
|
+
ext = File.extname(filename.to_s).delete_prefix(".").downcase
|
|
534
|
+
API_MIME_BY_EXTENSION[ext] || "application/octet-stream"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# True when two URLs share scheme + host + (effective) port.
|
|
538
|
+
def same_origin?(uri_a, uri_b)
|
|
539
|
+
a = uri_a.is_a?(URI) ? uri_a : URI.parse(uri_a.to_s)
|
|
540
|
+
b = uri_b.is_a?(URI) ? uri_b : URI.parse(uri_b.to_s)
|
|
541
|
+
defaults = { "http" => 80, "https" => 443 }
|
|
542
|
+
port_a = a.port || defaults[a.scheme]
|
|
543
|
+
port_b = b.port || defaults[b.scheme]
|
|
544
|
+
a.scheme == b.scheme && a.host == b.host && port_a == port_b
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def strip_cross_origin(headers)
|
|
548
|
+
headers.reject { |key, _| API_STRIP_ON_CROSS_ORIGIN.include?(key.to_s.downcase) }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Fetch a value from a Hash trying each key as both a symbol and a string.
|
|
552
|
+
def result_value(result, *keys)
|
|
553
|
+
keys.each do |key|
|
|
554
|
+
return result[key] if result.key?(key)
|
|
555
|
+
|
|
556
|
+
string_key = key.to_s
|
|
557
|
+
return result[string_key] if result.key?(string_key)
|
|
558
|
+
end
|
|
559
|
+
nil
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# ── cookie jar (opt-in, in-memory, per-client) ─────────────────────────
|
|
563
|
+
# The accumulated Cookie request header, or nil when the jar is empty.
|
|
564
|
+
def cookie_header
|
|
565
|
+
return nil if @cookies.empty?
|
|
566
|
+
|
|
567
|
+
@cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Parse Set-Cookie response header values into the jar (when enabled). Only
|
|
571
|
+
# the leading name=value pair of each Set-Cookie is kept (attributes like
|
|
572
|
+
# Path/HttpOnly/Expires are ignored); a later value for the same name
|
|
573
|
+
# overwrites an earlier one. Parity with the Python master's _store_cookies.
|
|
574
|
+
def store_cookies(values)
|
|
575
|
+
return unless @cookies_enabled
|
|
576
|
+
|
|
577
|
+
Array(values).each do |raw|
|
|
578
|
+
next if raw.nil? || raw.empty?
|
|
579
|
+
|
|
580
|
+
first_pair = raw.split(";", 2).first.to_s.strip
|
|
581
|
+
name, separator, value = first_pair.partition("=")
|
|
582
|
+
next if separator.empty?
|
|
583
|
+
|
|
584
|
+
name = name.strip
|
|
585
|
+
@cookies[name] = value.strip unless name.empty?
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Extract Set-Cookie values from a plain headers Hash (the transport seam
|
|
590
|
+
# returns a Hash, not a Net::HTTPResponse). Values may be a String or Array.
|
|
591
|
+
def set_cookie_values(headers)
|
|
592
|
+
return [] unless headers.is_a?(Hash)
|
|
593
|
+
|
|
594
|
+
values = []
|
|
595
|
+
headers.each do |key, value|
|
|
596
|
+
next unless key.to_s.downcase == "set-cookie"
|
|
597
|
+
|
|
598
|
+
Array(value).each { |item| values << item }
|
|
599
|
+
end
|
|
600
|
+
values
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def error_response(message, path: :__none__)
|
|
604
|
+
if path == :__none__
|
|
605
|
+
APIResponse.new(status: 0, body: "", headers: {}, error: message)
|
|
606
|
+
else
|
|
607
|
+
APIResponse.new(status: 0, body: nil, headers: {}, error: message, path: path)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
225
610
|
end
|
|
226
611
|
|
|
227
612
|
class APIResponse
|
|
228
|
-
attr_reader :status, :body, :headers, :error
|
|
613
|
+
attr_reader :status, :body, :headers, :error, :path
|
|
229
614
|
|
|
230
|
-
|
|
615
|
+
# `path` (default nil) is populated by API#download — the on-disk path the
|
|
616
|
+
# streamed body was written to (nil on error). It is nil for every other verb.
|
|
617
|
+
def initialize(status:, body:, headers:, error: nil, path: nil)
|
|
231
618
|
@status = status
|
|
232
619
|
@body = body
|
|
233
620
|
@headers = headers
|
|
234
621
|
@error = error
|
|
622
|
+
@path = path
|
|
235
623
|
end
|
|
236
624
|
|
|
237
625
|
def success?
|
|
@@ -240,7 +628,7 @@ module Tina4
|
|
|
240
628
|
|
|
241
629
|
def json
|
|
242
630
|
@json ||= JSON.parse(@body)
|
|
243
|
-
rescue JSON::ParserError
|
|
631
|
+
rescue JSON::ParserError, TypeError
|
|
244
632
|
{}
|
|
245
633
|
end
|
|
246
634
|
|
data/lib/tina4/version.rb
CHANGED