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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +191 -0
- data/SECURITY.md +10 -0
- data/lib/invoist/bills.rb +132 -0
- data/lib/invoist/client.rb +285 -0
- data/lib/invoist/download.rb +44 -0
- data/lib/invoist/error.rb +126 -0
- data/lib/invoist/http.rb +147 -0
- data/lib/invoist/model.rb +92 -0
- data/lib/invoist/models.rb +1598 -0
- data/lib/invoist/params.rb +62 -0
- data/lib/invoist/transport.rb +80 -0
- data/lib/invoist/upload.rb +148 -0
- data/lib/invoist/version.rb +6 -0
- data/lib/invoist/webhook.rb +155 -0
- data/lib/invoist.rb +30 -0
- data/sig/invoist/models.rbs +772 -0
- data/sig/invoist.rbs +273 -0
- metadata +68 -0
|
@@ -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,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
|