invoist 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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoist
4
+ # Formats method arguments for the query string.
5
+ #
6
+ # @api private
7
+ module Params
8
+ module_function
9
+
10
+ # @return [String, nil] `"true"` or `"false"`
11
+ def boolean(name, value)
12
+ case value
13
+ when nil then nil
14
+ when true then "true"
15
+ when false then "false"
16
+ else raise ArgumentError, "#{name} must be true or false, not #{value.inspect}"
17
+ end
18
+ end
19
+
20
+ # @return [String, nil] `YYYY-MM`, from a String, Date or Time (in local time)
21
+ def month(name, value)
22
+ return nil if value.nil?
23
+ return local(value).strftime("%Y-%m") if value.respond_to?(:strftime)
24
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
25
+
26
+ raise ArgumentError, "#{name} must be a Date or a \"YYYY-MM\" string, not #{value.inspect}"
27
+ end
28
+
29
+ # @return [String, nil] `YYYY-MM-DD`, from a String, Date or Time (in local time)
30
+ def date(name, value)
31
+ return nil if value.nil?
32
+ return local(value).strftime("%Y-%m-%d") if value.respond_to?(:strftime)
33
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
34
+
35
+ raise ArgumentError, "#{name} must be a Date or a \"YYYY-MM-DD\" string, not #{value.inspect}"
36
+ end
37
+
38
+ # A Time or DateTime moved to the local zone, so it names the calendar
39
+ # day the caller sees; a Date is already a calendar day.
40
+ def local(value)
41
+ value.respond_to?(:hour) && value.respond_to?(:to_time) ? value.to_time.getlocal : value
42
+ end
43
+
44
+ # @return [Integer, nil]
45
+ def integer(name, value)
46
+ return nil if value.nil?
47
+ raise ArgumentError, "#{name} must be an Integer, not #{value.inspect}" unless value.is_a?(Integer)
48
+
49
+ value
50
+ end
51
+
52
+ # A path segment, percent-encoded.
53
+ #
54
+ # @return [String]
55
+ def id(value)
56
+ value = value.to_s
57
+ raise ArgumentError, "an id is required" if value.empty?
58
+
59
+ value.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Invoist
8
+ # The HTTP layer, kept apart so it can be swapped: pass any object with
9
+ # `call(request) -> response` as `transport:` to {Client#initialize}, for
10
+ # tests or for an HTTP library of your choice.
11
+ #
12
+ # A transport sends one request and returns whatever status came back. It
13
+ # raises on failures without a response (connection refused, DNS, TLS,
14
+ # timeouts: `Timeout::Error`, `SystemCallError`, `SocketError`, `IOError`,
15
+ # `OpenSSL::SSL::SSLError`); the client turns those into {Invoist::Error}
16
+ # and decides whether to retry.
17
+ module Transport
18
+ # One HTTP request.
19
+ #
20
+ # @!attribute [r] http_method
21
+ # @return [String] `GET`, `POST` or `DELETE`
22
+ # @!attribute [r] url
23
+ # @return [String] the absolute URL, query string included
24
+ # @!attribute [r] headers
25
+ # @return [Hash{String => String}]
26
+ # @!attribute [r] body
27
+ # @return [String, nil] the encoded body (binary for uploads)
28
+ # @!attribute [r] timeout
29
+ # @return [Numeric] seconds allowed to connect, and for each read and write
30
+ Request = Data.define(:http_method, :url, :headers, :body, :timeout)
31
+
32
+ # One HTTP response.
33
+ #
34
+ # @!attribute [r] status
35
+ # @return [Integer]
36
+ # @!attribute [r] headers
37
+ # @return [Hash{String => String}] header names in any case
38
+ # @!attribute [r] body
39
+ # @return [String] the raw body, empty when there was none
40
+ Response = Data.define(:status, :headers, :body) do
41
+ # @param name [String] a header name, in any case
42
+ # @return [String, nil]
43
+ def header(name)
44
+ wanted = name.downcase
45
+ headers.each { |key, value| return Array(value).join(", ") if key.to_s.downcase == wanted }
46
+ nil
47
+ end
48
+ end
49
+
50
+ # The default transport, on the standard library's Net::HTTP. It honours
51
+ # the usual `http_proxy`/`https_proxy`/`no_proxy` environment variables
52
+ # and verifies TLS certificates.
53
+ class NetHTTP
54
+ # Sends the request on a fresh connection.
55
+ #
56
+ # @param request [Request]
57
+ # @return [Response]
58
+ def call(request)
59
+ uri = URI(request.url)
60
+ req = Net::HTTPGenericRequest.new(request.http_method, !request.body.nil?, true, uri.request_uri,
61
+ request.headers)
62
+ req.body = request.body unless request.body.nil?
63
+ res = connection(uri, request.timeout).start { |conn| conn.request(req) }
64
+ Response.new(status: res.code.to_i, headers: res.each_header.to_h, body: res.body.to_s)
65
+ end
66
+
67
+ private
68
+
69
+ def connection(uri, timeout)
70
+ http = Net::HTTP.new(uri.host, uri.port)
71
+ http.use_ssl = uri.scheme == "https"
72
+ http.open_timeout = timeout
73
+ http.read_timeout = timeout
74
+ http.write_timeout = timeout
75
+ http.ssl_timeout = timeout
76
+ http
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Invoist
6
+ # A file to upload: bytes, the filename Invoist shows, and a content type.
7
+ #
8
+ # Methods that take a file accept an Upload or anything {Upload.wrap}
9
+ # understands: a path (String or Pathname), an open File or other IO, a
10
+ # Rails/Rack uploaded file, or a Hash `{data:, filename:, content_type:}`.
11
+ #
12
+ # client.extract("invoice.pdf")
13
+ # client.extract(File.open("scan.jpg", "rb"))
14
+ # client.extract(Invoist::Upload.new(pdf_bytes, filename: "invoice.pdf"))
15
+ # client.extract(params[:file]) # in a Rails controller
16
+ class Upload
17
+ # Content types guessed from a filename's extension, when none is given.
18
+ CONTENT_TYPES = {
19
+ ".pdf" => "application/pdf",
20
+ ".png" => "image/png",
21
+ ".jpg" => "image/jpeg",
22
+ ".jpeg" => "image/jpeg",
23
+ ".webp" => "image/webp",
24
+ ".heic" => "image/heic",
25
+ ".tif" => "image/tiff",
26
+ ".tiff" => "image/tiff"
27
+ }.freeze
28
+
29
+ # @return [String] the bytes, binary encoded
30
+ attr_reader :data
31
+ # @return [String]
32
+ attr_reader :filename
33
+ # @return [String]
34
+ attr_reader :content_type
35
+
36
+ # @param data [String] the file's bytes
37
+ # @param filename [String] the name Invoist records and shows
38
+ # @param content_type [String, nil] guessed from the filename when nil
39
+ def initialize(data, filename:, content_type: nil)
40
+ raise ArgumentError, "filename is required" if filename.nil? || filename.to_s.empty?
41
+
42
+ @data = data.to_s.b
43
+ @filename = filename.to_s
44
+ @content_type = content_type || self.class.guess_content_type(@filename)
45
+ end
46
+
47
+ # Turns any supported file input into an Upload, reading it into memory
48
+ # so a retried request can send it again.
49
+ #
50
+ # @param input [Upload, String, Pathname, IO, #read, Hash]
51
+ # @return [Upload]
52
+ # @raise [ArgumentError] for a path that is not a file
53
+ # @raise [TypeError] for anything else
54
+ def self.wrap(input)
55
+ case input
56
+ when Upload then input
57
+ when Hash then from_hash(input)
58
+ when String then from_path(input)
59
+ else
60
+ if defined?(::Pathname) && input.is_a?(::Pathname) then from_path(input)
61
+ elsif input.respond_to?(:read) then from_io(input)
62
+ elsif input.respond_to?(:to_path) then from_path(input.to_path)
63
+ else
64
+ raise TypeError, "can't upload a #{input.class}: pass a path, an IO, or Invoist::Upload.new(bytes, filename:)"
65
+ end
66
+ end
67
+ end
68
+
69
+ # @api private
70
+ def self.guess_content_type(filename)
71
+ CONTENT_TYPES.fetch(File.extname(filename).downcase, "application/octet-stream")
72
+ end
73
+
74
+ # @api private
75
+ def self.from_hash(hash)
76
+ hash = hash.transform_keys(&:to_sym)
77
+ data = hash.key?(:data) ? hash[:data] : hash[:io]&.read
78
+ raise ArgumentError, "an upload Hash needs :data (or :io) and :filename" if data.nil?
79
+
80
+ new(data, filename: hash[:filename], content_type: hash[:content_type])
81
+ end
82
+ private_class_method :from_hash
83
+
84
+ # @api private
85
+ def self.from_path(path)
86
+ path = path.to_s
87
+ raise ArgumentError, "no such file: #{path} (for bytes, use Invoist::Upload.new(data, filename:))" \
88
+ unless File.file?(path)
89
+
90
+ new(File.binread(path), filename: File.basename(path))
91
+ end
92
+ private_class_method :from_path
93
+
94
+ # @api private
95
+ def self.from_io(io)
96
+ io.binmode if io.respond_to?(:binmode)
97
+ filename = io_filename(io)
98
+ content_type = io.content_type if io.respond_to?(:content_type)
99
+ new(io.read, filename: filename, content_type: content_type.is_a?(String) ? content_type : nil)
100
+ end
101
+ private_class_method :from_io
102
+
103
+ # @api private
104
+ def self.io_filename(io)
105
+ name = io.original_filename if io.respond_to?(:original_filename)
106
+ name ||= File.basename(io.path) if io.respond_to?(:path) && io.path.is_a?(String)
107
+ name.nil? || name.empty? ? "upload" : name
108
+ end
109
+ private_class_method :io_filename
110
+
111
+ # @return [String]
112
+ def inspect
113
+ "#<#{self.class.name} filename=#{filename.inspect} content_type=#{content_type.inspect} " \
114
+ "size=#{data.bytesize}>"
115
+ end
116
+ end
117
+
118
+ # A `multipart/form-data` body, encoded in memory.
119
+ #
120
+ # @api private
121
+ class Multipart
122
+ attr_reader :body, :content_type
123
+
124
+ # @param parts [Array(String, Upload)] field name and file, in order
125
+ def initialize(parts)
126
+ boundary = "invoist-#{SecureRandom.hex(16)}"
127
+ @content_type = "multipart/form-data; boundary=#{boundary}"
128
+ body = String.new(encoding: Encoding::BINARY)
129
+ parts.each do |name, upload|
130
+ body << "--#{boundary}\r\n"
131
+ body << "Content-Disposition: form-data; name=\"#{escape(name)}\"; " \
132
+ "filename=\"#{escape(upload.filename)}\"\r\n".b
133
+ body << "Content-Type: #{upload.content_type}\r\n\r\n"
134
+ body << upload.data << "\r\n"
135
+ end
136
+ body << "--#{boundary}--\r\n"
137
+ @body = body.freeze
138
+ end
139
+
140
+ private
141
+
142
+ # Quotes and line breaks are percent-encoded, as browsers do, so a
143
+ # filename can't break out of its header.
144
+ def escape(value)
145
+ value.to_s.gsub("\"", "%22").gsub("\r", "%0D").gsub("\n", "%0A")
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoist
4
+ # The gem's version, shared by every official Invoist SDK.
5
+ VERSION = "1.0.0"
6
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Invoist
7
+ # Checks that a webhook really came from Invoist.
8
+ #
9
+ # Every delivery carries an `X-Invoist-Signature` header,
10
+ # `t=<unix seconds>,v1=<hex HMAC-SHA256>`, signed over `"<t>.<raw body>"`
11
+ # with the endpoint's secret. Verify it before trusting anything in the
12
+ # body: an endpoint that skips this accepts whatever anyone posts to it.
13
+ #
14
+ # Pass the raw request body, byte for byte. Parsing the JSON and
15
+ # serialising it again changes it, and the signature no longer matches:
16
+ #
17
+ # # Rails
18
+ # event = Invoist::Webhook.verify(request.raw_post,
19
+ # request.headers["X-Invoist-Signature"],
20
+ # ENV.fetch("INVOIST_WEBHOOK_SECRET"))
21
+ module Webhook
22
+ # A document was added.
23
+ DOCUMENT_CREATED = "document.created"
24
+ # A document was read.
25
+ EXTRACTION_COMPLETED = "extraction.completed"
26
+ # Reading a document failed.
27
+ EXTRACTION_FAILED = "extraction.failed"
28
+ # Something was flagged for a person to check.
29
+ REVIEW_ITEM_CREATED = "review_item.created"
30
+ # A flagged item was resolved or dismissed.
31
+ REVIEW_ITEM_RESOLVED = "review_item.resolved"
32
+ # Someone sent a document that was asked for.
33
+ DOCUMENT_REQUEST_FULFILLED = "document_request.fulfilled"
34
+ # Every event type.
35
+ EVENT_TYPES = [DOCUMENT_CREATED, EXTRACTION_COMPLETED, EXTRACTION_FAILED, REVIEW_ITEM_CREATED,
36
+ REVIEW_ITEM_RESOLVED, DOCUMENT_REQUEST_FULFILLED].freeze
37
+
38
+ # The header with the signature.
39
+ SIGNATURE_HEADER = "X-Invoist-Signature"
40
+ # The header with the event's type.
41
+ EVENT_HEADER = "X-Invoist-Event"
42
+ # The header with the delivery's id, the same on every retry of it.
43
+ DELIVERY_HEADER = "X-Invoist-Delivery"
44
+ # How old (or how far ahead) a signature may be, in seconds.
45
+ DEFAULT_TOLERANCE = 300
46
+
47
+ # A verified event.
48
+ #
49
+ # @!attribute [r] id
50
+ # @return [String] the event's id; deduplicate on it
51
+ # @!attribute [r] type
52
+ # @return [String] one of {EVENT_TYPES}, or a newer one
53
+ # @!attribute [r] created_at
54
+ # @return [String] RFC 3339 time
55
+ # @!attribute [r] data
56
+ # @return [Hash{String => Object}] the event's payload, as sent
57
+ Event = Data.define(:id, :type, :created_at, :data) do
58
+ include Model
59
+ end
60
+
61
+ module_function
62
+
63
+ # Verifies a delivery and returns its event.
64
+ #
65
+ # @param payload [String, #read] the raw request body
66
+ # @param signature_header [String, nil] the `X-Invoist-Signature` value
67
+ # @param secret [String] the endpoint's signing secret
68
+ # @param tolerance [Integer] seconds `t` may differ from now, either way;
69
+ # 0 turns the check off
70
+ # @param now [Integer, Time, nil] the current time, for tests
71
+ # @return [Event]
72
+ # @raise [Error] status 400, code `invalid_signature` (missing or
73
+ # malformed header, or no matching signature) or `signature_too_old`
74
+ # (outside the tolerance)
75
+ # @raise [ArgumentError] when the secret is empty
76
+ def verify(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
77
+ raise ArgumentError, "a webhook secret is required" if secret.nil? || secret.to_s.empty?
78
+
79
+ body = raw_bytes(payload)
80
+ timestamp, signatures = parse_header(signature_header.to_s)
81
+ if timestamp.nil? || signatures.empty?
82
+ fail_with("invalid_signature", "The #{SIGNATURE_HEADER} header is missing or malformed")
83
+ end
84
+
85
+ expected = hmac(secret, timestamp, body)
86
+ matched = signatures.map { |candidate| secure_compare(expected, candidate) }.any?
87
+ fail_with("invalid_signature", "No signature matches the payload") unless matched
88
+
89
+ # The time only after the signature: a forged request is invalid however old it is.
90
+ if tolerance.positive? && ((now || Time.now).to_i - timestamp.to_i).abs > tolerance
91
+ fail_with("signature_too_old", "The signature's timestamp is outside the #{tolerance}s tolerance")
92
+ end
93
+
94
+ parse_event(body)
95
+ end
96
+
97
+ # Signs a payload the way Invoist does, to test your own endpoint.
98
+ #
99
+ # @param payload [String]
100
+ # @param secret [String]
101
+ # @param timestamp [Integer] Unix seconds
102
+ # @return [String] an `X-Invoist-Signature` header value
103
+ def sign(payload, secret, timestamp: Time.now.to_i)
104
+ "t=#{timestamp},v1=#{hmac(secret, timestamp, raw_bytes(payload))}"
105
+ end
106
+
107
+ # @api private
108
+ def raw_bytes(payload)
109
+ (payload.respond_to?(:read) ? payload.read : payload.to_s).b
110
+ end
111
+
112
+ # @api private
113
+ def hmac(secret, timestamp, body)
114
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s.b, "#{timestamp}.".b + body)
115
+ end
116
+
117
+ # @api private
118
+ # @return [Array(String, Array<String>), Array(nil, Array)] the timestamp and the v1 signatures
119
+ def parse_header(header)
120
+ timestamps = []
121
+ signatures = []
122
+ header.split(",").each do |part|
123
+ key, value = part.strip.split("=", 2)
124
+ next if value.nil? || value.empty?
125
+
126
+ timestamps << value if key == "t"
127
+ signatures << value if key == "v1"
128
+ end
129
+ timestamp = timestamps.first if timestamps.size == 1 && timestamps.first.match?(/\A\d+\z/)
130
+ [timestamp, signatures]
131
+ end
132
+
133
+ # @api private
134
+ def secure_compare(expected, candidate)
135
+ expected.bytesize == candidate.bytesize && OpenSSL.fixed_length_secure_compare(expected, candidate)
136
+ end
137
+
138
+ # @api private
139
+ def parse_event(body)
140
+ parsed = JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
141
+ raise JSON::ParserError, "not an event" unless parsed.is_a?(Hash) && parsed["type"].is_a?(String)
142
+
143
+ Event.from_h(parsed)
144
+ rescue JSON::ParserError, EncodingError
145
+ fail_with("invalid_payload", "The payload is signed but is not a JSON event")
146
+ end
147
+
148
+ # @api private
149
+ def fail_with(code, message)
150
+ raise Error.new(message, status: 400, code: code)
151
+ end
152
+
153
+ private_class_method :raw_bytes, :hmac, :parse_header, :secure_compare, :parse_event, :fail_with
154
+ end
155
+ end
data/lib/invoist.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "invoist/version"
4
+ require_relative "invoist/error"
5
+ require_relative "invoist/model"
6
+ require_relative "invoist/models"
7
+ require_relative "invoist/transport"
8
+ require_relative "invoist/download"
9
+ require_relative "invoist/upload"
10
+ require_relative "invoist/params"
11
+ require_relative "invoist/http"
12
+ require_relative "invoist/bills"
13
+ require_relative "invoist/client"
14
+ require_relative "invoist/webhook"
15
+
16
+ # The official Ruby client for the Invoist API.
17
+ #
18
+ # Start with {Invoist::Client}; check webhooks with {Invoist::Webhook.verify};
19
+ # every failure is an {Invoist::Error}. See https://invoi.st/docs.
20
+ module Invoist
21
+ # Sent with every request.
22
+ USER_AGENT = "invoist-ruby/#{VERSION} (ruby #{RUBY_VERSION})".freeze
23
+
24
+ # Shorthand for {Client#initialize}.
25
+ #
26
+ # @return [Client]
27
+ def self.new(...)
28
+ Client.new(...)
29
+ end
30
+ end