Class: Tina4::API
- Inherits:
-
Object
- Object
- Tina4::API
- Defined in:
- lib/tina4/api.rb
Instance Attribute Summary collapse
-
#base_url ⇒ Object
readonly
Returns the value of attribute base_url.
-
#headers ⇒ Object
readonly
Returns the value of attribute headers.
Instance Method Summary collapse
- #add_headers(headers) ⇒ Object
- #delete(path, body: nil) ⇒ Object
-
#download(path, dest_path: nil, params: {}) ⇒ Object
Stream a GET response body to dest_path in chunks.
- #get(path, params: {}) ⇒ Object
-
#initialize(base_url, headers: {}, timeout: 30, bearer_token: nil, username: nil, password: nil, verify_ssl: nil, max_retries: 0, retry_backoff: 0.5, transport: nil, cookies: false) ⇒ API
constructor
3.13.1: added ergonomic kwargs (bearer_token, username, password, verify_ssl) so callers no longer need three follow-up setter calls.
- #patch(path, body: nil, content_type: "application/json") ⇒ Object
- #post(path, body: nil, content_type: "application/json") ⇒ Object
- #put(path, body: nil, content_type: "application/json") ⇒ Object
- #send_request(method = "GET", path = "", body: nil, content_type: "application/json") ⇒ Object
- #set_basic_auth(username, password) ⇒ Object
- #set_bearer_token(token) ⇒ Object
-
#stream_bytes(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) ⇒ Object
── Streaming primitives (ADR-0060 / 3.13.113) ─────────────────────────────.
- #stream_lines(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) ⇒ Object
- #stream_sse(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) ⇒ Object
-
#upload(path, file_path: nil, field_name: "file", extra_fields: {}, headers: {}, file_bytes: nil, filename: nil) ⇒ Object
POST a multipart/form-data body — a file plus optional text fields.
Constructor Details
#initialize(base_url, headers: {}, timeout: 30, bearer_token: nil, username: nil, password: nil, verify_ssl: nil, max_retries: 0, retry_backoff: 0.5, transport: nil, cookies: false) ⇒ API
3.13.1: added ergonomic kwargs (bearer_token, username, password, verify_ssl) so callers no longer need three follow-up setter calls. Cross-framework parity with the Python tina4_python.api.Api kwargs.
api = Tina4::API.new("https://api.example.com", bearer_token: "sk-abc")
api = Tina4::API.new("https://api.example.com", username: "u", password: "p")
api = Tina4::API.new("https://api.example.com", headers: {"X-Tenant" => "acme"})
api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
Bearer wins over basic-auth when both are passed.
3.13.39: +max_retries / +retry_backoff enable opt-in automatic retry with exponential backoff (default max_retries: 0 = off, non-breaking) on a transport error (APIResponse#status == 0) or a retryable status (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be re-sent — retries are opt-in for exactly that reason. Parity with the Python master.
3.13.69: +transport (an injectable seam) and +cookies (an opt-in per-client cookie jar), both parity with the Python master.
transport (default nil = the real Net::HTTP network path) is an injectable
seam so that APPLICATION developers can unit-test their own code without a
live server. When supplied it must respond to #call with the signature
call(method, url, headers, body, timeout) and return a Hash shaped like
{http_code:/status:, body:, headers:, error:} (string or symbol keys). It
fully REPLACES the network call. NOTE: Tina4's own suite must NEVER inject a
fake/canned transport — the no-mock rule stands, so framework tests always
exercise the real network path against a real local server. The seam exists
purely for application-developer testing.
cookies (default false = off, zero behaviour change) turns on a
per-client, in-memory cookie jar: Set-Cookie headers on responses are
parsed (leading name=value only, last write wins) and the accumulated
Cookie header is sent on subsequent requests. Not persisted; scoped to
this instance.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/tina4/api.rb', line 101 def initialize(base_url, headers: {}, timeout: 30, bearer_token: nil, username: nil, password: nil, verify_ssl: nil, max_retries: 0, retry_backoff: 0.5, transport: nil, cookies: false) if !transport.nil? && !transport.respond_to?(:call) raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)" end @base_url = base_url.chomp("/") # VERSION-DEC-03 (feature 130): every outbound request carries a # default `Tina4/<version>` User-Agent. `headers` (the caller's own, # from the constructor kwarg) is merged in LAST, so a caller-supplied # "User-Agent" always wins -- this is a default, never a clobber. @headers = { "User-Agent" => "Tina4/#{Tina4::VERSION}", "Content-Type" => "application/json", "Accept" => "application/json" }.merge(headers) @timeout = timeout @verify_ssl = verify_ssl @max_retries = [0, max_retries.to_i].max @retry_backoff = retry_backoff.to_f @transport = transport @cookies_enabled = ? true : false @cookies = {} # Bearer wins over basic-auth when both passed if bearer_token set_bearer_token(bearer_token) elsif username && password set_basic_auth(username, password) end end |
Instance Attribute Details
#base_url ⇒ Object (readonly)
Returns the value of attribute base_url.
63 64 65 |
# File 'lib/tina4/api.rb', line 63 def base_url @base_url end |
#headers ⇒ Object (readonly)
Returns the value of attribute headers.
63 64 65 |
# File 'lib/tina4/api.rb', line 63 def headers @headers end |
Instance Method Details
#add_headers(headers) ⇒ Object
289 290 291 292 |
# File 'lib/tina4/api.rb', line 289 def add_headers(headers) @headers.merge!(headers) self end |
#delete(path, body: nil) ⇒ Object
175 176 177 178 179 180 181 |
# File 'lib/tina4/api.rb', line 175 def delete(path, body: nil) uri = build_uri(path) request = Net::HTTP::Delete.new(uri) request.body = body.is_a?(String) ? body : JSON.generate(body) if body apply_headers(request, {}) execute(uri, request) end |
#download(path, dest_path: nil, params: {}) ⇒ Object
Stream a GET response body to dest_path in chunks.
The body is written to disk API_DOWNLOAD_CHUNK_SIZE bytes at a time instead of being buffered whole in memory — safe for large payloads. Follows redirects (with the same cross-origin auth/cookie strip as every verb) and honours verify_ssl.
Returns an APIResponse carrying path (and no body — it went to disk).
path is dest_path on success and nil on any error (missing dest, HTTP
error status, or a transport failure); the destination file is not written
on a pre-flight or HTTP-status error. status is 0 on a transport failure.
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/tina4/api.rb', line 254 def download(path, dest_path: nil, params: {}) return error_response("download requires dest_path", path: nil) unless dest_path uri = build_uri(path, params) headers = @headers.dup = headers["Cookie"] = if @cookies_enabled && return download_via_transport(uri, headers, dest_path) if @transport begin result = network_call("GET", uri, headers, nil, stream_to: dest_path) code = result[:status] if result[:path] APIResponse.new(status: code, body: nil, headers: result[:headers], error: nil, path: result[:path]) else APIResponse.new(status: code, body: nil, headers: result[:headers], error: "download failed (HTTP #{code})", path: nil) end rescue StandardError => e APIResponse.new(status: 0, body: nil, headers: {}, error: e., path: nil) end end |
#get(path, params: {}) ⇒ Object
135 136 137 138 139 140 |
# File 'lib/tina4/api.rb', line 135 def get(path, params: {}) uri = build_uri(path, params) request = Net::HTTP::Get.new(uri) apply_headers(request, {}) execute(uri, request) end |
#patch(path, body: nil, content_type: "application/json") ⇒ Object
164 165 166 167 168 169 170 171 172 173 |
# File 'lib/tina4/api.rb', line 164 def patch(path, body: nil, content_type: "application/json") uri = build_uri(path) request = Net::HTTP::Patch.new(uri) if body request.body = body.is_a?(String) ? body : JSON.generate(body) request["Content-Type"] = content_type end apply_headers(request, {}) execute(uri, request) end |
#post(path, body: nil, content_type: "application/json") ⇒ Object
142 143 144 145 146 147 148 149 150 151 |
# File 'lib/tina4/api.rb', line 142 def post(path, body: nil, content_type: "application/json") uri = build_uri(path) request = Net::HTTP::Post.new(uri) if body request.body = body.is_a?(String) ? body : JSON.generate(body) request["Content-Type"] = content_type end apply_headers(request, {}) execute(uri, request) end |
#put(path, body: nil, content_type: "application/json") ⇒ Object
153 154 155 156 157 158 159 160 161 162 |
# File 'lib/tina4/api.rb', line 153 def put(path, body: nil, content_type: "application/json") uri = build_uri(path) request = Net::HTTP::Put.new(uri) if body request.body = body.is_a?(String) ? body : JSON.generate(body) request["Content-Type"] = content_type end apply_headers(request, {}) execute(uri, request) end |
#send_request(method = "GET", path = "", body: nil, content_type: "application/json") ⇒ Object
294 295 296 297 298 299 300 301 302 303 |
# File 'lib/tina4/api.rb', line 294 def send_request(method = "GET", path = "", body: nil, content_type: "application/json") case method.upcase when "GET" then get(path) when "POST" then post(path, body: body, content_type: content_type) when "PUT" then put(path, body: body, content_type: content_type) when "PATCH" then patch(path, body: body, content_type: content_type) when "DELETE" then delete(path, body: body) else get(path) end end |
#set_basic_auth(username, password) ⇒ Object
279 280 281 282 |
# File 'lib/tina4/api.rb', line 279 def set_basic_auth(username, password) @headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}" self end |
#set_bearer_token(token) ⇒ Object
284 285 286 287 |
# File 'lib/tina4/api.rb', line 284 def set_bearer_token(token) @headers["Authorization"] = "Bearer #{token}" self end |
#stream_bytes(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) ⇒ Object
── Streaming primitives (ADR-0060 / 3.13.113) ─────────────────────────────
Three cooperating primitives, each layered on the one below:
* stream_bytes — raw response body chunks in the order the transport
delivers them (no buffering, no framing).
* stream_lines — one String per LF- or CRLF-terminated line, decoded as
UTF-8. A trailing line without a newline is yielded on
EOF. An incomplete UTF-8 sequence at a chunk boundary
is buffered across chunks.
* stream_sse — SSE-framed events {data:, event:, id:, retry:}. Blank
line separates events; ":" comment lines are dropped;
multi-line data: fields are joined with "\n"; the
OpenAI [DONE] sentinel arrives as the last event
(data == "[DONE]") and the iterator ends on the next
EOF (the caller decides how to treat it).
Ruby idiom: pass a block, OR call without a block to get an Enumerator. Every keyword arg (method:, body:, headers:, content_type:, timeout:, connect_timeout:) is optional and matches the send_request defaults. Aborting the iterator (break, StopIteration on Enumerator#next, GC) closes the underlying socket cleanly — Net::HTTP's block form releases it on any exit from the block.
These are the SAME primitives Tina4::Ai.chat(stream: true) uses under the hood (ADR-0060 rule 5). Application code that streams HTTP anywhere (LLMs, log tails, event feeds, chunked downloads) reaches for these instead of hand-rolling a Net::HTTP + line-buffer + SSE-frame reader per app.
333 334 335 336 337 338 339 340 341 342 343 |
# File 'lib/tina4/api.rb', line 333 def stream_bytes(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) unless block_given? return Enumerator.new do |y| stream_bytes(path, method: method, body: body, headers: headers, content_type: content_type, timeout: timeout, connect_timeout: connect_timeout) { |chunk| y << chunk } end end open_stream(path, method, body, headers, content_type, timeout, connect_timeout, &block) end |
#stream_lines(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) ⇒ Object
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'lib/tina4/api.rb', line 345 def stream_lines(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) unless block_given? return Enumerator.new do |y| stream_lines(path, method: method, body: body, headers: headers, content_type: content_type, timeout: timeout, connect_timeout: connect_timeout) { |line| y << line } end end buffer = String.new(encoding: Encoding::BINARY) stream_bytes(path, method: method, body: body, headers: headers, content_type: content_type, timeout: timeout, connect_timeout: connect_timeout) do |chunk| buffer << chunk.b while (index = buffer.index("\n".b)) raw = buffer.byteslice(0, index) raw = raw.byteslice(0, raw.bytesize - 1) if raw.bytesize.positive? && raw.getbyte(raw.bytesize - 1) == 13 buffer = buffer.byteslice(index + 1, buffer.bytesize - index - 1) || String.new(encoding: Encoding::BINARY) block.call(decode_utf8(raw)) end end block.call(decode_utf8(buffer)) unless buffer.empty? end |
#stream_sse(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) ⇒ Object
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 |
# File 'lib/tina4/api.rb', line 369 def stream_sse(path, method: "GET", body: nil, headers: {}, content_type: nil, timeout: nil, connect_timeout: nil, &block) unless block_given? return Enumerator.new do |y| stream_sse(path, method: method, body: body, headers: headers, content_type: content_type, timeout: timeout, connect_timeout: connect_timeout) { |event| y << event } end end data_parts = [] event_name = nil event_id = nil retry_ms = nil dispatch = lambda do return if data_parts.empty? && event_name.nil? && event_id.nil? && retry_ms.nil? payload = { data: data_parts.join("\n") } payload[:event] = event_name if event_name payload[:id] = event_id if event_id payload[:retry] = retry_ms if retry_ms block.call(payload) data_parts = [] event_name = nil event_id = nil retry_ms = nil end stream_lines(path, method: method, body: body, headers: headers, content_type: content_type, timeout: timeout, connect_timeout: connect_timeout) do |line| if line.empty? dispatch.call elsif line.start_with?(":") # comment — ignored per the SSE spec else field, sep, value = line.partition(":") field = line if sep.empty? value = "" if sep.empty? value = value[1..] if value.start_with?(" ") case field when "data" then data_parts << value when "event" then event_name = value when "id" then event_id = value when "retry" begin retry_ms = Integer(value) rescue ArgumentError, TypeError retry_ms = nil end end end end dispatch.call end |
#upload(path, file_path: nil, field_name: "file", extra_fields: {}, headers: {}, file_bytes: nil, filename: nil) ⇒ Object
POST a multipart/form-data body — a file plus optional text fields.
BREAKING (3.13.69): reconciled to the canonical cross-framework shape.
file_path was a REQUIRED positional; it is now a keyword. Two ways to
supply the file, so a caller never needs a temp file:
- file_path: — a file on disk. filename: defaults to its basename.
- file_bytes: + filename: — an in-memory payload (String bytes).
field_name: is the form field the file is sent under (default "file"). extra_fields: (Hash) become additional text parts. headers: (Hash) are extra per-call headers merged onto the request. The part's Content-Type is guessed from the filename (falling back to application/octet-stream) — this replaces the old hard-coded application/octet-stream. The client's default headers (including any Authorization) are sent too, unlike the old upload().
Returns an APIResponse. A missing file or no source given returns a clean error response (status 0, error set) — it does NOT raise, and nothing is sent over the wire.
api.upload("/avatars", file_path: "/tmp/me.png")
api.upload("/avatars", file_bytes: raw, filename: "me.png",
extra_fields: { "user_id" => "42" })
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'lib/tina4/api.rb', line 206 def upload(path, file_path: nil, field_name: "file", extra_fields: {}, headers: {}, file_bytes: nil, filename: nil) unless file_bytes.nil? content = file_bytes.is_a?(String) ? file_bytes : file_bytes.to_s upload_name = filename || "upload.bin" end if content.nil? && file_path return error_response("file not found: #{file_path}") unless File.file?(file_path) begin content = File.binread(file_path) rescue SystemCallError => e return error_response(e.) end upload_name = filename || File.basename(file_path) end return error_response("upload requires file_path or file_bytes") if content.nil? part_content_type = guess_content_type(upload_name) boundary = "----Tina4Boundary#{SecureRandom.hex(16)}" body = build_multipart_body(boundary, field_name, upload_name, content, part_content_type, extra_fields) uri = build_uri(path) request = Net::HTTP::Post.new(uri) request.body = body # Apply the client default headers (auth, etc.) FIRST, then force the # multipart Content-Type so it wins over the default application/json, # then any per-call headers last (parity with the Python master's order). apply_headers(request, {}) request["Content-Type"] = "multipart/form-data; boundary=#{boundary}" headers.each { |key, value| request[key] = value } execute(uri, request) end |