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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VisionAPI
4
+ VERSION = "1.0.0"
5
+ 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: []