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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tina4/api.rb +429 -41
  3. data/lib/tina4/version.rb +1 -1
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2df4dd4df920f7515ff0d958f4974886d74eacb4ead581b42a913b54ac3124e4
4
- data.tar.gz: cec0373181fc77921ce01a8bc19e08492ca17bc10635ea534a273c62ee7b3684
3
+ metadata.gz: e96ce1bc6835280fdee7d6acc8d22af2fd4d013fbd658501aa189cf01266dabc
4
+ data.tar.gz: 236dbc434352d4210956b45e2eedcfc5129b849d47b636edc915084695098c3f
5
5
  SHA512:
6
- metadata.gz: 0ffb3bd3d71c9a404486a4b74d03a361b7c74d80d15b120923c326295f97bc629effc52666477098aeada3ffc592d32e5caee38760e3781695a0c3bc5a9b84de
7
- data.tar.gz: 6df31ce14a217e7c73ff4fd92a6ad16c7ee3587bdf6c1b730cb794488ec8dadbaeb9b4420c23a7b7467ea687f73f5cb3c59dab91758e6d59f2cc199f4d9669e7
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
- def upload(path, file_path, field_name: "file", extra_fields: {}, headers: {})
105
- uri = build_uri(path)
106
- boundary = "----Tina4Boundary#{SecureRandom.hex(16)}"
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
- body = build_multipart_body(boundary, file_path, field_name, extra_fields)
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 { |k, v| request[k] = v }
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
- http = Net::HTTP.new(uri.host, uri.port)
185
- http.use_ssl = uri.scheme == "https"
186
- # 3.13.39: honour verify_ssl: false (the dead-since-3.13.1 kwarg). Only
187
- # disable verification when EXPLICITLY false — nil/true keep the secure
188
- # default (OpenSSL::SSL::VERIFY_PEER).
189
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
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
- APIResponse.new(
196
- status: response.code.to_i,
197
- body: response.body,
198
- headers: response.to_hash
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: e.message
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
- def build_multipart_body(boundary, file_path, field_name, extra_fields)
210
- body = ""
211
- extra_fields.each do |key, value|
212
- body += "--#{boundary}\r\n"
213
- body += "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
214
- body += "#{value}\r\n"
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
- filename = File.basename(file_path)
218
- body += "--#{boundary}\r\n"
219
- body += "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n"
220
- body += "Content-Type: application/octet-stream\r\n\r\n"
221
- body += File.binread(file_path)
222
- body += "\r\n--#{boundary}--\r\n"
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
- def initialize(status:, body:, headers:, error: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.68"
4
+ VERSION = "3.13.69"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.68
4
+ version: 3.13.69
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team