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,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
@@ -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