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
-
#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 |
#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 |