Class: Quicopt::Client
- Inherits:
-
Object
- Object
- Quicopt::Client
- Defined in:
- lib/quicopt/client.rb
Overview
A connection to a Quicopt service at base_url — the public free tier
(+DEFAULT_BASE_URL+) unless another URL is given.
The client holds the API key: pass a known one, or let the first keyless call mint one — which is then cached on disk and reused by later runs, so one caller keeps one key.
client = Quicopt::Client.new
result = client.solve(model)
puts result.display
client.api_key # => the key the first call minted (and cached)
Constant Summary collapse
- REQUESTS =
The HTTP methods this client issues, and the request class for each.
{ "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "DELETE" => Net::HTTP::Delete }.freeze
- OCTET_STREAM =
The content type of a request body: the wire bytes are opaque binary.
"application/octet-stream"
Instance Attribute Summary collapse
-
#api_key ⇒ String?
The API key in use; set on the first mint.
-
#base_url ⇒ String
readonly
The service base URL, without a trailing slash.
-
#key_path ⇒ Pathname
readonly
Where the free key is cached.
-
#timeout ⇒ Float
The per-request socket timeout, in seconds.
Class Method Summary collapse
-
.decode(raw) ⇒ Hash
private
Best-effort decode of a response body into a Hash.
-
.default_key_path ⇒ Pathname
The default location of the cached free key:
$XDG_CACHE_HOME/quicopt/free_key, falling back to~/.cachewhen the variable is unset, unlessKEY_PATH_ENVoverrides the whole path. -
.escape(value) ⇒ Object
private
Percent-encode one query component per RFC 3986 (unreserved characters pass through; everything else is escaped byte-wise, UTF-8 included).
-
.gzip(data) ⇒ Object
private
Gzip-compress a request body.
-
.query(config) ⇒ String
private
Render a config mapping as a URL query-string suffix.
-
.read_key(path) ⇒ String?
Read the cached key.
-
.write_key(path, key) ⇒ Object
Persist
keyatpath, atomically and readable only by its owner.
Instance Method Summary collapse
-
#attempt(method, path, data = nil, config: nil, gzip: false) ⇒ String
private
Perform one HTTP request and return the raw response body.
-
#initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0, key_path: nil, cache: true) ⇒ Client
constructor
Bind a client to a service endpoint.
-
#request(method, path, data = nil, **options) ⇒ Hash
private
Perform a request via
request_rawand parse its JSON body. -
#request_raw(method, path, data = nil, config: nil, gzip: false) ⇒ String
private
Perform one HTTP request, retrying once if a cached key is rejected.
-
#solve(model, project: nil, config: nil, gzip: false) ⇒ Result
Solve
modelsynchronously, blocking until the result returns. -
#submit(model, project: nil, config: nil, gzip: false) ⇒ Job
Submit
modelfor asynchronous solving and return a handle to poll.
Constructor Details
#initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0, key_path: nil, cache: true) ⇒ Client
Bind a client to a service endpoint.
152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/quicopt/client.rb', line 152 def initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0, key_path: nil, cache: true) @base_url = base_url.to_s.sub(%r{/+\z}, "") @timeout = timeout @key_path = Pathname(key_path || Client.default_key_path). @cache = cache @explicit = !api_key.nil? @api_key = @explicit ? api_key : (cache ? Client.read_key(@key_path) : nil) # Only a key that came off disk may be discarded and re-minted on a 401 — # see +request_raw+, which relies on this to bound re-minting to one per run. @from_cache = !@api_key.nil? && !@explicit end |
Instance Attribute Details
#api_key ⇒ String?
Returns the API key in use; set on the first mint.
134 135 136 |
# File 'lib/quicopt/client.rb', line 134 def api_key @api_key end |
#base_url ⇒ String (readonly)
Returns the service base URL, without a trailing slash.
132 133 134 |
# File 'lib/quicopt/client.rb', line 132 def base_url @base_url end |
#key_path ⇒ Pathname (readonly)
Returns where the free key is cached.
138 139 140 |
# File 'lib/quicopt/client.rb', line 138 def key_path @key_path end |
#timeout ⇒ Float
Returns the per-request socket timeout, in seconds.
136 137 138 |
# File 'lib/quicopt/client.rb', line 136 def timeout @timeout end |
Class Method Details
.decode(raw) ⇒ Hash
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Best-effort decode of a response body into a Hash.
306 307 308 309 310 311 312 313 314 |
# File 'lib/quicopt/client.rb', line 306 def self.decode(raw) text = raw.to_s return {} if text.empty? parsed = JSON.parse(text) parsed.is_a?(Hash) ? parsed : { "error" => text } rescue JSON::ParserError { "error" => text } end |
.default_key_path ⇒ Pathname
The default location of the cached free key:
$XDG_CACHE_HOME/quicopt/free_key, falling back to ~/.cache when the
variable is unset, unless KEY_PATH_ENV overrides the whole path.
321 322 323 324 325 326 327 328 |
# File 'lib/quicopt/client.rb', line 321 def self.default_key_path override = ENV[KEY_PATH_ENV] return Pathname(override) unless override.nil? || override.empty? root = ENV["XDG_CACHE_HOME"] root = File.join(Dir.home, ".cache") if root.nil? || root.empty? Pathname(root) + "quicopt" + "free_key" end |
.escape(value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Percent-encode one query component per RFC 3986 (unreserved characters pass through; everything else is escaped byte-wise, UTF-8 included).
283 284 285 |
# File 'lib/quicopt/client.rb', line 283 def self.escape(value) value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) } end |
.gzip(data) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Gzip-compress a request body.
290 291 292 293 294 295 296 297 298 299 |
# File 'lib/quicopt/client.rb', line 290 def self.gzip(data) io = StringIO.new(+"".b) writer = Zlib::GzipWriter.new(io) begin writer.write(data) ensure writer.close end io.string end |
.query(config) ⇒ String
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Render a config mapping as a URL query-string suffix.
Escapes per RFC 3986, so a space becomes %20 and never +'+'+ — the form
the service's query parser round-trips unambiguously, and the form the
other clients send. Ruby's stock helpers (+URI.encode_www_form+,
CGI.escape, URI.encode_www_form_component) all emit +'+'+ here, so none
of them can be used.
273 274 275 276 277 |
# File 'lib/quicopt/client.rb', line 273 def self.query(config) return "" if config.nil? || config.empty? "?" + config.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join("&") end |
.read_key(path) ⇒ String?
Read the cached key. A missing or unreadable cache is not an error — it just means this caller has no key yet and the next call mints one.
334 335 336 337 338 339 |
# File 'lib/quicopt/client.rb', line 334 def self.read_key(path) key = path.read.strip key.empty? ? nil : key rescue SystemCallError, IOError nil end |
.write_key(path, key) ⇒ Object
Persist key at path, atomically and readable only by its owner.
The key is a credential, so the file is 0600; the write goes to a temp
file in the same directory and is then renamed into place, so a crash or a
concurrent writer can never leave a truncated key behind for the next run
to send.
350 351 352 353 354 355 356 357 358 |
# File 'lib/quicopt/client.rb', line 350 def self.write_key(path, key) path.dirname.mkpath path.dirname.chmod(0o700) tmp = path.dirname + ".free_key.#{Process.pid}" tmp.open(File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |fh| fh.write(key) } tmp.rename(path.to_s) ensure tmp&.unlink if tmp&.exist? end |
Instance Method Details
#attempt(method, path, data = nil, config: nil, gzip: false) ⇒ String
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Perform one HTTP request and return the raw response body.
Sets the octet-stream content type (and gzip encoding, if requested) for a body, attaches the bearer key when known, and captures a freshly minted key off the response — including from an error response, since a 502/504 on a first call may still have minted one.
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/quicopt/client.rb', line 231 def attempt(method, path, data = nil, config: nil, gzip: false) uri = URI.parse(@base_url + path + Client.query(config)) request_class = REQUESTS.fetch(method) { raise ArgumentError, "unsupported method #{method}" } req = request_class.new(uri) if data req["Content-Type"] = OCTET_STREAM if gzip data = Client.gzip(data) req["Content-Encoding"] = "gzip" end req.body = data end req["Authorization"] = "Bearer #{@api_key}" if @api_key resp = http(uri).start { |conn| conn.request(req) } capture_key(resp) # a 502/504 on a first call still minted a key raise QuicoptError.new(resp.code.to_i, Client.decode(resp.body)) unless resp.is_a?(Net::HTTPSuccess) resp.body.to_s end |
#request(method, path, data = nil, **options) ⇒ Hash
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Perform a request via request_raw and parse its JSON body.
257 258 259 260 |
# File 'lib/quicopt/client.rb', line 257 def request(method, path, data = nil, **) raw = request_raw(method, path, data, **) raw.empty? ? {} : JSON.parse(raw) end |
#request_raw(method, path, data = nil, config: nil, gzip: false) ⇒ String
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Perform one HTTP request, retrying once if a cached key is rejected.
A cache file can outlive the key it holds (the server was reset, the key was revoked, the file was copied from another machine), and a stale key 401s every call forever. So a 401 discards the cached key and retries once, keyless — which mints a fresh key and caches it.
The retry is deliberately narrow: only a key read from disk is discarded,
never one minted in this process and never a caller-supplied api_key. A
run can therefore mint at most one key beyond the stale one, so a server
that rejected a key it just issued can never turn into a mint loop.
212 213 214 215 216 217 218 219 |
# File 'lib/quicopt/client.rb', line 212 def request_raw(method, path, data = nil, config: nil, gzip: false) attempt(method, path, data, config: config, gzip: gzip) rescue QuicoptError => e raise unless e.status_code == 401 && @from_cache discard_key attempt(method, path, data, config: config, gzip: gzip) end |
#solve(model, project: nil, config: nil, gzip: false) ⇒ Result
Solve model synchronously, blocking until the result returns.
178 179 180 181 |
# File 'lib/quicopt/client.rb', line 178 def solve(model, project: nil, config: nil, gzip: false) Result.from_json(request("POST", "/v1/solve", to_wire(model), config: (model, project, config), gzip: gzip)) end |
#submit(model, project: nil, config: nil, gzip: false) ⇒ Job
Submit model for asynchronous solving and return a handle to poll.
Use this for the first call against a freshly-booted server: the worker warms up before claiming, which can time out a cold synchronous call.
190 191 192 193 194 195 |
# File 'lib/quicopt/client.rb', line 190 def submit(model, project: nil, config: nil, gzip: false) body = request("POST", "/v1/jobs", to_wire(model), config: (model, project, config), gzip: gzip) remember(body["api_key"]) # /v1/jobs echoes a minted key in the 202 body too Job.new(self, body["job_id"]) end |