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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE +21 -0
  4. data/README.md +230 -0
  5. data/lib/nombaone/client.rb +158 -0
  6. data/lib/nombaone/errors.rb +286 -0
  7. data/lib/nombaone/internal/http_client.rb +230 -0
  8. data/lib/nombaone/internal/util.rb +155 -0
  9. data/lib/nombaone/object.rb +152 -0
  10. data/lib/nombaone/pagination.rb +116 -0
  11. data/lib/nombaone/resources/base_resource.rb +40 -0
  12. data/lib/nombaone/resources/coupons.rb +84 -0
  13. data/lib/nombaone/resources/customers.rb +160 -0
  14. data/lib/nombaone/resources/events.rb +46 -0
  15. data/lib/nombaone/resources/invoices.rb +61 -0
  16. data/lib/nombaone/resources/mandates.rb +71 -0
  17. data/lib/nombaone/resources/metrics.rb +24 -0
  18. data/lib/nombaone/resources/organization.rb +91 -0
  19. data/lib/nombaone/resources/payment_methods.rb +102 -0
  20. data/lib/nombaone/resources/plans.rb +129 -0
  21. data/lib/nombaone/resources/prices.rb +53 -0
  22. data/lib/nombaone/resources/sandbox.rb +81 -0
  23. data/lib/nombaone/resources/settlements.rb +91 -0
  24. data/lib/nombaone/resources/subscriptions.rb +310 -0
  25. data/lib/nombaone/resources/webhook_endpoints.rb +140 -0
  26. data/lib/nombaone/version.rb +7 -0
  27. data/lib/nombaone/webhook_event.rb +64 -0
  28. data/lib/nombaone/webhooks.rb +167 -0
  29. data/lib/nombaone.rb +58 -0
  30. data/sig/nombaone/client.rbs +36 -0
  31. data/sig/nombaone/errors.rbs +68 -0
  32. data/sig/nombaone/internal.rbs +51 -0
  33. data/sig/nombaone/object.rbs +22 -0
  34. data/sig/nombaone/pagination.rbs +23 -0
  35. data/sig/nombaone/resources.rbs +243 -0
  36. data/sig/nombaone/webhooks.rbs +18 -0
  37. data/sig/nombaone.rbs +24 -0
  38. 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