rail0-sdk 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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ # Raised for non-2xx responses from the RAIL0 gateway, mirroring the code/title/detail
5
+ # triple the gateway answers with. `detail` is written to be shown to a user; `hint` is
6
+ # this SDK's own advice, present only for codes worth adding a next step to.
7
+ class ApiError < StandardError
8
+ # @!attribute [r] status
9
+ # @return [Integer] HTTP status code (e.g. 404, 409, 422).
10
+ # @!attribute [r] error
11
+ # @return [String] The specific condition, and the only field to branch on — e.g.
12
+ # "not_capturable", "insufficient_token_balance", "insufficient_gas_funds".
13
+ # @!attribute [r] title
14
+ # @return [String, nil] Short label for the failure, e.g. "Not enough balance".
15
+ # @!attribute [r] detail
16
+ # @return [String, nil] One or two sentences fit to show a user verbatim. Also this
17
+ # exception's message.
18
+ # @!attribute [r] retry_after
19
+ # @return [Integer, nil] Seconds the gateway asked the caller to wait, from the
20
+ # `Retry-After` header — present on a 429 (`error == "rate_limited"`) and nil
21
+ # otherwise. Surfaced because the alternative is a caller guessing: the SDK used
22
+ # to drop the header, so "rate limited" arrived with no idea of for how long.
23
+ #
24
+ # Note it is the WHOLE window the gateway throttles over, not the time left in it
25
+ # — the limiter sends its period verbatim — so it is an upper bound on the wait,
26
+ # not a measurement. Rail0::Backoff clamps it for that reason.
27
+ attr_reader :status, :error, :title, :detail, :retry_after
28
+
29
+ # @param status [Integer]
30
+ # @param error [String]
31
+ # @param message [String] The detail; kept positional for compatibility.
32
+ # @param title [String, nil]
33
+ # @param retry_after [Integer, nil]
34
+ def initialize(status, error, message, title: nil, retry_after: nil)
35
+ super(message)
36
+ @status = status
37
+ @error = error
38
+ @title = title
39
+ @detail = message
40
+ @retry_after = retry_after
41
+ freeze
42
+ end
43
+
44
+ # This SDK's actionable next step for the error, or nil when it has none.
45
+ # @return [String, nil]
46
+ def hint
47
+ Rail0.describe_error(error)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ # How long to wait before retrying a request the gateway rate-limited.
5
+ #
6
+ # Pure, and its own module, because the two interesting decisions here are easy to get
7
+ # backwards and impossible to notice once they are wrong — a client that waits too
8
+ # little walks straight back into the limiter, and one that waits too long looks hung.
9
+ #
10
+ # 1. JITTER NEVER SHORTENS THE WAIT BELOW WHAT IT IS FOR.
11
+ # On a server-instructed wait it is ADDITIVE: scaling a Retry-After DOWN means retrying
12
+ # before the window the server named has passed, which is a second 429 by construction.
13
+ # So the instruction is honoured in full and a small random tail is added.
14
+ #
15
+ # On a guessed wait it is EQUAL jitter — half the delay fixed, half random — not the
16
+ # textbook "full jitter" that multiplies the whole delay by rand(). Full jitter can
17
+ # land arbitrarily close to zero, which makes a real pause indistinguishable from the
18
+ # bug where a Retry-After of "0" is honoured as a duration and the retry fires
19
+ # immediately. A floor spreads the herd just as well and leaves "did we actually wait"
20
+ # observable.
21
+ #
22
+ # Why any jitter at all when the server told us the time: because callers align on
23
+ # it. rail0-admin proxies every merchant over ONE session, so they share the
24
+ # per-session bucket and would all be told the same Retry-After, wake together, and
25
+ # recreate the burst the limiter just rejected.
26
+ #
27
+ # 2. THE CAP IS NOT PARANOIA. The gateway sends the WHOLE period as Retry-After
28
+ # (rack_attack.rb: `headers["retry-after"] = match_data[:period].to_s`), not the time
29
+ # remaining in the window — so hitting the limit one second in is told to wait the
30
+ # full 60. Capping bounds both that over-wait and a hostile or misconfigured value
31
+ # from anything between the client and the gateway.
32
+ module Backoff
33
+ module_function
34
+
35
+ # @param retry_after [Integer, Float, nil] the server's Retry-After, in seconds.
36
+ # Absent, unparseable, zero or negative all mean "no instruction" — and zero is the
37
+ # trap: it is a valid duration, so treating it as one produces a burst of
38
+ # back-to-back requests against the very limiter that asked for a pause.
39
+ # @param attempt [Integer] 1 for the first retry, 2 for the second, …
40
+ # @param base [Float] the exponential backoff's first delay, in seconds.
41
+ # @param cap [Float] the longest wait to allow, in seconds.
42
+ # @param jitter [Float, nil] randomness in [0,1); injected only by tests. Nil draws it.
43
+ # @return [Float] seconds to sleep.
44
+ def throttle_delay(retry_after:, attempt:, base:, cap:, jitter: nil)
45
+ random = jitter || Kernel.rand
46
+ instructed = positive_number(retry_after)
47
+
48
+ if instructed
49
+ # Honour it in full (clamped), plus a fraction of one base delay so aligned
50
+ # callers do not wake in lockstep.
51
+ [instructed, cap].min + (random * base)
52
+ else
53
+ # No instruction: exponential from `base`, EQUAL jitter (half fixed, half random),
54
+ # clamped.
55
+ full = base * (2**(attempt - 1))
56
+ [(full / 2.0) + ((full / 2.0) * random), cap].min
57
+ end
58
+ end
59
+
60
+ # @return [Float, nil] the value when it is a positive number, else nil.
61
+ def positive_number(value)
62
+ number = Float(value, exception: false)
63
+ number&.positive? ? number : nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+ require_relative "resources/auth"
5
+ require_relative "resources/chains"
6
+ require_relative "resources/tokens"
7
+ require_relative "resources/health"
8
+ require_relative "resources/payment_methods"
9
+ require_relative "resources/accounts"
10
+ require_relative "resources/wallets"
11
+ require_relative "resources/payments"
12
+ require_relative "resources/disputes"
13
+ require_relative "resources/webhooks"
14
+ require_relative "resources/analytics"
15
+
16
+ module Rail0
17
+ # Entry point for the RAIL0 SDK.
18
+ #
19
+ # client = Rail0::Client.new(base_url: "https://api.rail0.xyz")
20
+ # resp = client.auth.login(private_key: "0x...", domain: "api.rail0.xyz")
21
+ # resp = client.payments.create(chain_id: 84532, mode: "authorize", amount: "100.00", token: "0x...", payer: "0x...", payee: "0x...")
22
+ #
23
+ # Most of this API is authenticated: the ENTIRE payments sub-tree (create, sign,
24
+ # every prepare/submit, reads and list) plus wallets, webhooks, disputes and
25
+ # analytics. Only chains, tokens, health and payment_methods are public. The token
26
+ # is supplied via +headers+: pass +{ "Authorization" => "Bearer <jwt>" }+ (obtained
27
+ # from +auth.login+). The SDK does not persist the token for you.
28
+ class Client
29
+ # @!attribute [r] auth
30
+ # @return [Resources::Auth] SIWE authentication operations.
31
+ # @!attribute [r] chains
32
+ # @return [Resources::Chains] Public blockchain catalog.
33
+ # @!attribute [r] tokens
34
+ # @return [Resources::Tokens] Public token catalog.
35
+ # @!attribute [r] health
36
+ # @return [Resources::Health] Gateway liveness/readiness check.
37
+ # @!attribute [r] payment_methods
38
+ # @return [Resources::PaymentMethods] Public buyer-facing payment-method discovery.
39
+ # @!attribute [r] accounts
40
+ # @return [Resources::Accounts] The account's own profile (JWT, holder only).
41
+ # @!attribute [r] wallets
42
+ # @return [Resources::Wallets] Account-scoped wallet management (JWT).
43
+ # @!attribute [r] payments
44
+ # @return [Resources::Payments] Payment lifecycle operations.
45
+ # @!attribute [r] disputes
46
+ # @return [Resources::Disputes] Account-level dispute list (JWT).
47
+ # @!attribute [r] webhooks
48
+ # @return [Resources::Webhooks] Webhook subscription management (JWT).
49
+ # @!attribute [r] analytics
50
+ # @return [Resources::Analytics] Account-scoped payment analytics (JWT).
51
+ attr_reader :auth, :chains, :tokens, :health, :payment_methods,
52
+ :accounts, :wallets, :payments, :disputes, :webhooks, :analytics
53
+
54
+ # @param base_url [String] Base URL of the RAIL0 API, e.g. "https://api.rail0.xyz".
55
+ # @param headers [Hash] Default headers merged into every request (e.g. Authorization).
56
+ # @param token [String, #call, nil] Bearer token, or a callable resolved per request
57
+ # (e.g. +token: -> { current_jwt }+) so one shared client survives a token
58
+ # refresh. An explicit Authorization in +headers+ takes precedence.
59
+ # @param timeout [Numeric] Timeout in seconds. Default: 30.
60
+ # @param logger [#call, nil] Optional logger. Pass Rail0::DEFAULT_LOGGER for built-in output.
61
+ # @param max_retries [Integer] Extra attempts after a network failure. Default: 0.
62
+ # @param retry_delay [Numeric] Base delay in seconds between retries (exponential backoff). Default: 0.2.
63
+ # @param retry_on_429 [Boolean] Retry a rate-limited request, waiting the gateway's
64
+ # Retry-After. Default: false — an automatic sleep hides back-pressure from the
65
+ # process that could react to it, and stalls a request/response app. Turn it on in a
66
+ # job. It works on its own: no need to set +max_retries+ as well.
67
+ # @param retry_after_cap [Numeric] Longest Retry-After to honour, in seconds. Default: 60.
68
+ def initialize(base_url:, headers: {}, token: nil, timeout: 30, logger: nil,
69
+ max_retries: 0, retry_delay: 0.2, retry_on_429: false, retry_after_cap: 60)
70
+ http = HttpClient.new(
71
+ base_url: base_url, headers: headers, token: token, timeout: timeout,
72
+ logger: logger, max_retries: max_retries, retry_delay: retry_delay,
73
+ retry_on_429: retry_on_429, retry_after_cap: retry_after_cap
74
+ )
75
+ @auth = Resources::Auth.new(http)
76
+ @chains = Resources::Chains.new(http)
77
+ @tokens = Resources::Tokens.new(http)
78
+ @health = Resources::Health.new(http)
79
+ @payment_methods = Resources::PaymentMethods.new(http)
80
+ @accounts = Resources::Accounts.new(http)
81
+ @wallets = Resources::Wallets.new(http)
82
+ @payments = Resources::Payments.new(http)
83
+ @disputes = Resources::Disputes.new(http)
84
+ @webhooks = Resources::Webhooks.new(http)
85
+ @analytics = Resources::Analytics.new(http)
86
+ freeze
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Rail0
6
+ # One log record emitted per request attempt.
7
+ # rubocop:disable Lint/StructNewOverride -- `method` is the HTTP method, which is the right
8
+ # name for it on a log record. It shadows Struct#method (reflection), which nothing here
9
+ # uses; renaming the field to avoid that would make every log line read worse.
10
+ LogEntry = Struct.new(
11
+ :method, :url, :duration_ms, :request_body,
12
+ :status, :response_body, :error, :attempt, :will_retry,
13
+ keyword_init: true
14
+ )
15
+ # rubocop:enable Lint/StructNewOverride
16
+
17
+ # Default logger for Rail0::Client's `logger:` option. A Logger subclass:
18
+ # formats a Rail0::LogEntry into a one-line summary and logs it through the
19
+ # standard Logger machinery, so #level, #formatter, and any IO/file logdev
20
+ # all work normally instead of being reimplemented.
21
+ #
22
+ # client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
23
+ # # [rail0] GET 200 https://.../payments/0x… 87ms
24
+ #
25
+ # # Write to a file, only warnings and above:
26
+ # client = Rail0::Client.new(
27
+ # base_url: "https://api.rail0.xyz",
28
+ # logger: Rail0::DefaultLogger.new("rail0.log", level: Logger::WARN)
29
+ # )
30
+ class DefaultLogger < Logger
31
+ def initialize(logdev = $stdout, *args, **kwargs)
32
+ super
33
+ end
34
+
35
+ # The callable interface HttpClient expects: one Rail0::LogEntry per
36
+ # request attempt. Routes to #error on failure so raising the level past
37
+ # DEBUG still surfaces failed requests, and to #debug otherwise.
38
+ def call(entry)
39
+ entry.error ? error(message_for(entry)) : debug(message_for(entry))
40
+ end
41
+
42
+ private
43
+
44
+ def message_for(entry)
45
+ flag = entry.error ? " ERROR" : ""
46
+ status_part = entry.status ? " #{entry.status}" : ""
47
+ attempt_part =
48
+ if entry.attempt && (entry.attempt > 1 || entry.will_retry)
49
+ retry_part = entry.will_retry ? ", retrying" : ""
50
+ " [attempt #{entry.attempt}#{retry_part}]"
51
+ else
52
+ ""
53
+ end
54
+
55
+ parts = ["[rail0]#{flag}#{attempt_part} #{entry.method}#{status_part} #{entry.url} #{entry.duration_ms.round}ms"]
56
+ parts << "-> #{entry.request_body.inspect}" if entry.request_body
57
+ parts << "<- #{entry.response_body.inspect}" if entry.response_body
58
+ parts << "! #{entry.error}" if entry.error
59
+ parts.join(" ")
60
+ end
61
+ end
62
+
63
+ # Ready-to-use DefaultLogger writing to $stdout at the default level, shared
64
+ # so callers who just want the built-in output don't need to instantiate
65
+ # their own. Frozen -- it can't be reconfigured (no #level=, no #formatter=)
66
+ # since that would leak across every Client sharing it; anyone wanting a
67
+ # different IO/level should build their own via DefaultLogger.new(...).
68
+ #
69
+ # client = Rail0::Client.new(base_url: "https://api.rail0.xyz", logger: Rail0::DEFAULT_LOGGER)
70
+ DEFAULT_LOGGER = DefaultLogger.new.freeze
71
+
72
+ # The `logger:` used when the caller doesn't pass one. Discards every entry,
73
+ # so HttpClient can always unconditionally call `logger.call(entry)` without
74
+ # ever checking whether logging is actually configured.
75
+ class NullLogger
76
+ def call(_entry); end
77
+ end
78
+
79
+ # NullLogger is stateless, so every HttpClient built without an explicit
80
+ # `logger:` shares this one frozen instance instead of allocating its own.
81
+ NULL_LOGGER = NullLogger.new.freeze
82
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ # Actionable next steps per error code, supplementing the gateway's own `detail`.
5
+ # Shared source with the Go SDK (rail0.DescribeError), the TS SDK (describeError) and
6
+ # the CLI's hints — keep the four in step.
7
+ ERROR_HINTS = {
8
+ "amount_exceeds_capturable" => "amount is above the capturable balance — check capturableAmount on the payment",
9
+ "amount_exceeds_refundable" => "amount is above the refundable balance — check refundableAmount on the payment",
10
+ "not_capturable" => "the payment must be 'authorized' or 'partially_captured' to capture",
11
+ "not_voidable" => "void is only allowed while 'authorized' with nothing captured — use release for the remainder after a capture",
12
+ "not_releasable" => "release opens only after authorizationExpiry",
13
+ "not_refundable" => "nothing is refundable — the payment must be charged/captured and within the refund window",
14
+ "not_signable" => "the payment must be 'unsigned' to sign",
15
+ "already_signed" => "the payer signature is already stored — the payee can act now",
16
+ "no_signature" => "the payer has not signed yet",
17
+ "wrong_mode" => "this operation doesn't match the payment's mode (authorize vs charge)",
18
+ "already_disputed" => "a dispute is already open — close it first",
19
+ "not_disputed" => "there is no open dispute to close",
20
+ "nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance",
21
+ "transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle",
22
+ "signer_mismatch" => "the signing key doesn't match the payment's payer/payee",
23
+ # The SIWE BINDING failures, split out of signer_mismatch so a failed login says
24
+ # WHICH part of the proof did not bind (#216). None names the server's own
25
+ # expectation: that endpoint is unauthenticated, so echoing the allow-list or the
26
+ # expected chain id would turn every hint into a probe.
27
+ "siwe_domain_not_allowed" => "sign with the origin the front-end is served from, and have it added to the gateway's SIWE domain allow-list",
28
+ "siwe_uri_mismatch" => "the message's uri host must equal its own domain",
29
+ "siwe_chain_mismatch" => "use the chain id the client library sends - this login is off-chain and nominal",
30
+ "siwe_proof_expired" => "get a fresh nonce and sign again",
31
+ # Address-wide, not one token: "sign in again", not "that token is dead".
32
+ "sessions_revoked" => "every session issued before this address's revoke-all cutoff is refused - sign in again",
33
+ "config_hash_mismatch" => "the payment record and its on-chain deployment disagree — the payment cannot be operated as recorded",
34
+ "payment_not_on_chain" => "the contract has no record of this payment — its opening transaction may never have confirmed",
35
+ "unsupported_contract_version" => "the payment's RAIL0 deployment is newer or older than this gateway supports — upgrade the gateway",
36
+ "insufficient_token_balance" => "the paying wallet does not hold enough of the token — top it up and retry",
37
+ "invalid_token_signature" => "the EIP-3009 authorization did not recover to the paying wallet — wrong key, chain, token or amount",
38
+ "authorization_already_used" => "that EIP-3009 authorization was already spent or cancelled — each is single-use, create a fresh payment",
39
+ "authorization_not_yet_valid" => "the authorization's validAfter is still in the future",
40
+ "token_account_blocked" => "the token issuer has blocklisted one of the wallets in this transfer",
41
+ "token_paused" => "the token contract is paused by its issuer — no transfer can settle right now",
42
+ "insufficient_gas_funds" => "the sending wallet cannot cover gas — fund it with the chain's native token",
43
+ "nonce_too_low" => "a transaction with that nonce is already on-chain — re-prepare the operation",
44
+ "replacement_underpriced" => "another transaction with that nonce is pending and this one does not pay enough to replace it",
45
+ "gas_price_too_low" => "the fee is below what the node accepts — re-prepare to pick up current fees",
46
+ "already_known" => "the node already has this exact transaction — wait for it to confirm rather than resending",
47
+ "rpc_unavailable" => "no configured RPC endpoint answered — the transaction was not submitted",
48
+ "not_the_payee" => "only the payment's payee can do this — sign in with the merchant's wallet",
49
+ "not_the_payer" => "only the payment's payer can do this — sign in with the buyer's wallet",
50
+ "not_a_participant" => "only the payer and the payee can see or act on a payment",
51
+ "not_payee" => "only the merchant (payee) may do this",
52
+ "not_payer" => "only the buyer (payer) may do this",
53
+ "not_payer_or_payee" => "only the payer or the payee may do this",
54
+ "refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible",
55
+ "authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes",
56
+ "already_captured" => "already (partially) captured — use release for the remainder, not void",
57
+ "token_not_accepted" => "the token isn't in this deployment's allowlist",
58
+ "payment_already_exists" => "a payment with this id already exists on-chain",
59
+ # Ported from rail0-go, which had drifted five codes ahead of this table (#13).
60
+ "unsupported_payment_method" => "the payee (merchant) doesn't accept this token/chain — check the merchant's payment methods",
61
+ "idempotency_key_reused" => "that Idempotency-Key was already used for a payment with different terms — reuse it only to retry the same request, or pick a new key",
62
+ "unknown_token" => "the token isn't configured on this chain",
63
+ "no_active_contract" => "no active RAIL0 contract on that chain",
64
+ "missing_param" => "a required parameter is missing from the request",
65
+ # A BARE forbidden is not a party mismatch: the gateway split those into codes of
66
+ # their own (not_the_payee, not_the_payer, wallet_deactivated, not_your_account)
67
+ # because they need different fixes. This entry kept describing one of them long
68
+ # after the split — and the rule it named no longer exists in the gateway at all.
69
+ "forbidden" => "not permitted for this session — typically the operator grant, a resource owned by another account, or a transaction signed by the wrong wallet"
70
+ }.freeze
71
+
72
+ # An actionable hint for a rail0 error code, or nil when the code is unknown.
73
+ # @param code [String, nil]
74
+ # @return [String, nil]
75
+ def self.describe_error(code)
76
+ return nil if code.nil? || code.to_s.empty?
77
+
78
+ ERROR_HINTS[code.to_s]
79
+ end
80
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "default_logger"
4
+ require_relative "request"
5
+
6
+ module Rail0
7
+ # @!visibility private
8
+ class HttpClient
9
+ attr_reader :base_url, :timeout, :logger, :max_retries, :retry_delay,
10
+ :retry_on_429, :retry_after_cap
11
+
12
+ # @param retry_on_429 [Boolean] retry a rate-limited request, waiting the gateway's
13
+ # Retry-After (see Rail0::Backoff). OFF by default, deliberately: an automatic
14
+ # sleep hides back-pressure from the one process that could react to it, and in a
15
+ # request/response app it turns a 429 into a stalled page. Turn it on for a job.
16
+ #
17
+ # It does NOT need `max_retries` to be set as well. That pairing is a footgun —
18
+ # the flag would silently do nothing — so on its own it allows one retry.
19
+ # @param retry_after_cap [Numeric] longest wait to honour, in seconds. The gateway
20
+ # sends its whole throttle period as Retry-After rather than the time left in it,
21
+ # so this bounds both the over-wait and any hostile value from in between.
22
+ def initialize(base_url:, headers: {}, token: nil, timeout: 30, logger: nil,
23
+ max_retries: 0, retry_delay: 0.2, retry_on_429: false,
24
+ retry_after_cap: 60)
25
+ @base_url = base_url.chomp("/")
26
+ @static_headers = { "Content-Type" => "application/json" }.merge(headers)
27
+ @token = token
28
+ @timeout = timeout
29
+ @logger = logger || NULL_LOGGER
30
+ @max_retries = max_retries
31
+ @retry_delay = retry_delay
32
+ @retry_on_429 = retry_on_429
33
+ @retry_after_cap = retry_after_cap
34
+ freeze
35
+ end
36
+
37
+ # Headers for one request.
38
+ #
39
+ # A `token` is resolved HERE, per request, rather than baked in at construction:
40
+ # you need a client to call auth.login, and the login's JWT to build the client
41
+ # you actually use, so a header fixed at construction forced a whole new client
42
+ # (and its ten resource objects) on every sign-in and every refresh. Passing a
43
+ # callable — `token: -> { current_jwt }` — keeps one shared client valid across
44
+ # token rotations, which is what a long-lived process needs. A String token is
45
+ # accepted for the simple case, and an explicit Authorization in `headers`
46
+ # still wins so nothing existing changes. (#11)
47
+ #
48
+ # The object stays frozen: what varies is the proc's answer, not this object.
49
+ def headers
50
+ return @static_headers if @token.nil? || @static_headers.key?("Authorization")
51
+
52
+ resolved = @token.respond_to?(:call) ? @token.call : @token
53
+ return @static_headers if resolved.nil? || resolved.to_s.empty?
54
+
55
+ @static_headers.merge("Authorization" => "Bearer #{resolved}")
56
+ end
57
+
58
+ def get(path)
59
+ request(:get, path)
60
+ end
61
+
62
+ # GET a paginated collection endpoint. The gateway returns a bare JSON array
63
+ # with pagination carried in the X-Total-Count / X-Page / X-Per-Page response
64
+ # headers (not a {data, meta} envelope), so this reads the meta back from the
65
+ # headers and wraps the array. Non-paginated array endpoints (blockchains,
66
+ # tokens, payment_methods) use plain #get instead.
67
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
68
+ def get_list(path)
69
+ request(:get, path, nil, paginated: true)
70
+ end
71
+
72
+ # @param headers [Hash] extra headers merged over the client defaults for this
73
+ # request only (e.g. { "Idempotency-Key" => "..." }).
74
+ def post(path, body = nil, headers: {})
75
+ request(:post, path, body, headers: headers)
76
+ end
77
+
78
+ def put(path, body = nil)
79
+ request(:put, path, body)
80
+ end
81
+
82
+ def patch(path, body = nil)
83
+ request(:patch, path, body)
84
+ end
85
+
86
+ def delete(path)
87
+ request(:delete, path)
88
+ end
89
+
90
+ private
91
+
92
+ def request(method, path, body = nil, paginated: false, headers: {})
93
+ Request.new(
94
+ client: self, method: method, path: path, body: body,
95
+ paginated: paginated, extra_headers: headers
96
+ ).call
97
+ end
98
+ end
99
+ end