Class: VisionAPI::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/vision_api/client.rb

Overview

The Vision API client.

One object, one method per endpoint, plus the two things every integration ends up writing by hand: retry that honors Retry-After, and polling that knows when a task is done. Docs: https://docs.visionapi.io

vision = VisionAPI::Client.new              # reads ENV["VISION_API_KEY"]
res = vision.analyze(file: "invoice.pdf", preset: "invoice")
res["result"]["invoice_id"]["value"]        # => "A-10422"

Responses are plain hashes with the wire's snake_case keys, because the response shape is the public contract and renaming it here would make https://docs.visionapi.io stop matching what you see in your editor.

Constant Summary collapse

RETRYABLE_STATUS =
[429, 500, 502, 503].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_key: nil, base_url: nil, timeout: 120, max_retries: 3, auto_idempotency: true, headers: {}) ⇒ Client

Returns a new instance of Client.

Parameters:

  • api_key (String, nil) (defaults to: nil)

    your secret key; defaults to ENV. There is no publishable key and no test mode — this is a live spending credential, so keep it server-side. Create one at https://app.visionapi.io/dashboard/keys

  • base_url (String, nil) (defaults to: nil)

    override for a self-hosted or staging deployment

  • timeout (Numeric) (defaults to: 120)

    per-request deadline in seconds. The server kills a synchronous request at 60 s, so anything above that only covers upload time.

  • max_retries (Integer) (defaults to: 3)

    how many times to retry a retryable failure — 429, 500, 502 and network errors. Input errors and insufficient_credits are never retried.

  • auto_idempotency (Boolean) (defaults to: true)

    generate an Idempotency-Key for every billable POST that does not carry one, so a retry replays the first response instead of paying twice

  • headers (Hash) (defaults to: {})

    extra headers sent on every request



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/vision_api/client.rb', line 45

def initialize(api_key: nil, base_url: nil, timeout: 120, max_retries: 3,
               auto_idempotency: true, headers: {})
  @api_key = api_key || ENV.fetch("VISION_API_KEY", nil)
  if @api_key.nil? || @api_key.empty?
    raise UsageError, "No API key. Pass VisionAPI::Client.new(api_key: …) or set " \
                      "VISION_API_KEY. Create one at https://app.visionapi.io/dashboard/keys"
  end

  @base_url = (base_url || ENV["VISION_API_URL"] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
  @timeout = timeout
  @max_retries = max_retries
  @auto_idempotency = auto_idempotency
  @headers = headers
  @user_agent = "visionapi-ruby/#{VisionAPI::VERSION} ruby/#{RUBY_VERSION}"
end

Instance Attribute Details

#base_urlObject (readonly)

Returns the value of attribute base_url.



30
31
32
# File 'lib/vision_api/client.rb', line 30

def base_url
  @base_url
end

#max_retriesObject (readonly)

Returns the value of attribute max_retries.



30
31
32
# File 'lib/vision_api/client.rb', line 30

def max_retries
  @max_retries
end

#timeoutObject (readonly)

Returns the value of attribute timeout.



30
31
32
# File 'lib/vision_api/client.rb', line 30

def timeout
  @timeout
end

Instance Method Details

#analyze(file: nil, file_url: nil, file_base64: nil, idempotency_key: nil, timeout: nil, **params) ⇒ Hash

Extracts structured data from one image or PDF and waits for the answer.

Costs 1 credit per image, or 1 per selected PDF page. Failures cost nothing — the reservation is released in full on any non-2xx, so there is no compensating logic to write. Anything that might run past 60 s belongs on #analyze_async.

Parameters:

  • file (String, Pathname, IO, String) (defaults to: nil)

    a path, an open binary IO, or raw bytes

  • file_url (String) (defaults to: nil)

    a public URL for the API to fetch instead

  • file_base64 (String) (defaults to: nil)

    base64 bytes, with or without a data: prefix

  • preset (String)

    a catalog name, or "auto" to have the API classify the file first (free)

  • schema (Hash)

    custom fields, alone or on top of a preset

  • schema_name (String)

    a schema saved in the dashboard; excludes preset and schema

  • pages (String)

    PDF page selection, e.g. "1-3,7". You pay for selected pages only.

  • language_hint (String)

    ISO 639-1 code, e.g. "es"

  • detail (String)

    "high" renders pages at higher resolution; same cost, slower

  • output (String)

    "text" returns raw OCR text instead of fields

  • include_raw_text (Boolean)

    adds full_text alongside result

  • min_confidence (String)

    fields below this level come back nil, confidence kept

  • idempotency_key (String) (defaults to: nil)

    supply your own when the caller may retry

Returns:

  • (Hash)

    the full response body



84
85
86
87
88
# File 'lib/vision_api/client.rb', line 84

def analyze(file: nil, file_url: nil, file_base64: nil, idempotency_key: nil,
            timeout: nil, **params)
  submit("/v1/analyze", file, file_url, file_base64, analyze_fields(**params),
         idempotency_key, timeout)
end

#analyze_and_wait(poll_interval: 2, max_wait: 600, on_poll: nil, **params) ⇒ Object

#analyze_async followed by #wait_for_task — the shape most batch jobs want.

Raises:



107
108
109
110
# File 'lib/vision_api/client.rb', line 107

def analyze_and_wait(poll_interval: 2, max_wait: 600, on_poll: nil, **params)
  ref = analyze_async(**params)
  wait_for_task(ref["task_id"], poll_interval: poll_interval, max_wait: max_wait, on_poll: on_poll)
end

#analyze_async(file: nil, file_url: nil, file_base64: nil, webhook_url: nil, idempotency_key: nil, timeout: nil, **params) ⇒ Hash

Submits an extraction to the queue and returns as soon as it is accepted.

Use it for long PDFs, detail: "high", or any batch you do not want to hold a connection open for. Poll with #wait_for_task, or pass webhook_url — an HTTPS endpoint that does not resolve to a private address — and let the result come to you.

Returns:

  • (Hash)

    {"task_id" => …, "status" => "queued"}



97
98
99
100
101
# File 'lib/vision_api/client.rb', line 97

def analyze_async(file: nil, file_url: nil, file_base64: nil, webhook_url: nil,
                  idempotency_key: nil, timeout: nil, **params)
  fields = analyze_fields(**params).merge("async" => true, "webhook_url" => webhook_url)
  submit("/v1/analyze", file, file_url, file_base64, fields, idempotency_key, timeout)
end

#ask(questions:, file: nil, file_url: nil, file_base64: nil, pages: nil, language_hint: nil, detail: nil, idempotency_key: nil, timeout: nil) ⇒ Object

Asks up to 5 questions about one file.

Priced exactly like an extraction — per image or per selected page — and the questions themselves are free. Branch on answer (+yes+ / no / uncertain / n/a) rather than parsing the prose.



117
118
119
120
121
# File 'lib/vision_api/client.rb', line 117

def ask(questions:, file: nil, file_url: nil, file_base64: nil, pages: nil,
        language_hint: nil, detail: nil, idempotency_key: nil, timeout: nil)
  fields = ask_fields(questions, pages, language_hint, detail)
  submit("/v1/ask", file, file_url, file_base64, fields, idempotency_key, timeout)
end

#ask_async(questions:, file: nil, file_url: nil, file_base64: nil, pages: nil, language_hint: nil, detail: nil, webhook_url: nil, idempotency_key: nil, timeout: nil) ⇒ Object

Queues an #ask instead of waiting for it.



124
125
126
127
128
129
130
# File 'lib/vision_api/client.rb', line 124

def ask_async(questions:, file: nil, file_url: nil, file_base64: nil, pages: nil,
              language_hint: nil, detail: nil, webhook_url: nil, idempotency_key: nil,
              timeout: nil)
  fields = ask_fields(questions, pages, language_hint, detail)
           .merge("async" => true, "webhook_url" => webhook_url)
  submit("/v1/ask", file, file_url, file_base64, fields, idempotency_key, timeout)
end

#create_schema(name, preset: nil, schema: nil, timeout: nil) ⇒ Object

Saves a preset + custom-field combination under a name, for later use as analyze(schema_name: …). The definition is compiled before it is stored, so an invalid schema fails here rather than on the first extraction that uses it.



241
242
243
244
245
246
# File 'lib/vision_api/client.rb', line 241

def create_schema(name, preset: nil, schema: nil, timeout: nil)
  body = { "name" => name }
  body["preset"] = preset if preset
  body["schema"] = schema if schema
  request(:post, "/v1/schemas", json: body, timeout: timeout)
end

#credits(timeout: nil) ⇒ Object

Balance and the per-bucket breakdown. Buckets are spent in order: subscription → rollover → pack → welcome.



189
190
191
# File 'lib/vision_api/client.rb', line 189

def credits(timeout: nil)
  request(:get, "/v1/credits", timeout: timeout)
end

#delete_schema(name, timeout: nil) ⇒ Object



256
257
258
259
# File 'lib/vision_api/client.rb', line 256

def delete_schema(name, timeout: nil)
  request(:delete, "/v1/schemas/#{escape_segment(name)}", timeout: timeout)
  nil
end

#detect(file: nil, file_url: nil, file_base64: nil, detail: nil, idempotency_key: nil, timeout: nil) ⇒ Object

Identifies what a file is without paying to extract it.

Metered at 1 credit per 10 calls whatever the page count, because detection only ever reads page 1 — so nine calls out of ten report "credits_used" => 0. "recommended" is exactly what preset: "auto" would have run, so you can probe first and trust the answer.

Reach for it when the type is the decision (routing a mixed inbox, refusing to spend on a 40-page PDF sight unseen); reach for preset: "auto" when you want the data and do not care which preset produced it.



142
143
144
145
146
# File 'lib/vision_api/client.rb', line 142

def detect(file: nil, file_url: nil, file_base64: nil, detail: nil,
           idempotency_key: nil, timeout: nil)
  submit("/v1/detect", file, file_url, file_base64, { "detail" => detail },
         idempotency_key, timeout)
end

#each_request(limit: 100, &block) ⇒ Enumerator

Enumerates the whole history, one record at a time, paging as it goes.

Returns:

  • (Enumerator)

    lazy — each_request.first(10) fetches one page



202
203
204
205
206
207
208
209
210
211
212
# File 'lib/vision_api/client.rb', line 202

def each_request(limit: 100, &block)
  return enum_for(:each_request, limit: limit) unless block_given?

  cursor = nil
  loop do
    page = requests(limit: limit, cursor: cursor)
    page["data"].each(&block)
    cursor = page["next_cursor"]
    break if cursor.nil? || cursor.empty?
  end
end

#get_task(task_id, timeout: nil) ⇒ Object

Status and, once finished, the result of an async task.

Results stay retrievable for 7 days; after that this raises ResultExpiredError (the metadata survives, the payload does not).



154
155
156
# File 'lib/vision_api/client.rb', line 154

def get_task(task_id, timeout: nil)
  request(:get, "/v1/tasks/#{escape_segment(task_id)}", timeout: timeout)
end

#preset(name, timeout: nil) ⇒ Object

One preset with its full field definitions. Read this before mapping field names.



223
224
225
# File 'lib/vision_api/client.rb', line 223

def preset(name, timeout: nil)
  request(:get, "/v1/presets/#{escape_segment(name)}", auth: false, timeout: timeout)
end

#presets(timeout: nil) ⇒ Object

The preset catalog. No API key required.

Pick a preset from here rather than from memory — presets are versioned, and the catalog is the source of truth for both the names and the fields.



218
219
220
# File 'lib/vision_api/client.rb', line 218

def presets(timeout: nil)
  request(:get, "/v1/presets", auth: false, timeout: timeout)["data"]
end

#requests(limit: nil, cursor: nil, timeout: nil) ⇒ Object

One page of usage history, newest first. Metadata only — the file and the extracted values are never kept. Pass cursor: page for the next page.



195
196
197
# File 'lib/vision_api/client.rb', line 195

def requests(limit: nil, cursor: nil, timeout: nil)
  request(:get, "/v1/requests", query: { "limit" => limit, "cursor" => cursor }, timeout: timeout)
end

#schema(name, timeout: nil) ⇒ Object



234
235
236
# File 'lib/vision_api/client.rb', line 234

def schema(name, timeout: nil)
  request(:get, "/v1/schemas/#{escape_segment(name)}", timeout: timeout)
end

#schemas(timeout: nil) ⇒ Object

Every schema saved on this account.



230
231
232
# File 'lib/vision_api/client.rb', line 230

def schemas(timeout: nil)
  request(:get, "/v1/schemas", timeout: timeout)["data"]
end

#update_schema(name, preset: nil, schema: nil, timeout: nil) ⇒ Object

Replaces a saved schema in place.



249
250
251
252
253
254
# File 'lib/vision_api/client.rb', line 249

def update_schema(name, preset: nil, schema: nil, timeout: nil)
  body = {}
  body["preset"] = preset if preset
  body["schema"] = schema if schema
  request(:put, "/v1/schemas/#{escape_segment(name)}", json: body, timeout: timeout)
end

#wait_for_task(task_id, poll_interval: 2, max_wait: 600, raise_on_failure: true, on_poll: nil) ⇒ Object

Polls until the task leaves +queued+/+processing+, then returns it.

Parameters:

  • raise_on_failure (Boolean) (defaults to: true)

    when false, a failed task is returned as-is

  • on_poll (Proc, nil) (defaults to: nil)

    called with each non-final poll — a progress bar, a log line

Raises:



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/vision_api/client.rb', line 165

def wait_for_task(task_id, poll_interval: 2, max_wait: 600, raise_on_failure: true, on_poll: nil)
  deadline = monotonic + max_wait

  loop do
    task = get_task(task_id)
    case task["status"]
    when "completed" then return task
    when "failed"
      return task unless raise_on_failure

      raise TaskFailedError.new(task_id, task["error"])
    end

    raise TaskTimeoutError.new(task_id, max_wait) if monotonic + poll_interval > deadline

    on_poll&.call(task)
    sleep(poll_interval)
  end
end