tina4ruby 3.13.68 → 3.13.70

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2df4dd4df920f7515ff0d958f4974886d74eacb4ead581b42a913b54ac3124e4
4
- data.tar.gz: cec0373181fc77921ce01a8bc19e08492ca17bc10635ea534a273c62ee7b3684
3
+ metadata.gz: 87f4e1923c2fc20dbfdce286957c5e0505ccc37091e51ea6b0286041a342dacc
4
+ data.tar.gz: be4a0fc97b935313a27f8b5f53941cff0e939c10d73a5fbca081409a010db210
5
5
  SHA512:
6
- metadata.gz: 0ffb3bd3d71c9a404486a4b74d03a361b7c74d80d15b120923c326295f97bc629effc52666477098aeada3ffc592d32e5caee38760e3781695a0c3bc5a9b84de
7
- data.tar.gz: 6df31ce14a217e7c73ff4fd92a6ad16c7ee3587bdf6c1b730cb794488ec8dadbaeb9b4420c23a7b7467ea687f73f5cb3c59dab91758e6d59f2cc199f4d9669e7
6
+ metadata.gz: 44806d41e9bcbc45e4666a484a026ac7bb7c90babb8d7e5e031cd5316995f5faa7f9b303760e3339d67a2540aaddcd1fc29f1ca553593f15788c86048c8f8c8b
7
+ data.tar.gz: a4c84561d96968f9ca8c5eafaebb4cfd480a3693a88b63d7c694d64bedc9a4fa232e87c405f1a0e72693751c8a273d5969606fe115dfd879e9e8b60d7033e6af
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
 
@@ -969,6 +969,11 @@ module Tina4
969
969
  return { error: "No database configured" } unless db
970
970
 
971
971
  begin
972
+ # Force-load the seeder: Tina4.seed_table is defined in
973
+ # lib/tina4/seeder.rb, reachable only via the Tina4::FakeData autoload,
974
+ # which a bare Tina4.seed_table call never trips (mirrors Python's
975
+ # explicit `from tina4_python.seeder import seed_table`).
976
+ require_relative "seeder"
972
977
  # Delegate to the shared resilient seed_table helper so the endpoint
973
978
  # gets the exact same per-row wrap (P1) — no unhandled row failure can
974
979
  # crash the endpoint — plus clear/seed/strict (P2/P3). _normalize_columns
@@ -76,7 +76,42 @@ module Tina4
76
76
  decoded.start_with?("/") ? decoded : "/#{decoded}"
77
77
  end
78
78
 
79
- def connect(connection_string, username: nil, password: nil)
79
+ # Resolve the Firebird connection charset (#160, mirrors php #160 /
80
+ # the Python master's _resolve_firebird_charset).
81
+ #
82
+ # The driver used to pass NO charset to the `fb` gem, leaving it to the
83
+ # gem's own (non-UTF8) default — which double-encodes UTF-8 bytes stored
84
+ # under a legacy NONE database and diverges from the other frameworks.
85
+ # The charset is now resolved from, in precedence order:
86
+ #
87
+ # 1. the connection URL query — firebird://host:port/path?charset=NONE
88
+ # 2. an explicit charset: kwarg passed to #connect
89
+ # 3. the TINA4_DATABASE_CHARSET environment variable
90
+ # 4. the UTF8 default (canonical across all four frameworks)
91
+ #
92
+ # Pure config resolution over its inputs (URL string, kwarg, env) — it
93
+ # opens NO connection, so it is unit-testable without a live server. A
94
+ # blank value at any level is treated as absent (matching the Python
95
+ # master's falsy-string semantics) so `?charset=` / an empty env var falls
96
+ # through rather than connecting with an empty charset.
97
+ def self.resolve_charset(connection_string, kwarg_charset = nil)
98
+ require "uri"
99
+ url_charset = nil
100
+ query = begin
101
+ URI.parse(connection_string.to_s).query
102
+ rescue URI::InvalidURIError
103
+ nil
104
+ end
105
+ if query && !query.empty?
106
+ pair = URI.decode_www_form(query).find { |k, _| k == "charset" }
107
+ url_charset = pair[1] if pair && !pair[1].to_s.empty?
108
+ end
109
+ kwarg = kwarg_charset.to_s.empty? ? nil : kwarg_charset
110
+ env = ENV["TINA4_DATABASE_CHARSET"].to_s.empty? ? nil : ENV["TINA4_DATABASE_CHARSET"]
111
+ url_charset || kwarg || env || "UTF8"
112
+ end
113
+
114
+ def connect(connection_string, username: nil, password: nil, charset: nil)
80
115
  require "fb"
81
116
  require "uri"
82
117
  uri = URI.parse(connection_string)
@@ -115,6 +150,10 @@ module Tina4
115
150
  @connect_opts = { database: database }
116
151
  @connect_opts[:username] = db_user if db_user
117
152
  @connect_opts[:password] = db_pass if db_pass
153
+ # #160: honour ?charset= in the URL, an explicit charset: kwarg, and
154
+ # TINA4_DATABASE_CHARSET so a legacy NONE database isn't force-connected
155
+ # with the gem's default charset (double-encoding). Defaults to UTF8.
156
+ @connect_opts[:charset] = self.class.resolve_charset(connection_string, charset)
118
157
 
119
158
  open_connection
120
159
  rescue LoadError
@@ -187,8 +187,24 @@ module Tina4
187
187
  field_definitions[name] = { type: type }.merge(options)
188
188
  @primary_key_field = name if options[:primary_key]
189
189
 
190
- # Define getter/setter
191
- attr_accessor name
190
+ # Getter (plain reader) + an assignment-tracking setter.
191
+ #
192
+ # #165: save() must tell a column the caller EXPLICITLY set to nil
193
+ # (write it — an explicit nil becomes SQL NULL) from one left UNSET
194
+ # (omit it from the INSERT so a NOT NULL DEFAULT column gets its DB
195
+ # default instead of an explicit NULL). The setter records the field
196
+ # name in @assigned_fields on every caller assignment — via the
197
+ # constructor's attribute application, a from_hash/load populate, or a
198
+ # direct `model.field = x`. Field DEFAULTS are seeded in #initialize
199
+ # with instance_variable_set (bypassing this setter), so a default is
200
+ # never counted as a caller assignment. Mirrors the Python master's
201
+ # __setattr__ + object.__setattr__ default-seeding.
202
+ attr_reader name
203
+ define_method("#{name}=") do |value|
204
+ fields = (@assigned_fields ||= [])
205
+ fields << name unless fields.include?(name)
206
+ instance_variable_set("@#{name}", value)
207
+ end
192
208
  end
193
209
  end
194
210
  end
data/lib/tina4/mcp.rb CHANGED
@@ -906,7 +906,9 @@ module Tina4
906
906
  # ── Template Tools ────────────────────────────────
907
907
  server.register_tool("template_render", lambda { |template:, data: "{}"|
908
908
  ctx = data.is_a?(String) ? JSON.parse(data) : data
909
- Tina4::Template.render_string(template, ctx)
909
+ # render_string is an INSTANCE method of Frond (Tina4::Template has none).
910
+ # Mirrors Python tools.py: Frond("src/templates").render_string(...).
911
+ Tina4::Frond.new(template_dir: "src/templates").render_string(template, ctx)
910
912
  }, "Render a template string with data")
911
913
 
912
914
  # ── File Tools ────────────────────────────────────
@@ -1023,9 +1025,9 @@ module Tina4
1023
1025
  q = Tina4::Queue.new(topic: topic)
1024
1026
  {
1025
1027
  "topic" => topic,
1026
- "pending" => q.size("pending"),
1027
- "completed" => q.size("completed"),
1028
- "failed" => q.size("failed")
1028
+ "pending" => q.size(status: "pending"),
1029
+ "completed" => q.size(status: "completed"),
1030
+ "failed" => q.size(status: "failed")
1029
1031
  }
1030
1032
  rescue => e
1031
1033
  { "error" => e.message }
@@ -1107,10 +1109,15 @@ module Tina4
1107
1109
  # ── Data Tools ────────────────────────────────────
1108
1110
  server.register_tool("seed_table", lambda { |table:, count: 10|
1109
1111
  begin
1112
+ # Tina4.seed_table lives in lib/tina4/seeder.rb, reachable only via the
1113
+ # autoload of Tina4::FakeData — a bare Tina4.seed_table call never trips
1114
+ # it. Force-load the seeder first (mirrors Python's explicit
1115
+ # `from tina4_python.seeder import seed_table`).
1116
+ require_relative "seeder"
1110
1117
  db = Tina4.database
1111
1118
  return { "error" => "No database connection" } if db.nil?
1112
1119
  inserted = Tina4.seed_table(table, db.columns(table), count: count.to_i)
1113
- { "table" => table, "inserted" => inserted }
1120
+ { "table" => table, "inserted" => inserted.seeded, "failed" => inserted.failed }
1114
1121
  rescue => e
1115
1122
  { "error" => e.message }
1116
1123
  end
data/lib/tina4/orm.rb CHANGED
@@ -625,6 +625,13 @@ module Tina4
625
625
  # cause via #get_error / #last_error — the failure never vanishes silently.
626
626
  @last_error = nil
627
627
  @relationship_cache = {}
628
+ # #165: field names the caller EXPLICITLY assigned (via the attribute loop
629
+ # below, a from_hash/load populate, or a later `model.field = x`). save()
630
+ # reads this to OMIT an unset column from an INSERT (so a NOT NULL DEFAULT
631
+ # column gets its DB default) while still writing NULL for a field the
632
+ # caller set to nil. The defaults seeded below use instance_variable_set,
633
+ # bypassing the tracking setter so they are NOT counted as assignments.
634
+ @assigned_fields = []
628
635
  # Accept a JSON object string (parity with Python/PHP/Node):
629
636
  # Widget.new('{"id":1,"name":"alpha"}')
630
637
  attributes = JSON.parse(attributes) if attributes.is_a?(String)
@@ -654,7 +661,10 @@ module Tina4
654
661
  # a.meta must not leak into b.meta). Parity with the Python master,
655
662
  # which deepcopies a JSONField's dict/list default per instance.
656
663
  d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
657
- __send__("#{name}=", d)
664
+ # #165: seed the default straight into the ivar, BYPASSING the
665
+ # tracking setter, so a default is not recorded as a caller
666
+ # assignment (mirrors the Python master's object.__setattr__).
667
+ instance_variable_set("@#{name}", d)
658
668
  end
659
669
  end
660
670
  end
@@ -737,12 +747,15 @@ module Tina4
737
747
  end
738
748
 
739
749
  begin
740
- # Built here (inside the rescue's reach) so a JSON column that can't be
741
- # serialised (to_db_hash raises JSON::GeneratorError) fails loud through
742
- # the same path as a driver error — rolled back, false, cause recorded.
743
- data = to_db_hash(exclude_nil: true)
750
+ # The column hashes are built INSIDE this begin (via the transaction
751
+ # block below) so a JSON column that can't be serialised (to_db_hash /
752
+ # insert_db_hash raises JSON::GeneratorError) fails loud through the
753
+ # same path as a driver error — rolled back, false, cause recorded.
744
754
  self.class.db.transaction do |db|
745
755
  if is_update
756
+ # UPDATE is unchanged (#165 targets INSERT only): keep excluding nil
757
+ # so a save never nulls a column the caller didn't touch.
758
+ data = to_db_hash(exclude_nil: true)
746
759
  filter = { pk => pk_value }
747
760
  data.delete(pk)
748
761
  # Remove mapped primary key too
@@ -750,13 +763,32 @@ module Tina4
750
763
  data.delete(mapped_pk.to_sym) if mapped_pk
751
764
  db.update(self.class.table_name, data, filter)
752
765
  else
753
- result = db.insert(self.class.table_name, data)
754
- # Only adopt the engine-assigned id for an auto-increment PK. A
755
- # natural-key PK was set by the caller; don't overwrite it with the
756
- # driver's last_insert_id (which may be a sequence value that
757
- # doesn't apply here).
758
- if auto_increment && result[:last_id] && respond_to?("#{pk}=")
759
- __send__("#{pk}=", result[:last_id])
766
+ # #165: OMIT a column the caller left unset (value nil, never
767
+ # assigned) so a NOT NULL DEFAULT column gets its DB default rather
768
+ # than an explicit NULL; a column the caller set to nil is KEPT and
769
+ # written as NULL (see #insert_db_hash).
770
+ insert_data = insert_db_hash
771
+ if insert_data.empty?
772
+ # Every insertable column is unset — let the engine apply ALL its
773
+ # column defaults instead of emitting explicit NULLs. DEFAULT
774
+ # VALUES is valid on SQLite / PostgreSQL / MSSQL / Firebird; MySQL
775
+ # spells the all-defaults insert () VALUES ().
776
+ table = self.class.table_name
777
+ engine = (self.class.db.respond_to?(:get_database_type) ? self.class.db.get_database_type : "").to_s.downcase
778
+ db.execute(engine == "mysql" ? "INSERT INTO #{table} () VALUES ()" : "INSERT INTO #{table} DEFAULT VALUES")
779
+ if auto_increment && respond_to?("#{pk}=")
780
+ last = db.get_last_id
781
+ __send__("#{pk}=", last) if last
782
+ end
783
+ else
784
+ result = db.insert(self.class.table_name, insert_data)
785
+ # Only adopt the engine-assigned id for an auto-increment PK. A
786
+ # natural-key PK was set by the caller; don't overwrite it with the
787
+ # driver's last_insert_id (which may be a sequence value that
788
+ # doesn't apply here).
789
+ if auto_increment && result[:last_id] && respond_to?("#{pk}=")
790
+ __send__("#{pk}=", result[:last_id])
791
+ end
760
792
  end
761
793
  end
762
794
  end
@@ -1007,6 +1039,41 @@ module Tina4
1007
1039
 
1008
1040
  private
1009
1041
 
1042
+ # Build the column=>value hash for an INSERT (#165).
1043
+ #
1044
+ # Distinguishes a column the caller left UNSET from one the caller
1045
+ # explicitly set to nil (tracked in @assigned_fields, populated by the field
1046
+ # setters):
1047
+ #
1048
+ # * value nil AND field NOT assigned -> OMITTED (a DB DEFAULT applies, so
1049
+ # a NOT NULL DEFAULT column succeeds instead of taking an explicit NULL)
1050
+ # * value nil AND field assigned -> KEPT as nil (written as SQL NULL;
1051
+ # a NOT NULL column rightly rejects it, so an explicit nil fails loud)
1052
+ # * any non-nil value (incl. a resolved ORM default) -> KEPT
1053
+ #
1054
+ # An unset auto-increment PK is skipped so the engine assigns the id. JSON
1055
+ # columns serialise their Hash/Array exactly as #to_db_hash does. An empty
1056
+ # result signals save() to emit the engine's all-defaults INSERT. Mirrors
1057
+ # the Python master's INSERT omission (tina4_python.orm.model.save).
1058
+ def insert_db_hash
1059
+ hash = {}
1060
+ mapping = self.class.field_mapping
1061
+ assigned = @assigned_fields || []
1062
+ self.class.field_definitions.each do |name, opts|
1063
+ value = __send__(name)
1064
+ # Skip an unset auto-increment PK — let the engine assign it.
1065
+ next if opts[:auto_increment] && value.nil?
1066
+ # Omit an unset-nil column so the DB DEFAULT applies.
1067
+ next if value.nil? && !assigned.include?(name)
1068
+ if opts[:type] == :json && !value.nil? && !value.is_a?(String)
1069
+ value = JSON.generate(value)
1070
+ end
1071
+ db_col = mapping[name.to_s] || name
1072
+ hash[db_col.to_sym] = value
1073
+ end
1074
+ hash
1075
+ end
1076
+
1010
1077
  # Convert to hash using DB column names (with field_mapping applied)
1011
1078
  def to_db_hash(exclude_nil: false)
1012
1079
  hash = {}
@@ -13,6 +13,8 @@
13
13
  #
14
14
  # response = client.get("/api/users/1", headers: { "Authorization" => "Bearer token123" })
15
15
  #
16
+ require "stringio" # rack.input is a StringIO; require it here so a clean `require "tina4"` boot never NameErrors
17
+
16
18
  module Tina4
17
19
  class TestResponse
18
20
  attr_reader :status, :body, :headers, :content_type
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.70"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
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.70
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-10 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack