nombaone 0.1.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 +36 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/nombaone/client.rb +158 -0
- data/lib/nombaone/errors.rb +286 -0
- data/lib/nombaone/internal/http_client.rb +230 -0
- data/lib/nombaone/internal/util.rb +155 -0
- data/lib/nombaone/object.rb +152 -0
- data/lib/nombaone/pagination.rb +116 -0
- data/lib/nombaone/resources/base_resource.rb +40 -0
- data/lib/nombaone/resources/coupons.rb +84 -0
- data/lib/nombaone/resources/customers.rb +160 -0
- data/lib/nombaone/resources/events.rb +46 -0
- data/lib/nombaone/resources/invoices.rb +61 -0
- data/lib/nombaone/resources/mandates.rb +71 -0
- data/lib/nombaone/resources/metrics.rb +24 -0
- data/lib/nombaone/resources/organization.rb +91 -0
- data/lib/nombaone/resources/payment_methods.rb +102 -0
- data/lib/nombaone/resources/plans.rb +129 -0
- data/lib/nombaone/resources/prices.rb +53 -0
- data/lib/nombaone/resources/sandbox.rb +81 -0
- data/lib/nombaone/resources/settlements.rb +91 -0
- data/lib/nombaone/resources/subscriptions.rb +310 -0
- data/lib/nombaone/resources/webhook_endpoints.rb +140 -0
- data/lib/nombaone/version.rb +7 -0
- data/lib/nombaone/webhook_event.rb +64 -0
- data/lib/nombaone/webhooks.rb +167 -0
- data/lib/nombaone.rb +58 -0
- data/sig/nombaone/client.rbs +36 -0
- data/sig/nombaone/errors.rbs +68 -0
- data/sig/nombaone/internal.rbs +51 -0
- data/sig/nombaone/object.rbs +22 -0
- data/sig/nombaone/pagination.rbs +23 -0
- data/sig/nombaone/resources.rbs +243 -0
- data/sig/nombaone/webhooks.rbs +18 -0
- data/sig/nombaone.rbs +24 -0
- metadata +87 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nombaone
|
|
4
|
+
# Every public error code the API can emit, vendored verbatim from the
|
|
5
|
+
# platform's `PUBLIC_ERROR_CODES`. Each constant's value is its own name
|
|
6
|
+
# (e.g. `Nombaone::ErrorCode::CUSTOMER_NOT_FOUND == "CUSTOMER_NOT_FOUND"`),
|
|
7
|
+
# so you can branch on `error.code == Nombaone::ErrorCode::CUSTOMER_NOT_FOUND`
|
|
8
|
+
# without memorizing strings.
|
|
9
|
+
#
|
|
10
|
+
# The list is a **closed catalog shipped open**: a code the API adds tomorrow
|
|
11
|
+
# is still parsed and surfaced on {APIError#code} as a plain string — it just
|
|
12
|
+
# will not have a named constant here yet, and never breaks your code today.
|
|
13
|
+
module ErrorCode
|
|
14
|
+
# The full public catalog, in wire order.
|
|
15
|
+
ALL = %w[
|
|
16
|
+
CLIENT_INVALID_REQUEST
|
|
17
|
+
CLIENT_VALIDATION_FAILED
|
|
18
|
+
CLIENT_FORBIDDEN
|
|
19
|
+
CLIENT_ROUTE_NOT_FOUND
|
|
20
|
+
CLIENT_RESOURCE_NOT_FOUND
|
|
21
|
+
CLIENT_CONFLICT
|
|
22
|
+
INVALID_CURSOR
|
|
23
|
+
API_KEY_MISSING
|
|
24
|
+
API_KEY_INVALID
|
|
25
|
+
API_KEY_SCOPE_FORBIDDEN
|
|
26
|
+
API_KEY_ENVIRONMENT_MISMATCH
|
|
27
|
+
API_KEY_HOST_MISMATCH
|
|
28
|
+
IDEMPOTENCY_KEY_MISSING
|
|
29
|
+
IDEMPOTENCY_KEY_REUSED
|
|
30
|
+
IDEMPOTENCY_IN_PROGRESS
|
|
31
|
+
RATE_LIMIT_EXCEEDED
|
|
32
|
+
PLATFORM_MAINTENANCE
|
|
33
|
+
WEBHOOK_SIGNATURE_INVALID
|
|
34
|
+
CUSTOMER_NOT_FOUND
|
|
35
|
+
CUSTOMER_EMAIL_TAKEN
|
|
36
|
+
PLAN_NOT_FOUND
|
|
37
|
+
PLAN_NAME_TAKEN
|
|
38
|
+
PLAN_ALREADY_ARCHIVED
|
|
39
|
+
PLAN_HAS_ACTIVE_SUBSCRIBERS
|
|
40
|
+
PRICE_NOT_FOUND
|
|
41
|
+
PRICE_PLAN_MISMATCH
|
|
42
|
+
PRICE_ALREADY_INACTIVE
|
|
43
|
+
PRICE_TIERED_NOT_SUPPORTED
|
|
44
|
+
PAYMENT_METHOD_NOT_FOUND
|
|
45
|
+
PAYMENT_METHOD_NOT_ACTIVE
|
|
46
|
+
PAYMENT_METHOD_KIND_MISMATCH
|
|
47
|
+
MANDATE_NOT_ACTIVE
|
|
48
|
+
MANDATE_MAX_AMOUNT_EXCEEDED
|
|
49
|
+
MANDATE_CONSENT_PENDING
|
|
50
|
+
SUBSCRIPTION_NOT_FOUND
|
|
51
|
+
SUBSCRIPTION_ILLEGAL_TRANSITION
|
|
52
|
+
SUBSCRIPTION_VERSION_CONFLICT
|
|
53
|
+
SUBSCRIPTION_NOT_TERMINAL
|
|
54
|
+
SUBSCRIPTION_PAYMENT_METHOD_REQUIRED
|
|
55
|
+
INVOICE_NOT_FOUND
|
|
56
|
+
INVOICE_ALREADY_FINALIZED
|
|
57
|
+
INVOICE_ALREADY_PAID
|
|
58
|
+
INVOICE_NOT_VOIDABLE
|
|
59
|
+
SUBSCRIPTION_SCHEDULE_NOT_FOUND
|
|
60
|
+
SUBSCRIPTION_SCHEDULE_CONFLICT
|
|
61
|
+
SUBSCRIPTION_SCHEDULE_INVALID_EFFECTIVE_AT
|
|
62
|
+
PRORATION_NOT_APPLICABLE
|
|
63
|
+
PRORATION_INTERVAL_SWITCH_UNSUPPORTED
|
|
64
|
+
COUPON_NOT_FOUND
|
|
65
|
+
COUPON_EXPIRED
|
|
66
|
+
COUPON_MAX_REDEMPTIONS_REACHED
|
|
67
|
+
COUPON_INVALID_DEFINITION
|
|
68
|
+
COUPON_ALREADY_APPLIED
|
|
69
|
+
DISCOUNT_NOT_FOUND
|
|
70
|
+
CREDIT_GRANT_NOT_FOUND
|
|
71
|
+
CREDIT_GRANT_ALREADY_VOIDED
|
|
72
|
+
CREDIT_INSUFFICIENT_BALANCE
|
|
73
|
+
CREDIT_INVALID_AMOUNT
|
|
74
|
+
DUNNING_NO_OPEN_INVOICE
|
|
75
|
+
DUNNING_ATTEMPT_NOT_FOUND
|
|
76
|
+
DUNNING_CARD_UPDATE_REQUIRED
|
|
77
|
+
DUNNING_ALREADY_TERMINAL
|
|
78
|
+
SETTLEMENT_NOT_FOUND
|
|
79
|
+
SETTLEMENT_SUBACCOUNT_NOT_FOUND
|
|
80
|
+
REFUND_ALREADY_REFUNDED
|
|
81
|
+
REFUND_AMOUNT_EXCEEDS_NET
|
|
82
|
+
ESCROW_LOCKED
|
|
83
|
+
PAYOUT_EXCEEDS_AVAILABLE
|
|
84
|
+
QUOTA_EXCEEDED
|
|
85
|
+
EXAMPLE_NOT_FOUND
|
|
86
|
+
SYSTEM_INTERNAL_ERROR
|
|
87
|
+
SYSTEM_UPSTREAM_ERROR
|
|
88
|
+
].freeze
|
|
89
|
+
|
|
90
|
+
ALL.each { |code| const_set(code, code) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Base class for everything this SDK raises — API failures, connection
|
|
94
|
+
# problems, webhook verification failures, and client misconfiguration.
|
|
95
|
+
# Rescue `Nombaone::Error` to catch anything the SDK can throw.
|
|
96
|
+
#
|
|
97
|
+
# @example
|
|
98
|
+
# begin
|
|
99
|
+
# nombaone.subscriptions.create(customer_id: cus, price_id: prc)
|
|
100
|
+
# rescue Nombaone::Error => e
|
|
101
|
+
# warn e.message
|
|
102
|
+
# end
|
|
103
|
+
class Error < StandardError; end
|
|
104
|
+
|
|
105
|
+
# A non-2xx response from the API. Carries everything the error envelope
|
|
106
|
+
# said: the stable {#code} to branch on, the {#hint} telling you how to fix
|
|
107
|
+
# it, the {#doc_url} into the error reference, per-field validation errors on
|
|
108
|
+
# 422s, and the {#request_id} to quote to support.
|
|
109
|
+
#
|
|
110
|
+
# Subclasses are keyed by HTTP status so `rescue` reads naturally:
|
|
111
|
+
# {AuthenticationError}, {RateLimitError}, {ValidationError}, ….
|
|
112
|
+
# Branch on {#code} (stable) or the class (by HTTP status), never on the
|
|
113
|
+
# message (it may be reworded).
|
|
114
|
+
class APIError < Error
|
|
115
|
+
# @return [Integer] HTTP status of the response.
|
|
116
|
+
attr_reader :status_code
|
|
117
|
+
# @return [String] stable machine-readable error code — branch on this.
|
|
118
|
+
attr_reader :code
|
|
119
|
+
# @return [String] actionable guidance from the API on what to do next.
|
|
120
|
+
attr_reader :hint
|
|
121
|
+
# @return [String] deep link to this code's entry in the error reference.
|
|
122
|
+
attr_reader :doc_url
|
|
123
|
+
# @return [Hash{String => Array<String>}, nil] per-field validation errors,
|
|
124
|
+
# present on 422 responses.
|
|
125
|
+
attr_reader :fields
|
|
126
|
+
# @return [String, nil] the request id — include it when contacting support.
|
|
127
|
+
attr_reader :request_id
|
|
128
|
+
|
|
129
|
+
# @api private
|
|
130
|
+
def initialize(message, status_code:, code:, hint: "", doc_url: "", fields: nil,
|
|
131
|
+
request_id: nil)
|
|
132
|
+
# Surface the hint in the raised message itself — the fix should arrive
|
|
133
|
+
# with the failure, without a docs tab.
|
|
134
|
+
super(hint.nil? || hint.empty? ? message : "#{message} — #{hint}")
|
|
135
|
+
@status_code = status_code
|
|
136
|
+
@code = code
|
|
137
|
+
@hint = hint || ""
|
|
138
|
+
@doc_url = doc_url || ""
|
|
139
|
+
@fields = fields
|
|
140
|
+
@request_id = request_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build the right {APIError} subclass from a raw response.
|
|
144
|
+
#
|
|
145
|
+
# @param status [Integer]
|
|
146
|
+
# @param body [Object, nil] the parsed JSON body (nil if it was not JSON).
|
|
147
|
+
# @param headers [#[]] response headers, looked up case-insensitively by
|
|
148
|
+
# lowercase name.
|
|
149
|
+
# @return [APIError]
|
|
150
|
+
# @api private
|
|
151
|
+
def self.from_response(status, body, headers)
|
|
152
|
+
parsed = parse_error_body(body)
|
|
153
|
+
code = parsed[:code] || default_code_for_status(status)
|
|
154
|
+
message = parsed[:message] || "Request failed with status #{status}"
|
|
155
|
+
details = {
|
|
156
|
+
status_code: status,
|
|
157
|
+
code: code,
|
|
158
|
+
hint: parsed[:hint] || "",
|
|
159
|
+
doc_url: parsed[:doc_url] || "",
|
|
160
|
+
request_id: parsed[:request_id] || headers["x-request-id"],
|
|
161
|
+
fields: parsed[:fields],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
klass = CLASS_FOR_STATUS[status] || (status >= 500 ? ServerError : APIError)
|
|
165
|
+
return klass.new(message, **details) unless status == 429
|
|
166
|
+
|
|
167
|
+
RateLimitError.new(
|
|
168
|
+
message,
|
|
169
|
+
retry_after: numeric(headers["retry-after"]),
|
|
170
|
+
limit: numeric(headers["x-ratelimit-limit"]),
|
|
171
|
+
remaining: numeric(headers["x-ratelimit-remaining"]),
|
|
172
|
+
**details,
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @api private
|
|
177
|
+
def self.default_code_for_status(status)
|
|
178
|
+
DEFAULT_CODE_FOR_STATUS.fetch(status, ErrorCode::SYSTEM_INTERNAL_ERROR)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Pull `{ error: { code, message, hint, docUrl, fields }, meta: { requestId } }`
|
|
182
|
+
# out of a parsed body, tolerating any missing or malformed piece — a proxy
|
|
183
|
+
# 502 page must degrade to a clean error, never crash the parser.
|
|
184
|
+
#
|
|
185
|
+
# @api private
|
|
186
|
+
def self.parse_error_body(body)
|
|
187
|
+
return {} unless body.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
error = body["error"]
|
|
190
|
+
out = {}
|
|
191
|
+
if error.is_a?(Hash)
|
|
192
|
+
out[:code] = error["code"] if error["code"].is_a?(String)
|
|
193
|
+
out[:message] = error["message"] if error["message"].is_a?(String)
|
|
194
|
+
out[:hint] = error["hint"] if error["hint"].is_a?(String)
|
|
195
|
+
out[:doc_url] = error["docUrl"] if error["docUrl"].is_a?(String)
|
|
196
|
+
out[:fields] = error["fields"] if error["fields"].is_a?(Hash)
|
|
197
|
+
end
|
|
198
|
+
meta = body["meta"]
|
|
199
|
+
out[:request_id] = meta["requestId"] if meta.is_a?(Hash) && meta["requestId"].is_a?(String)
|
|
200
|
+
out
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @api private
|
|
204
|
+
def self.numeric(value)
|
|
205
|
+
return nil if value.nil?
|
|
206
|
+
|
|
207
|
+
Integer(value)
|
|
208
|
+
rescue ArgumentError, TypeError
|
|
209
|
+
Float(value, exception: false)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private_class_method :parse_error_body, :numeric
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 400 — the request could not be understood.
|
|
216
|
+
class BadRequestError < APIError; end
|
|
217
|
+
# 401 — missing, invalid, revoked, or wrong-environment API key.
|
|
218
|
+
class AuthenticationError < APIError; end
|
|
219
|
+
# 403 — valid key, but not allowed (missing scope, foreign resource).
|
|
220
|
+
class PermissionDeniedError < APIError; end
|
|
221
|
+
# 404 — no resource at that id in this environment.
|
|
222
|
+
class NotFoundError < APIError; end
|
|
223
|
+
# 409 — conflicts with current state (including idempotency in-progress/reuse).
|
|
224
|
+
class ConflictError < APIError; end
|
|
225
|
+
# 422 — one or more fields invalid; see {APIError#fields}.
|
|
226
|
+
class ValidationError < APIError; end
|
|
227
|
+
# 5xx — something failed on NombaOne's side; safe to retry (the SDK already did).
|
|
228
|
+
class ServerError < APIError; end
|
|
229
|
+
|
|
230
|
+
# 429 — slow down; retry after {#retry_after} seconds. The SDK retries these
|
|
231
|
+
# automatically, honoring `Retry-After`.
|
|
232
|
+
class RateLimitError < APIError
|
|
233
|
+
# @return [Integer, Float, nil] seconds until the rate-limit window rolls
|
|
234
|
+
# over (`Retry-After`).
|
|
235
|
+
attr_reader :retry_after
|
|
236
|
+
# @return [Integer, Float, nil] your request cap (`X-RateLimit-Limit`).
|
|
237
|
+
attr_reader :limit
|
|
238
|
+
# @return [Integer, Float, nil] requests left in the window
|
|
239
|
+
# (`X-RateLimit-Remaining`).
|
|
240
|
+
attr_reader :remaining
|
|
241
|
+
|
|
242
|
+
# @api private
|
|
243
|
+
def initialize(message, retry_after: nil, limit: nil, remaining: nil, **details)
|
|
244
|
+
super(message, **details)
|
|
245
|
+
@retry_after = retry_after
|
|
246
|
+
@limit = limit
|
|
247
|
+
@remaining = remaining
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The request never completed — DNS failure, connection reset, or a
|
|
252
|
+
# caller-initiated cancellation.
|
|
253
|
+
class ConnectionError < Error; end
|
|
254
|
+
|
|
255
|
+
# A single attempt exceeded its timeout budget. Retried automatically.
|
|
256
|
+
class TimeoutError < ConnectionError; end
|
|
257
|
+
|
|
258
|
+
# Webhook signature or timestamp verification failed. Reject the delivery.
|
|
259
|
+
class WebhookVerificationError < Error; end
|
|
260
|
+
|
|
261
|
+
class APIError
|
|
262
|
+
# Subclass dispatch by HTTP status (429 is special-cased in `from_response`).
|
|
263
|
+
CLASS_FOR_STATUS = {
|
|
264
|
+
400 => BadRequestError,
|
|
265
|
+
401 => AuthenticationError,
|
|
266
|
+
403 => PermissionDeniedError,
|
|
267
|
+
404 => NotFoundError,
|
|
268
|
+
409 => ConflictError,
|
|
269
|
+
422 => ValidationError,
|
|
270
|
+
}.freeze
|
|
271
|
+
|
|
272
|
+
# The code to assume when the error body is missing or unusable.
|
|
273
|
+
DEFAULT_CODE_FOR_STATUS = {
|
|
274
|
+
400 => ErrorCode::CLIENT_INVALID_REQUEST,
|
|
275
|
+
401 => ErrorCode::API_KEY_INVALID,
|
|
276
|
+
403 => ErrorCode::CLIENT_FORBIDDEN,
|
|
277
|
+
404 => ErrorCode::CLIENT_RESOURCE_NOT_FOUND,
|
|
278
|
+
409 => ErrorCode::CLIENT_CONFLICT,
|
|
279
|
+
422 => ErrorCode::CLIENT_VALIDATION_FAILED,
|
|
280
|
+
429 => ErrorCode::RATE_LIMIT_EXCEEDED,
|
|
281
|
+
502 => ErrorCode::SYSTEM_UPSTREAM_ERROR,
|
|
282
|
+
503 => ErrorCode::SYSTEM_UPSTREAM_ERROR,
|
|
283
|
+
504 => ErrorCode::SYSTEM_UPSTREAM_ERROR,
|
|
284
|
+
}.freeze
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module Nombaone
|
|
9
|
+
module Internal
|
|
10
|
+
# One raw HTTP round-trip's outcome: status, lower-cased headers, and the
|
|
11
|
+
# unparsed body string.
|
|
12
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# What the transport hands back on success: the unwrapped `data`, optional
|
|
15
|
+
# cursor `pagination`, the `request_id`, and the raw {Response}.
|
|
16
|
+
Result = Struct.new(:data, :pagination, :request_id, :response, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# The default connection: a single `Net::HTTP` round-trip. Returns a
|
|
19
|
+
# {Response} for **any** HTTP status (2xx, 4xx, 5xx alike) and raises only
|
|
20
|
+
# on transport failure — a timeout ({Nombaone::TimeoutError}) or an
|
|
21
|
+
# unreachable/broken connection ({Nombaone::ConnectionError}). Distinguishing
|
|
22
|
+
# our own read timeout (a specific `Net::*Timeout`) from a caller's own
|
|
23
|
+
# `Timeout.timeout` (a bare `Timeout::Error`, left to propagate) is what
|
|
24
|
+
# lets the SDK retry the former and never retry the latter.
|
|
25
|
+
class NetHTTPConnection
|
|
26
|
+
METHOD_CLASSES = {
|
|
27
|
+
get: Net::HTTP::Get,
|
|
28
|
+
post: Net::HTTP::Post,
|
|
29
|
+
patch: Net::HTTP::Patch,
|
|
30
|
+
put: Net::HTTP::Put,
|
|
31
|
+
delete: Net::HTTP::Delete,
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# @return [Nombaone::Internal::Response]
|
|
35
|
+
# @raise [Nombaone::TimeoutError, Nombaone::ConnectionError]
|
|
36
|
+
def execute(method:, url:, headers:, body:, timeout:)
|
|
37
|
+
uri = URI.parse(url)
|
|
38
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
39
|
+
http.use_ssl = uri.scheme == "https"
|
|
40
|
+
http.open_timeout = timeout
|
|
41
|
+
http.read_timeout = timeout
|
|
42
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
43
|
+
|
|
44
|
+
response = http.request(build_request(method, uri, headers, body))
|
|
45
|
+
Response.new(status: response.code.to_i, headers: header_hash(response),
|
|
46
|
+
body: response.body)
|
|
47
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
48
|
+
raise Nombaone::TimeoutError, "The request to NombaOne timed out after #{timeout}s."
|
|
49
|
+
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError,
|
|
50
|
+
Net::HTTPBadResponse, Net::ProtocolError => e
|
|
51
|
+
raise Nombaone::ConnectionError, "Could not reach the NombaOne API: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_request(method, uri, headers, body)
|
|
57
|
+
klass = METHOD_CLASSES.fetch(method) { raise ArgumentError, "unsupported method #{method}" }
|
|
58
|
+
request = klass.new(uri)
|
|
59
|
+
headers.each { |name, value| request[name] = value }
|
|
60
|
+
request.body = body unless body.nil?
|
|
61
|
+
request
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def header_hash(response)
|
|
65
|
+
headers = {}
|
|
66
|
+
response.each_header { |name, value| headers[name.downcase] = value }
|
|
67
|
+
headers
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Executes one logical API call: builds the request, runs the retry loop,
|
|
72
|
+
# parses the response envelope, and returns a {Result} or raises a typed
|
|
73
|
+
# error.
|
|
74
|
+
#
|
|
75
|
+
# Money-safety invariants enforced here and nowhere else:
|
|
76
|
+
#
|
|
77
|
+
# * The `Idempotency-Key` for a POST is computed **once, before the retry
|
|
78
|
+
# loop**, so every automatic retry replays the same logical operation
|
|
79
|
+
# instead of creating a new one.
|
|
80
|
+
# * A caller-initiated cancellation is never retried; only timeouts,
|
|
81
|
+
# connection failures, 408/429/5xx, and our own in-flight idempotency
|
|
82
|
+
# conflict are.
|
|
83
|
+
class HTTPClient
|
|
84
|
+
# The version prefix is applied here, at exactly one place — never in a
|
|
85
|
+
# resource path.
|
|
86
|
+
API_PREFIX = "/v1"
|
|
87
|
+
|
|
88
|
+
# Statuses retried unconditionally (409 is retried only for
|
|
89
|
+
# `IDEMPOTENCY_IN_PROGRESS`, handled in {#retryable?}).
|
|
90
|
+
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
91
|
+
|
|
92
|
+
# @api private
|
|
93
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:, connection:,
|
|
94
|
+
default_headers: nil, sleeper: nil)
|
|
95
|
+
@api_key = api_key
|
|
96
|
+
@base_url = base_url
|
|
97
|
+
@timeout = timeout
|
|
98
|
+
@max_retries = max_retries
|
|
99
|
+
@connection = connection
|
|
100
|
+
@default_headers = default_headers
|
|
101
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param method [Symbol] :get :post :patch :put :delete
|
|
105
|
+
# @param path [String] path below `/v1`, segments already encoded.
|
|
106
|
+
# @param query [Hash{String => String}, nil] wire-ready query params.
|
|
107
|
+
# @param body [Hash, nil] wire-ready (camelCase) body, or nil for no body.
|
|
108
|
+
# @param options [Hash] per-call options (idempotency_key, headers,
|
|
109
|
+
# timeout, max_retries, cancel_when).
|
|
110
|
+
# @return [Nombaone::Internal::Result]
|
|
111
|
+
def request(method:, path:, query: nil, body: nil, options: nil)
|
|
112
|
+
options ||= {}
|
|
113
|
+
timeout = options[:timeout] || @timeout
|
|
114
|
+
max_retries = [options[:max_retries] || @max_retries, 0].max
|
|
115
|
+
cancel_when = options[:cancel_when]
|
|
116
|
+
|
|
117
|
+
url = build_url(path, query)
|
|
118
|
+
headers = build_headers(method, body, options)
|
|
119
|
+
payload = body.nil? ? nil : JSON.generate(body)
|
|
120
|
+
|
|
121
|
+
attempt = 0
|
|
122
|
+
loop do
|
|
123
|
+
raise_if_canceled(cancel_when)
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
response = @connection.execute(
|
|
127
|
+
method: method, url: url, headers: headers, body: payload, timeout: timeout,
|
|
128
|
+
)
|
|
129
|
+
rescue Nombaone::TimeoutError, Nombaone::ConnectionError => e
|
|
130
|
+
raise e if attempt >= max_retries
|
|
131
|
+
|
|
132
|
+
wait(Util.backoff_seconds(attempt))
|
|
133
|
+
attempt += 1
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
parsed = parse_json(response.body)
|
|
138
|
+
return build_result(response, parsed) if success_status?(response.status)
|
|
139
|
+
|
|
140
|
+
error = APIError.from_response(response.status, parsed, response.headers)
|
|
141
|
+
raise error unless attempt < max_retries && retryable?(response.status, error)
|
|
142
|
+
|
|
143
|
+
retry_after = Util.retry_after_seconds(response.headers["retry-after"])
|
|
144
|
+
wait(retry_after || Util.backoff_seconds(attempt))
|
|
145
|
+
attempt += 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def build_url(path, query)
|
|
152
|
+
url = "#{@base_url}#{API_PREFIX}#{path}"
|
|
153
|
+
return url if query.nil? || query.empty?
|
|
154
|
+
|
|
155
|
+
"#{url}?#{URI.encode_www_form(query)}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_headers(method, body, options)
|
|
159
|
+
computed = {
|
|
160
|
+
"authorization" => "Bearer #{@api_key}",
|
|
161
|
+
"accept" => "application/json",
|
|
162
|
+
"user-agent" => "nombaone-ruby/#{Nombaone::VERSION}",
|
|
163
|
+
}
|
|
164
|
+
computed["content-type"] = "application/json" unless body.nil?
|
|
165
|
+
if method == :post
|
|
166
|
+
computed["idempotency-key"] = options[:idempotency_key] || Util.generate_idempotency_key
|
|
167
|
+
end
|
|
168
|
+
Util.merge_headers(computed, @default_headers, options[:headers])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def success_status?(status)
|
|
172
|
+
status.between?(200, 299)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def retryable?(status, error)
|
|
176
|
+
RETRYABLE_STATUSES.include?(status) ||
|
|
177
|
+
(status == 409 && error.code == ErrorCode::IDEMPOTENCY_IN_PROGRESS)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def raise_if_canceled(cancel_when)
|
|
181
|
+
return unless cancel_when.respond_to?(:call) && cancel_when.call
|
|
182
|
+
|
|
183
|
+
raise Nombaone::ConnectionError, "The request was canceled before it completed."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def wait(seconds)
|
|
187
|
+
@sleeper.call(seconds) if seconds&.positive?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def parse_json(body)
|
|
191
|
+
return nil if body.nil? || body.empty?
|
|
192
|
+
|
|
193
|
+
JSON.parse(body)
|
|
194
|
+
rescue JSON::ParserError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_result(response, parsed)
|
|
199
|
+
unless parsed.is_a?(Hash) && parsed["success"] == true && parsed.key?("data")
|
|
200
|
+
raise APIError.new(
|
|
201
|
+
"The API returned a response that was not a valid NombaOne envelope.",
|
|
202
|
+
status_code: response.status,
|
|
203
|
+
code: ErrorCode::SYSTEM_INTERNAL_ERROR,
|
|
204
|
+
request_id: response.headers["x-request-id"],
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
request_id = parsed.dig("meta", "requestId")
|
|
209
|
+
request_id = response.headers["x-request-id"] unless request_id.is_a?(String)
|
|
210
|
+
|
|
211
|
+
Result.new(
|
|
212
|
+
data: parsed["data"],
|
|
213
|
+
pagination: parse_pagination(parsed["pagination"]),
|
|
214
|
+
request_id: request_id,
|
|
215
|
+
response: response,
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def parse_pagination(raw)
|
|
220
|
+
return nil unless raw.is_a?(Hash) && [true, false].include?(raw["hasMore"])
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
limit: raw["limit"].is_a?(Integer) ? raw["limit"] : 0,
|
|
224
|
+
has_more: raw["hasMore"],
|
|
225
|
+
next_cursor: raw["nextCursor"].is_a?(String) ? raw["nextCursor"] : nil,
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Nombaone
|
|
8
|
+
# @api private
|
|
9
|
+
# Internal building blocks. Nothing here is part of the public API; it may
|
|
10
|
+
# change between minor versions.
|
|
11
|
+
module Internal
|
|
12
|
+
# Sentinel for "the caller did not supply this optional argument."
|
|
13
|
+
#
|
|
14
|
+
# Optional keyword params default to {OMIT}. A key left as {OMIT} is
|
|
15
|
+
# dropped from the request body entirely; an explicit `nil` is preserved
|
|
16
|
+
# and sent as JSON `null` (the documented way to *clear* a nullable field
|
|
17
|
+
# such as a customer's phone). This is the one reliable way in Ruby to tell
|
|
18
|
+
# "absent" apart from "explicitly null."
|
|
19
|
+
OMIT = Object.new
|
|
20
|
+
OMIT.define_singleton_method(:inspect) { "#<Nombaone omitted>" }
|
|
21
|
+
OMIT.freeze
|
|
22
|
+
|
|
23
|
+
# Pure, dependency-free helpers shared across the transport and resources.
|
|
24
|
+
module Util
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Keys whose *values* are arbitrary user JSON and must never have their
|
|
28
|
+
# inner keys rewritten (a customer's `metadata`, a sandbox webhook
|
|
29
|
+
# `payload`). The key name itself is still normalized (both are already
|
|
30
|
+
# single words, so normalization is a no-op).
|
|
31
|
+
PRESERVE_VALUE_KEYS = %w[metadata payload].freeze
|
|
32
|
+
|
|
33
|
+
# Convert a snake_case key to the camelCase name the wire expects.
|
|
34
|
+
# The wire field name is law; this is the single place the SDK's
|
|
35
|
+
# idiomatic Ruby names become it (`customer_id` → `customerId`,
|
|
36
|
+
# `amount_in_kobo` → `amountInKobo`, `plan_ref` → `planRef`).
|
|
37
|
+
#
|
|
38
|
+
# @param key [String, Symbol]
|
|
39
|
+
# @return [String]
|
|
40
|
+
def camelize(key)
|
|
41
|
+
parts = key.to_s.split("_")
|
|
42
|
+
return parts.first.to_s if parts.length <= 1
|
|
43
|
+
|
|
44
|
+
head, *tail = parts
|
|
45
|
+
(head + tail.map { |word| word.empty? ? "" : word[0].upcase + word[1..] }.join)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Recursively rewrite Hash keys to camelCase, except that the value of a
|
|
49
|
+
# {PRESERVE_VALUE_KEYS} key is passed through untouched.
|
|
50
|
+
#
|
|
51
|
+
# @param value [Object]
|
|
52
|
+
# @return [Object]
|
|
53
|
+
def deep_camelize_keys(value)
|
|
54
|
+
case value
|
|
55
|
+
when Hash
|
|
56
|
+
value.each_with_object({}) do |(key, val), out|
|
|
57
|
+
out[camelize(key)] =
|
|
58
|
+
PRESERVE_VALUE_KEYS.include?(key.to_s) ? val : deep_camelize_keys(val)
|
|
59
|
+
end
|
|
60
|
+
when Array
|
|
61
|
+
value.map { |item| deep_camelize_keys(item) }
|
|
62
|
+
else
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Prepare a request body hash for the wire: drop omitted keys (keeping
|
|
68
|
+
# explicit `nil`s so nullable fields can be cleared), then camelize.
|
|
69
|
+
#
|
|
70
|
+
# @param hash [Hash]
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def serialize_body(hash)
|
|
73
|
+
deep_camelize_keys(hash.reject { |_, v| OMIT.equal?(v) })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Prepare query params: drop omitted **and** nil filters (an absent
|
|
77
|
+
# filter, not a null one), camelize keys, and stringify values.
|
|
78
|
+
#
|
|
79
|
+
# @param hash [Hash]
|
|
80
|
+
# @return [Hash{String => String}]
|
|
81
|
+
def serialize_query(hash)
|
|
82
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
83
|
+
next if OMIT.equal?(value) || value.nil?
|
|
84
|
+
|
|
85
|
+
out[camelize(key)] = value.to_s
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Percent-encode one URL path segment. Ids come from user input, so they
|
|
90
|
+
# are never trusted raw; this matches JavaScript's `encodeURIComponent`
|
|
91
|
+
# for the characters that appear in NombaOne ids.
|
|
92
|
+
#
|
|
93
|
+
# @param value [String]
|
|
94
|
+
# @return [String]
|
|
95
|
+
def encode_path_segment(value)
|
|
96
|
+
CGI.escape(value.to_s).gsub("+", "%20")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A fresh idempotency key for one logical POST. Computed once, before the
|
|
100
|
+
# retry loop, so every automatic retry replays the same operation.
|
|
101
|
+
#
|
|
102
|
+
# @return [String]
|
|
103
|
+
def generate_idempotency_key
|
|
104
|
+
SecureRandom.uuid
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Full-jitter exponential backoff, in seconds: a random delay in
|
|
108
|
+
# `[0, min(8, 0.5 * 2**attempt))`. Jitter keeps a fleet of retrying
|
|
109
|
+
# clients from stampeding the API in lockstep.
|
|
110
|
+
#
|
|
111
|
+
# @param attempt [Integer] zero-based attempt index.
|
|
112
|
+
# @return [Float]
|
|
113
|
+
def backoff_seconds(attempt)
|
|
114
|
+
rand * [8.0, 0.5 * (2**attempt)].min
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Parse a `Retry-After` header into seconds. Accepts delta-seconds or an
|
|
118
|
+
# HTTP-date; returns nil when absent or unparseable so the caller falls
|
|
119
|
+
# back to its own backoff.
|
|
120
|
+
#
|
|
121
|
+
# @param raw [String, nil]
|
|
122
|
+
# @return [Float, nil]
|
|
123
|
+
def retry_after_seconds(raw)
|
|
124
|
+
return nil if raw.nil?
|
|
125
|
+
|
|
126
|
+
seconds = Float(raw, exception: false)
|
|
127
|
+
return [seconds, 0.0].max if seconds
|
|
128
|
+
|
|
129
|
+
date = begin
|
|
130
|
+
Time.httpdate(raw)
|
|
131
|
+
rescue ArgumentError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
date ? [date - Time.now, 0.0].max : nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Merge header layers left-to-right; later layers win. A `nil` value
|
|
138
|
+
# deletes a header (lets a caller strip an SDK default for one request).
|
|
139
|
+
# Header names are case-insensitive, so everything is lowercased once.
|
|
140
|
+
#
|
|
141
|
+
# @param layers [Array<Hash, nil>]
|
|
142
|
+
# @return [Hash{String => String}]
|
|
143
|
+
def merge_headers(*layers)
|
|
144
|
+
layers.each_with_object({}) do |layer, out|
|
|
145
|
+
next unless layer
|
|
146
|
+
|
|
147
|
+
layer.each do |name, value|
|
|
148
|
+
key = name.to_s.downcase
|
|
149
|
+
value.nil? ? out.delete(key) : out[key] = value.to_s
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|