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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoist
|
|
4
|
+
# A downloaded file: an original document or bill, exactly as it was added.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] data
|
|
7
|
+
# @return [String] the bytes (binary encoding)
|
|
8
|
+
# @!attribute [r] content_type
|
|
9
|
+
# @return [String] e.g. `application/pdf`
|
|
10
|
+
# @!attribute [r] filename
|
|
11
|
+
# @return [String, nil] the name from `Content-Disposition`, when sent
|
|
12
|
+
Download = Data.define(:data, :content_type, :filename) do
|
|
13
|
+
# @return [Integer] the size in bytes
|
|
14
|
+
def size
|
|
15
|
+
data.bytesize
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Writes the bytes to a file.
|
|
19
|
+
#
|
|
20
|
+
# @param path [String, #to_path]
|
|
21
|
+
# @return [Integer] bytes written
|
|
22
|
+
def save(path)
|
|
23
|
+
File.binwrite(path, data)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String]
|
|
27
|
+
def inspect
|
|
28
|
+
"#<#{self.class.name} content_type=#{content_type.inspect} filename=#{filename.inspect} size=#{size}>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @api private
|
|
32
|
+
# @param header [String, nil] a Content-Disposition value
|
|
33
|
+
# @return [String, nil]
|
|
34
|
+
def self.filename_from(header)
|
|
35
|
+
return nil if header.nil?
|
|
36
|
+
|
|
37
|
+
if (encoded = header[/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/, 1])
|
|
38
|
+
return URI.decode_www_form_component(encoded.strip.gsub("+", "%2B")).force_encoding(Encoding::UTF_8)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
header[/filename\s*=\s*"((?:[^"\\]|\\.)*)"/, 1]&.gsub(/\\(.)/, '\1') || header[/filename\s*=\s*([^;\s]+)/, 1]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Invoist
|
|
6
|
+
# Every failure the client raises. Branch on {#code}, a stable identifier;
|
|
7
|
+
# {#message} is for people and its wording can change.
|
|
8
|
+
#
|
|
9
|
+
# begin
|
|
10
|
+
# client.bills.mark_paid(id)
|
|
11
|
+
# rescue Invoist::Error => e
|
|
12
|
+
# raise unless e.permission_error?
|
|
13
|
+
# warn "this key may only read bills (#{e.code})"
|
|
14
|
+
# end
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
# @return [Integer] the HTTP status, or 0 when no response arrived
|
|
17
|
+
attr_reader :status
|
|
18
|
+
# @return [String] a stable machine code, e.g. `invalid_api_key`,
|
|
19
|
+
# `not_found`, `network_error`, `timeout`, `unexpected_response`
|
|
20
|
+
attr_reader :code
|
|
21
|
+
# @return [String, nil] the `X-Request-Id` response header; quote it to support
|
|
22
|
+
attr_reader :request_id
|
|
23
|
+
# @return [String, nil] the raw response body, when there was one
|
|
24
|
+
attr_reader :body
|
|
25
|
+
# @return [Integer, nil] seconds from the `Retry-After` header, when sent
|
|
26
|
+
attr_reader :retry_after
|
|
27
|
+
|
|
28
|
+
# @param message [String] human text
|
|
29
|
+
# @param status [Integer] HTTP status, 0 without a response
|
|
30
|
+
# @param code [String] stable machine code
|
|
31
|
+
# @param request_id [String, nil]
|
|
32
|
+
# @param body [String, nil]
|
|
33
|
+
# @param retry_after [Integer, nil]
|
|
34
|
+
def initialize(message, status:, code:, request_id: nil, body: nil, retry_after: nil)
|
|
35
|
+
super(message)
|
|
36
|
+
@status = status
|
|
37
|
+
@code = code
|
|
38
|
+
@request_id = request_id
|
|
39
|
+
@body = body
|
|
40
|
+
@retry_after = retry_after
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Builds the error for a non-2xx response from the API's
|
|
44
|
+
# `{"error":{"code","message"}}` envelope. The code is
|
|
45
|
+
# `unexpected_response` when the body is something else (a proxy's HTML
|
|
46
|
+
# page, an empty body) or the envelope has no code; the message is
|
|
47
|
+
# `Request failed with status <n>` when the API sent none.
|
|
48
|
+
#
|
|
49
|
+
# @param status [Integer]
|
|
50
|
+
# @param body [String, nil] the raw body
|
|
51
|
+
# @param headers [Hash{String => String}] response headers, lowercase names
|
|
52
|
+
# @return [Error]
|
|
53
|
+
def self.from_response(status:, body:, headers: {})
|
|
54
|
+
raw = body.nil? || body.empty? ? nil : body
|
|
55
|
+
code, message = envelope(raw)
|
|
56
|
+
new(message || "Request failed with status #{status}",
|
|
57
|
+
status: status,
|
|
58
|
+
code: code || "unexpected_response",
|
|
59
|
+
request_id: present(headers["x-request-id"]),
|
|
60
|
+
body: raw,
|
|
61
|
+
retry_after: integer_seconds(headers["retry-after"]))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @api private
|
|
65
|
+
# @return [Integer, nil] a non-negative integer number of seconds
|
|
66
|
+
def self.integer_seconds(value)
|
|
67
|
+
value = value.to_s.strip
|
|
68
|
+
value.match?(/\A\d+\z/) ? value.to_i : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @api private
|
|
72
|
+
def self.envelope(raw)
|
|
73
|
+
return nil if raw.nil?
|
|
74
|
+
|
|
75
|
+
detail = JSON.parse(raw.dup.force_encoding(Encoding::UTF_8)).then { |p| p["error"] if p.is_a?(Hash) }
|
|
76
|
+
return nil unless detail.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
# An envelope missing one key keeps the other.
|
|
79
|
+
[present(detail["code"]), present(detail["message"])]
|
|
80
|
+
rescue JSON::ParserError, EncodingError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
private_class_method :envelope
|
|
84
|
+
|
|
85
|
+
# @api private
|
|
86
|
+
def self.present(value)
|
|
87
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
88
|
+
end
|
|
89
|
+
private_class_method :present
|
|
90
|
+
|
|
91
|
+
# @return [Boolean] the key is missing, malformed or revoked (401)
|
|
92
|
+
def auth_error?
|
|
93
|
+
status == 401
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Boolean] the key is valid but lacks the scope, or the route
|
|
97
|
+
# belongs to the other kind of account (403)
|
|
98
|
+
def permission_error?
|
|
99
|
+
status == 403
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Boolean] no such resource (404)
|
|
103
|
+
def not_found?
|
|
104
|
+
status == 404
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Boolean] too many requests (429)
|
|
108
|
+
def rate_limited?
|
|
109
|
+
status == 429
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether sending the same request again could succeed: no response
|
|
113
|
+
# arrived, 429, or a server error. Never another 4xx.
|
|
114
|
+
#
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def retryable?
|
|
117
|
+
status.zero? || status == 429 || status >= 500
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [String]
|
|
121
|
+
def inspect
|
|
122
|
+
"#<#{self.class.name} status=#{status} code=#{code.inspect} message=#{message.inspect}" \
|
|
123
|
+
"#{" request_id=#{request_id.inspect}" if request_id}>"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/invoist/http.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module Invoist
|
|
10
|
+
# The request loop every method goes through: headers, encoding, the error
|
|
11
|
+
# envelope and retries.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class HTTP
|
|
15
|
+
# Failures where no response arrived at all.
|
|
16
|
+
TIMEOUT_ERRORS = [Timeout::Error].freeze
|
|
17
|
+
NETWORK_ERRORS = [SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::ProtocolError,
|
|
18
|
+
Net::HTTPBadResponse, Zlib::Error].freeze
|
|
19
|
+
|
|
20
|
+
BASE_DELAY = 0.5
|
|
21
|
+
MAX_DELAY = 8.0
|
|
22
|
+
JITTER = 0.2
|
|
23
|
+
# A Retry-After longer than this is not waited for: the call fails at once.
|
|
24
|
+
MAX_RETRY_AFTER = 60
|
|
25
|
+
|
|
26
|
+
attr_reader :base_url, :timeout, :max_retries
|
|
27
|
+
|
|
28
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:, transport:, sleeper:)
|
|
29
|
+
@api_key = api_key
|
|
30
|
+
@base_url = base_url
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
@transport = transport
|
|
34
|
+
@sleeper = sleeper
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sends a request and decodes its JSON answer.
|
|
38
|
+
#
|
|
39
|
+
# @return [Object, nil] the decoded body, nil for 204 or an empty body
|
|
40
|
+
def json(verb, path, query: nil, body: nil, headers: {})
|
|
41
|
+
response = send_request(verb, path, query: query, headers: headers,
|
|
42
|
+
body: body.nil? ? nil : [JSON.generate(body), "application/json"])
|
|
43
|
+
decode(response)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sends a multipart upload.
|
|
47
|
+
def multipart(path, form, headers: {})
|
|
48
|
+
decode(send_request("POST", path, body: [form.body, form.content_type], headers: headers))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fetches raw bytes.
|
|
52
|
+
#
|
|
53
|
+
# @return [Download]
|
|
54
|
+
def binary(path)
|
|
55
|
+
response = send_request("GET", path)
|
|
56
|
+
Download.new(data: response.body.b,
|
|
57
|
+
content_type: response.header("content-type") || "application/octet-stream",
|
|
58
|
+
filename: Download.filename_from(response.header("content-disposition")))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Waits, between polls or retries.
|
|
62
|
+
def sleep(seconds)
|
|
63
|
+
@sleeper.call(seconds)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The delay before retry number `attempt` (0-based): Retry-After when the
|
|
67
|
+
# response has it, otherwise 500ms x 2^attempt with ±20% jitter, at most 8s.
|
|
68
|
+
def retry_delay(attempt, response = nil)
|
|
69
|
+
seconds = Error.integer_seconds(response&.header("retry-after"))
|
|
70
|
+
return seconds.to_f if seconds
|
|
71
|
+
|
|
72
|
+
backoff = BASE_DELAY * (2**attempt) * (1 + (((rand * 2) - 1) * JITTER))
|
|
73
|
+
[backoff, MAX_DELAY].min
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def url(path, query = nil)
|
|
77
|
+
pairs = (query || {}).reject { |_, v| v.nil? || v == "" }
|
|
78
|
+
url = "#{@base_url}#{path}"
|
|
79
|
+
pairs.empty? ? url : "#{url}?#{URI.encode_www_form(pairs.map { |k, v| [k.to_s, v.to_s] })}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def send_request(verb, path, query: nil, body: nil, headers: {})
|
|
85
|
+
payload, content_type = body
|
|
86
|
+
request = Transport::Request.new(http_method: verb, url: url(path, query), timeout: @timeout, body: payload,
|
|
87
|
+
headers: request_headers(headers, content_type))
|
|
88
|
+
attempt = 0
|
|
89
|
+
loop do
|
|
90
|
+
response, error, cause = attempt_once(request)
|
|
91
|
+
return response if error.nil?
|
|
92
|
+
raise error, cause: cause unless retry?(error, attempt)
|
|
93
|
+
|
|
94
|
+
sleep(retry_delay(attempt, response))
|
|
95
|
+
attempt += 1
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def retry?(error, attempt)
|
|
100
|
+
return false unless error.retryable? && attempt < @max_retries
|
|
101
|
+
|
|
102
|
+
error.retry_after.nil? || error.retry_after <= MAX_RETRY_AFTER
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Array(Transport::Response, Error, Exception)] the response, the
|
|
106
|
+
# error it means (nil on success), and the low-level cause
|
|
107
|
+
def attempt_once(request)
|
|
108
|
+
response = normalize(@transport.call(request))
|
|
109
|
+
return [response, nil, nil] if (200..299).cover?(response.status)
|
|
110
|
+
|
|
111
|
+
[response, Error.from_response(status: response.status, body: response.body,
|
|
112
|
+
headers: { "x-request-id" => response.header("x-request-id"),
|
|
113
|
+
"retry-after" => response.header("retry-after") }), nil]
|
|
114
|
+
rescue *TIMEOUT_ERRORS => e
|
|
115
|
+
[nil, Error.new("Request timed out after #{@timeout}s (#{e.class})", status: 0, code: "timeout"), e]
|
|
116
|
+
rescue *NETWORK_ERRORS => e
|
|
117
|
+
[nil, Error.new("Network error: #{e.message} (#{e.class})", status: 0, code: "network_error"), e]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalize(response)
|
|
121
|
+
return response if response.is_a?(Transport::Response)
|
|
122
|
+
|
|
123
|
+
Transport::Response.new(status: Integer(response.status), headers: response.headers.to_h,
|
|
124
|
+
body: response.body.to_s)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def request_headers(extra, content_type)
|
|
128
|
+
headers = {
|
|
129
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
130
|
+
"Accept" => "application/json",
|
|
131
|
+
"User-Agent" => USER_AGENT
|
|
132
|
+
}
|
|
133
|
+
headers["Content-Type"] = content_type if content_type
|
|
134
|
+
headers.merge(extra)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def decode(response)
|
|
138
|
+
return nil if response.status == 204 || response.body.empty?
|
|
139
|
+
|
|
140
|
+
JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
raise Error.new("The API answered #{response.status} with a body that is not JSON",
|
|
143
|
+
status: response.status, code: "unexpected_response", body: response.body,
|
|
144
|
+
request_id: response.header("x-request-id"))
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoist
|
|
4
|
+
# Behaviour shared by every response type in {Invoist::Models}.
|
|
5
|
+
#
|
|
6
|
+
# Models are immutable `Data` objects built from the API's JSON with
|
|
7
|
+
# {ClassMethods#from_h}. Fields read like methods (`bill.total_cents`), and
|
|
8
|
+
# `bill[:total_cents]`, {#to_h} and {#to_json} are there for code that
|
|
9
|
+
# wants a Hash.
|
|
10
|
+
module Model
|
|
11
|
+
# @api private
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Turns a JSON value into the type a field declares.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
# @param value [Object] the decoded JSON
|
|
20
|
+
# @param type [Class, Array(Class), nil] a model class, `[model class]` for a list, or nil for plain JSON
|
|
21
|
+
# @return [Object]
|
|
22
|
+
def self.coerce(value, type)
|
|
23
|
+
case type
|
|
24
|
+
when nil then value
|
|
25
|
+
when Array then value.is_a?(Array) ? value.map { |v| coerce(v, type.first) } : value
|
|
26
|
+
else value.is_a?(Hash) ? type.from_h(value) : value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @api private
|
|
31
|
+
def self.dump(value)
|
|
32
|
+
case value
|
|
33
|
+
when Model then value.to_h
|
|
34
|
+
when Array then value.map { |v| dump(v) }
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Class methods of every model.
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Builds the model from decoded JSON. Missing fields are nil, unknown
|
|
42
|
+
# ones are ignored: the API adds fields over time.
|
|
43
|
+
#
|
|
44
|
+
# @param hash [Hash{String, Symbol => Object}, nil]
|
|
45
|
+
# @return [self, nil] nil when `hash` is nil
|
|
46
|
+
# @raise [TypeError] when `hash` is not a Hash
|
|
47
|
+
def from_h(hash)
|
|
48
|
+
return nil if hash.nil?
|
|
49
|
+
raise TypeError, "#{name} is built from a Hash, not #{hash.class}" unless hash.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
types = nested_types
|
|
52
|
+
attrs = members.to_h do |member|
|
|
53
|
+
key = member.to_s
|
|
54
|
+
value = hash.key?(key) ? hash[key] : hash[member]
|
|
55
|
+
[member, Model.coerce(value, types[member])]
|
|
56
|
+
end
|
|
57
|
+
new(**attrs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Fields holding other models.
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
# @return [Hash{Symbol => Object}]
|
|
64
|
+
def nested_types
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reads a field by name, like a Hash.
|
|
70
|
+
#
|
|
71
|
+
# @param key [Symbol, String]
|
|
72
|
+
# @return [Object, nil] nil for a name the model does not have
|
|
73
|
+
def [](key)
|
|
74
|
+
name = key.to_sym
|
|
75
|
+
self.class.members.include?(name) ? public_send(name) : nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The model as a Hash with Symbol keys, nested models included.
|
|
79
|
+
#
|
|
80
|
+
# @return [Hash{Symbol => Object}]
|
|
81
|
+
def to_h(&)
|
|
82
|
+
# self.class.members: a field may itself be called `members`.
|
|
83
|
+
hash = self.class.members.to_h { |member| [member, Model.dump(public_send(member))] }
|
|
84
|
+
block_given? ? hash.to_h(&) : hash
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [String] the model as JSON, with the API's field names
|
|
88
|
+
def to_json(*)
|
|
89
|
+
to_h.to_json(*)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|