vision_api 1.0.0
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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +451 -0
- data/lib/vision_api/client.rb +431 -0
- data/lib/vision_api/errors.rb +239 -0
- data/lib/vision_api/multipart.rb +106 -0
- data/lib/vision_api/result.rb +111 -0
- data/lib/vision_api/version.rb +5 -0
- data/lib/vision_api/webhook.rb +94 -0
- data/lib/vision_api.rb +31 -0
- metadata +104 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module VisionAPI
|
|
7
|
+
# multipart/form-data encoding, written out rather than pulled in.
|
|
8
|
+
#
|
|
9
|
+
# The gem depends on nothing but the standard library, and this is the only piece
|
|
10
|
+
# net/http does not already give us.
|
|
11
|
+
module Multipart
|
|
12
|
+
CONTENT_TYPES = {
|
|
13
|
+
".pdf" => "application/pdf",
|
|
14
|
+
".png" => "image/png",
|
|
15
|
+
".jpg" => "image/jpeg",
|
|
16
|
+
".jpeg" => "image/jpeg",
|
|
17
|
+
".webp" => "image/webp",
|
|
18
|
+
".tif" => "image/tiff",
|
|
19
|
+
".tiff" => "image/tiff"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Turns whatever the caller passed into <tt>[filename, bytes, content_type]</tt>.
|
|
25
|
+
#
|
|
26
|
+
# Accepts a path, a Pathname, an open binary IO, raw bytes, or an explicit
|
|
27
|
+
# <tt>[filename, bytes]</tt> pair. The filename is cosmetic — the server detects the
|
|
28
|
+
# type from magic bytes and ignores what we declare — but a sensible one makes
|
|
29
|
+
# multipart logs readable.
|
|
30
|
+
def resolve(file)
|
|
31
|
+
case file
|
|
32
|
+
when Array
|
|
33
|
+
name, data = file
|
|
34
|
+
[name.to_s, data.to_s, content_type_for(name.to_s)]
|
|
35
|
+
when ->(f) { f.respond_to?(:read) }
|
|
36
|
+
name = file.respond_to?(:path) ? File.basename(file.path.to_s) : "upload"
|
|
37
|
+
[name, file.read, content_type_for(name)]
|
|
38
|
+
when String, ->(f) { f.respond_to?(:to_path) }
|
|
39
|
+
path = file.respond_to?(:to_path) ? file.to_path : file
|
|
40
|
+
if path.match?(%r{\Ahttps?://}i)
|
|
41
|
+
raise UsageError, "Pass a URL as file_url:, not as file:. " \
|
|
42
|
+
"file: is a path on disk, an open IO, or the bytes themselves."
|
|
43
|
+
end
|
|
44
|
+
return [File.basename(path), File.binread(path), content_type_for(path)] if File.file?(path)
|
|
45
|
+
|
|
46
|
+
# Not a path on disk, so treat it as the bytes themselves — which is what a caller
|
|
47
|
+
# who read the file already has in hand.
|
|
48
|
+
["upload", file, "application/octet-stream"]
|
|
49
|
+
else
|
|
50
|
+
raise UsageError, "file: must be a path, an open IO, bytes, or [filename, bytes]."
|
|
51
|
+
end
|
|
52
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
53
|
+
raise UsageError, "Could not read file: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Encodes a multipart body. Returns <tt>[body, content_type]</tt>.
|
|
57
|
+
#
|
|
58
|
+
# An array value becomes repeated parts, which is how +questions+ is meant to arrive
|
|
59
|
+
# over multipart; a hash is JSON-encoded, which is how +schema+ arrives.
|
|
60
|
+
def encode(fields, file)
|
|
61
|
+
boundary = "----visionapi#{SecureRandom.hex(16)}"
|
|
62
|
+
body = +""
|
|
63
|
+
|
|
64
|
+
fields.each do |name, value|
|
|
65
|
+
next if value.nil?
|
|
66
|
+
|
|
67
|
+
Array(wrap(value)).each do |item|
|
|
68
|
+
body << "--#{boundary}\r\n"
|
|
69
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
|
|
70
|
+
body << item
|
|
71
|
+
body << "\r\n"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if file
|
|
76
|
+
filename, data, content_type = file
|
|
77
|
+
body = body.b
|
|
78
|
+
body << "--#{boundary}\r\n"
|
|
79
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
|
|
80
|
+
body << "Content-Type: #{content_type}\r\n\r\n"
|
|
81
|
+
body << data.b
|
|
82
|
+
body << "\r\n"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
body << "--#{boundary}--\r\n"
|
|
86
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @api private
|
|
90
|
+
def wrap(value)
|
|
91
|
+
case value
|
|
92
|
+
when Array then value.map { |item| item.is_a?(String) ? item : JSON.generate(item) }
|
|
93
|
+
when Hash then JSON.generate(value)
|
|
94
|
+
# Booleans go out as "true"/"false", not as JSON. Same body as the else branch, kept
|
|
95
|
+
# separate because it is the one case a reader is likely to expect JSON encoding for.
|
|
96
|
+
when true, false then value.to_s
|
|
97
|
+
else value.to_s # rubocop:disable Lint/DuplicateBranch
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @api private
|
|
102
|
+
def content_type_for(name)
|
|
103
|
+
CONTENT_TYPES.fetch(File.extname(name.to_s).downcase, "application/octet-stream")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VisionAPI
|
|
4
|
+
# Helpers for reading a result.
|
|
5
|
+
#
|
|
6
|
+
# None of this is required — <tt>res["result"]["total"]["value"]</tt> is a perfectly good
|
|
7
|
+
# way to read a field, and responses stay plain hashes. These exist because three rules
|
|
8
|
+
# trip people up often enough to be worth a method:
|
|
9
|
+
#
|
|
10
|
+
# 1. every scalar is wrapped in <tt>{"value", "confidence"}</tt>;
|
|
11
|
+
# 2. a preset response contains every field of the preset, absent ones as +nil+;
|
|
12
|
+
# 3. a line-item array is a *bare* array whose cells are wrapped individually — the array
|
|
13
|
+
# itself has no confidence, each cell has its own.
|
|
14
|
+
module Result
|
|
15
|
+
ORDER = { "low" => 0, "mid" => 1, "high" => 2 }.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @return [Boolean] whether the node is a wrapped scalar rather than rows or a block
|
|
20
|
+
def field?(node)
|
|
21
|
+
node.is_a?(Hash) && node.key?("value") && node.key?("confidence")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] whether the node is a line-item / repeated-block array
|
|
25
|
+
def rows?(node)
|
|
26
|
+
node.is_a?(Array)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Drops the wrappers, recursively.
|
|
30
|
+
#
|
|
31
|
+
# unwrap("total" => {"value" => 12, "confidence" => "high"}) # => {"total" => 12}
|
|
32
|
+
#
|
|
33
|
+
# Absent fields stay as +nil+, because "the preset has this field and the document did
|
|
34
|
+
# not carry it" is information. Pass <tt>drop_null: true</tt> for only what was found.
|
|
35
|
+
#
|
|
36
|
+
# @param result [Hash, nil]
|
|
37
|
+
# @return [Hash]
|
|
38
|
+
def unwrap(result, drop_null: false)
|
|
39
|
+
(result || {}).each_with_object({}) do |(key, node), out|
|
|
40
|
+
if rows?(node)
|
|
41
|
+
unwrapped = node.map { |row| unwrap(row, drop_null: drop_null) }
|
|
42
|
+
out[key] = unwrapped unless drop_null && unwrapped.empty?
|
|
43
|
+
elsif field?(node)
|
|
44
|
+
next if drop_null && node["value"].nil?
|
|
45
|
+
|
|
46
|
+
out[key] = node["value"]
|
|
47
|
+
elsif node.is_a?(Hash)
|
|
48
|
+
out[key] = unwrap(node, drop_null: drop_null)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Hash, nil] the wrapped scalar, or nil when the result has no such field
|
|
54
|
+
def field(result, name)
|
|
55
|
+
node = (result || {})[name.to_s]
|
|
56
|
+
field?(node) ? node : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A scalar field's value directly.
|
|
60
|
+
#
|
|
61
|
+
# Returns +default+ when the field is absent from the schema *or* present with a null
|
|
62
|
+
# value — collapsing the two on purpose, for the common case where you only want the
|
|
63
|
+
# number.
|
|
64
|
+
def value(result, name, default = nil)
|
|
65
|
+
node = field(result, name)
|
|
66
|
+
node.nil? || node["value"].nil? ? default : node["value"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Array<Hash>] a line-item array; +[]+ when absent or the document had no lines
|
|
70
|
+
def rows(result, name)
|
|
71
|
+
node = (result || {})[name.to_s]
|
|
72
|
+
rows?(node) ? node : []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Array<String>] the fields the document actually carried
|
|
76
|
+
def present(result)
|
|
77
|
+
(result || {}).filter_map do |key, node|
|
|
78
|
+
if rows?(node)
|
|
79
|
+
key unless node.empty?
|
|
80
|
+
elsif field?(node)
|
|
81
|
+
key unless node["value"].nil?
|
|
82
|
+
else
|
|
83
|
+
key
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Array<String>] the fields that came back empty
|
|
89
|
+
def missing(result)
|
|
90
|
+
found = present(result)
|
|
91
|
+
(result || {}).keys.reject { |key| found.include?(key) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Boolean] whether the field's confidence is at least +level+
|
|
95
|
+
def at_least?(node, level)
|
|
96
|
+
return false unless field?(node)
|
|
97
|
+
|
|
98
|
+
ORDER.fetch(node["confidence"], 0) >= ORDER.fetch(level.to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The scalar fields found but below +level+ — your review queue.
|
|
102
|
+
#
|
|
103
|
+
# Extract at the default <tt>min_confidence: "low"</tt> so nothing is silently dropped,
|
|
104
|
+
# then route what came back weak to a human instead of trusting it.
|
|
105
|
+
def below_confidence(result, level)
|
|
106
|
+
(result || {}).filter_map do |key, node|
|
|
107
|
+
key if field?(node) && !node["value"].nil? && !at_least?(node, level)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module VisionAPI
|
|
7
|
+
# Webhook signature verification.
|
|
8
|
+
#
|
|
9
|
+
# The delivery carries <tt>X-Vision-Signature: t=<unix seconds>,v1=<hex hmac></tt>, where
|
|
10
|
+
# the HMAC is <tt>HMAC-SHA256(secret, "{t}.{raw body}")</tt>. Two details are
|
|
11
|
+
# load-bearing:
|
|
12
|
+
#
|
|
13
|
+
# 1. The HMAC covers the *exact bytes received*. Verify before parsing, and never
|
|
14
|
+
# re-serialize the JSON first — a re-encode changes key order and whitespace, and the
|
|
15
|
+
# signature stops matching for reasons that look like a bug in this library.
|
|
16
|
+
# 2. After a secret rotation the header carries *several* +v1=+ parts, one per valid
|
|
17
|
+
# secret, for a 24-hour grace period. Accept the delivery if any of them matches.
|
|
18
|
+
module Webhook
|
|
19
|
+
DEFAULT_TOLERANCE = 300
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Verifies a delivery and returns its parsed payload.
|
|
24
|
+
#
|
|
25
|
+
# # Rails: config.middleware handles the raw body for you via request.raw_post
|
|
26
|
+
# def vision_hook
|
|
27
|
+
# event = VisionAPI::Webhook.verify(
|
|
28
|
+
# request.raw_post, request.headers["X-Vision-Signature"], ENV["VISION_WEBHOOK_SECRET"]
|
|
29
|
+
# )
|
|
30
|
+
# VisionResultJob.perform_later(event)
|
|
31
|
+
# head :accepted # any 2xx is success — ack fast, work after
|
|
32
|
+
# rescue VisionAPI::WebhookSignatureError
|
|
33
|
+
# head :bad_request # never parse an unverified body
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @param payload [String] the bytes exactly as received, untouched
|
|
37
|
+
# @param signature [String, nil] the +X-Vision-Signature+ header
|
|
38
|
+
# @param secret [String] your signing secret, from https://app.visionapi.io/dashboard/webhooks
|
|
39
|
+
# @param tolerance [Integer] how far the timestamp may be from now, in seconds. This is
|
|
40
|
+
# what stops a captured delivery being replayed later. Pass 0 to skip the check.
|
|
41
|
+
# @raise [WebhookSignatureError] the delivery cannot be trusted
|
|
42
|
+
# @return [Hash] the parsed event
|
|
43
|
+
def verify(payload, signature, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
44
|
+
raise WebhookSignatureError, "No signing secret provided." if secret.nil? || secret.empty?
|
|
45
|
+
raise WebhookSignatureError, "Missing X-Vision-Signature header." if signature.nil? || signature.empty?
|
|
46
|
+
|
|
47
|
+
timestamp = nil
|
|
48
|
+
candidates = []
|
|
49
|
+
signature.split(",").each do |part|
|
|
50
|
+
key, _, value = part.strip.partition("=")
|
|
51
|
+
case key
|
|
52
|
+
when "t" then timestamp = value
|
|
53
|
+
when "v1" then candidates << value unless value.empty?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if timestamp.nil? || timestamp.empty? || candidates.empty?
|
|
58
|
+
raise WebhookSignatureError, "Malformed X-Vision-Signature header."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sent_at = Integer(timestamp, exception: false)
|
|
62
|
+
raise WebhookSignatureError, "Malformed timestamp in X-Vision-Signature." if sent_at.nil?
|
|
63
|
+
|
|
64
|
+
if tolerance.positive?
|
|
65
|
+
drift = ((now || Time.now).to_i - sent_at).abs
|
|
66
|
+
if drift > tolerance
|
|
67
|
+
raise WebhookSignatureError,
|
|
68
|
+
"Delivery timestamp is #{drift}s away from now, outside the #{tolerance}s tolerance."
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
body = payload.to_s.b
|
|
73
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.".b + body)
|
|
74
|
+
|
|
75
|
+
# Rotation sends one v1= per valid secret. secure_compare keeps each comparison
|
|
76
|
+
# constant-time; checking them all rather than stopping at the first match is the point.
|
|
77
|
+
matched = candidates.map { |candidate| secure_compare(expected, candidate) }.any?
|
|
78
|
+
raise WebhookSignatureError, "Signature does not match the request body." unless matched
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
JSON.parse(body)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
raise WebhookSignatureError, "Delivery body is not valid JSON."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @api private
|
|
88
|
+
def secure_compare(a, b)
|
|
89
|
+
return false unless a.bytesize == b.bytesize
|
|
90
|
+
|
|
91
|
+
OpenSSL.secure_compare(a, b)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/vision_api.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "vision_api/version"
|
|
4
|
+
require_relative "vision_api/errors"
|
|
5
|
+
require_relative "vision_api/multipart"
|
|
6
|
+
require_relative "vision_api/result"
|
|
7
|
+
require_relative "vision_api/webhook"
|
|
8
|
+
require_relative "vision_api/client"
|
|
9
|
+
|
|
10
|
+
# Official Ruby client for the Vision API — credit-based OCR and visual intelligence.
|
|
11
|
+
#
|
|
12
|
+
# Send an image or a PDF, describe the fields you want in plain language, and get
|
|
13
|
+
# structured JSON back with a confidence level on every value.
|
|
14
|
+
#
|
|
15
|
+
# require "vision_api"
|
|
16
|
+
#
|
|
17
|
+
# vision = VisionAPI.new # reads ENV["VISION_API_KEY"]
|
|
18
|
+
# res = vision.analyze(file: "invoice.pdf", preset: "invoice")
|
|
19
|
+
# res["result"]["invoice_id"]["value"] # => "A-10422"
|
|
20
|
+
#
|
|
21
|
+
# - Website: https://visionapi.io
|
|
22
|
+
# - Documentation: https://docs.visionapi.io
|
|
23
|
+
# - API keys: https://app.visionapi.io/dashboard/keys
|
|
24
|
+
module VisionAPI
|
|
25
|
+
# Builds a {Client}. Everything {Client#initialize} accepts is accepted here.
|
|
26
|
+
#
|
|
27
|
+
# @return [Client]
|
|
28
|
+
def self.new(**options)
|
|
29
|
+
Client.new(**options)
|
|
30
|
+
end
|
|
31
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: vision_api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vision API
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rubocop
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.60'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.60'
|
|
55
|
+
description: |
|
|
56
|
+
Send an image or a PDF, describe the fields you want in plain language, and get
|
|
57
|
+
structured JSON back with a confidence level on every value. Presets for invoices,
|
|
58
|
+
receipts, IDs and 25 more; custom schemas; visual Q&A; async tasks and signed webhooks.
|
|
59
|
+
email:
|
|
60
|
+
- support@visionapi.io
|
|
61
|
+
executables: []
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- CHANGELOG.md
|
|
66
|
+
- LICENSE
|
|
67
|
+
- README.md
|
|
68
|
+
- lib/vision_api.rb
|
|
69
|
+
- lib/vision_api/client.rb
|
|
70
|
+
- lib/vision_api/errors.rb
|
|
71
|
+
- lib/vision_api/multipart.rb
|
|
72
|
+
- lib/vision_api/result.rb
|
|
73
|
+
- lib/vision_api/version.rb
|
|
74
|
+
- lib/vision_api/webhook.rb
|
|
75
|
+
homepage: https://visionapi.io
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata:
|
|
79
|
+
homepage_uri: https://visionapi.io
|
|
80
|
+
documentation_uri: https://docs.visionapi.io
|
|
81
|
+
source_code_uri: https://github.com/devrobotlabs/visionapi-ruby
|
|
82
|
+
bug_tracker_uri: https://github.com/devrobotlabs/visionapi-ruby/issues
|
|
83
|
+
changelog_uri: https://github.com/devrobotlabs/visionapi-ruby/blob/main/CHANGELOG.md
|
|
84
|
+
rubygems_mfa_required: 'true'
|
|
85
|
+
post_install_message:
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: 3.0.0
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubygems_version: 3.5.22
|
|
101
|
+
signing_key:
|
|
102
|
+
specification_version: 4
|
|
103
|
+
summary: Official Ruby client for the Vision API — OCR and visual intelligence.
|
|
104
|
+
test_files: []
|