zazu-ruby 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb2684661a728ba3f69777037fa66bbc8c9a0ed50b168709b3be159dabb9d5d4
4
+ data.tar.gz: 8ff94850c110055ea939adc202010df5f8f2622f64d4935e79b67e848a48e986
5
+ SHA512:
6
+ metadata.gz: fef8dcde5e3433ece2c550573fc717a5bd172ebaf9d13284eb3e77b44503793d10859fa7324669551189ce4230e059c9ba436b173e5a90d8c5cec90cab2270d0
7
+ data.tar.gz: ad848283fcb72e9c17d385fba58f19588370db028f673963a49ac682d9c7aefc718969586867e67e542da966df0e2038ac78a1398c533b3dbf70471011092ca9
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to `zazu-ruby` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ Initial release.
13
+
14
+ ### Added
15
+
16
+ - `Zazu::Client` — Faraday + HTTPX adapter, JSON request/response, retry middleware.
17
+ - Resource modules: `Accounts`, `Customers`, `Entity`, `Invoices`, `PaymentLinks`, `WebhookEndpoints`.
18
+ - Cursor-based pagination via `Zazu::Page` (max 100 records per page; no auto-pagination).
19
+ - Error hierarchy: `AuthenticationError`, `ForbiddenError`, `NotFoundError`,
20
+ `ValidationError`, `RateLimitError`, `ServerError`, `ConnectionError`,
21
+ `ConfigurationError`, `ArgumentError` — all under `Zazu::Error`.
22
+ - VCR-backed RSpec suite covering every public method.
23
+ - Cassette tarball published as a release asset for cross-language SDK reuse.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zazu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # Zazu Ruby SDK
2
+
3
+ Ruby SDK for the [Zazu API](https://zazu.ma). Faraday + HTTPX adapter for HTTP/2 + persistent connections.
4
+
5
+ ```ruby
6
+ gem "zazu-ruby"
7
+ ```
8
+
9
+ The gem is published as `zazu-ruby` on RubyGems but loaded as `zazu` in code (the `zazu` name was already taken by an unrelated 2014-era gem).
10
+
11
+ ## Quick start
12
+
13
+ ```ruby
14
+ require "zazu"
15
+
16
+ zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"])
17
+ # Or with explicit base URL (defaults to https://zazu.ma):
18
+ zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"], base_url: "https://zazu.africa")
19
+
20
+ entity = zazu.entity.get
21
+ # => #<Zazu::Response status=200 ...>
22
+ entity.body["name"]
23
+ # => "Acme Corp"
24
+ ```
25
+
26
+ Environment variables `ZAZU_API_KEY`, `ZAZU_BASE_URL`, `ZAZU_API_VERSION`, and `ZAZU_TIMEOUT` are read by default.
27
+
28
+ ## Resources
29
+
30
+ ```ruby
31
+ zazu.entity.get
32
+
33
+ zazu.accounts.list(currency_code: "MAD", limit: 50)
34
+ zazu.accounts.get("019dde7d-...")
35
+ zazu.accounts.list_transactions("019dde7d-...", operation: "credit")
36
+ zazu.accounts.get_transaction("019dde7d-...", "01a0e1...")
37
+
38
+ zazu.customers.list(q: "acme")
39
+ zazu.customers.get("01a0...")
40
+ zazu.customers.create(
41
+ customer_type: "business",
42
+ company_name: "Acme Corp",
43
+ email: "billing@acme.com",
44
+ ice_number: "000000000000000"
45
+ )
46
+ zazu.customers.update("01a0...", email: "new@example.com")
47
+ zazu.customers.delete("01a0...")
48
+
49
+ zazu.invoices.list(status: "sent", limit: 50)
50
+ zazu.invoices.create(
51
+ customer_id: "01a0...",
52
+ currency_code: "MAD",
53
+ issue_date: "2026-05-03",
54
+ due_date: "2026-06-03",
55
+ items: [{ description: "Consulting", quantity: 10, unit_price: "150.00" }]
56
+ )
57
+ zazu.invoices.send_invoice("01a0...")
58
+ zazu.invoices.mark_as_paid("01a0...")
59
+ zazu.invoices.cancel("01a0...")
60
+ zazu.invoices.credit_note("01a0...")
61
+ zazu.invoices.create_payment_link("01a0...", account_id: "019dde7d-...")
62
+
63
+ zazu.payment_links.list(status: "active")
64
+ zazu.payment_links.create(
65
+ account_id: "019dde7d-...",
66
+ amount: "1500.00",
67
+ description: "March consulting",
68
+ link_type: "single"
69
+ )
70
+ zazu.payment_links.cancel("01a0...")
71
+
72
+ zazu.webhook_endpoints.list
73
+ zazu.webhook_endpoints.create(
74
+ url: "https://example.com/webhooks/zazu",
75
+ events: ["invoice.sent", "payment_link.paid"]
76
+ )
77
+ zazu.webhook_endpoints.test_endpoint("01a0...")
78
+ zazu.webhook_endpoints.regenerate_secret("01a0...")
79
+ zazu.webhook_endpoints.enable("01a0...")
80
+ zazu.webhook_endpoints.disable("01a0...")
81
+ ```
82
+
83
+ ## Pagination
84
+
85
+ Every list endpoint returns a `Zazu::Page`. The SDK enforces a hard cap of **100 records per page** — there is no auto-pagination across pages.
86
+
87
+ ```ruby
88
+ page = zazu.invoices.list(limit: 100)
89
+ page.data # => Array of invoice hashes
90
+ page.has_more # => true / false
91
+ page.next_cursor # => string or nil
92
+
93
+ # Walk pages explicitly:
94
+ while page
95
+ page.data.each { |inv| process(inv) }
96
+ page = page.next # returns nil when has_more is false
97
+ end
98
+ ```
99
+
100
+ For capped iteration, use the underlying `each_page_record` helper on a resource (private; access via `send` if you need it). The deliberate restriction is a guardrail — accidentally pulling 50,000 records in a single SDK call should be impossible without explicit per-page consent.
101
+
102
+ ## Errors
103
+
104
+ Every non-2xx response raises a subclass of `Zazu::Error`:
105
+
106
+ | Status | Class |
107
+ |---|---|
108
+ | 401 | `Zazu::AuthenticationError` |
109
+ | 403 | `Zazu::ForbiddenError` |
110
+ | 404 | `Zazu::NotFoundError` |
111
+ | 422 | `Zazu::ValidationError` |
112
+ | 429 | `Zazu::RateLimitError` (carries `#retry_after`) |
113
+ | 5xx | `Zazu::ServerError` |
114
+ | network | `Zazu::ConnectionError` |
115
+
116
+ Each error exposes `#status`, `#request_id`, `#type`, `#param`, and the raw `#body`.
117
+
118
+ ```ruby
119
+ begin
120
+ zazu.invoices.get("does-not-exist")
121
+ rescue Zazu::NotFoundError => e
122
+ e.status # => 404
123
+ e.request_id # => "req_..."
124
+ e.type # => "not_found_error"
125
+ end
126
+ ```
127
+
128
+ ## Versioning the API contract
129
+
130
+ ```ruby
131
+ zazu = Zazu.new(api_key: "...", api_version: "2026-03-27")
132
+ ```
133
+
134
+ Or via env: `ZAZU_API_VERSION=2026-03-27`. The header is sent on every request; the API echoes it back in `Zazu-Version`.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ bundle install
140
+ bundle exec rspec
141
+ bundle exec rubocop
142
+ ```
143
+
144
+ The spec suite is VCR-backed — cassettes live in `spec/fixtures/cassettes/` and are committed to the repo.
145
+
146
+ To re-record cassettes against staging:
147
+
148
+ ```bash
149
+ cp .env.example .env
150
+ # fill in ZAZU_STAGING_API_KEY and the ZAZU_FIXTURE_*_ID values
151
+ bundle exec rake fixtures:record
152
+ ```
153
+
154
+ Cassettes are scrubbed before write — bearer tokens and request IDs are rewritten to placeholders. Even if a real key is in `.env`, the committed cassette never contains it.
155
+
156
+ ## Cassettes for other-language SDKs
157
+
158
+ Each release of `zazu-ruby` publishes the cassette directory as a tarball release asset:
159
+
160
+ ```
161
+ https://github.com/getzazu/zazu-ruby/releases/download/v0.1.0/cassettes-v0.1.0.tar.gz
162
+ ```
163
+
164
+ `zazu-go`, `zazu-python`, etc. pin a specific version in their `.zazu-fixtures` file and download the tarball during CI. This guarantees every SDK is tested against the same recorded API interactions, surfacing cross-SDK inconsistencies immediately.
165
+
166
+ VCR's YAML format is supported natively by:
167
+
168
+ - Ruby — VCR (this gem)
169
+ - Go — go-vcr
170
+ - Python — VCR.py
171
+ - PHP — PHP-VCR
172
+ - Crystal — vcr-crystal / hi8.cr
173
+ - Rust — http_replayer (or a small custom YAML reader)
174
+ - JavaScript / TypeScript — Talkback or polly.js (slight format adapter needed)
175
+
176
+ ## License
177
+
178
+ MIT
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "httpx/adapters/faraday"
6
+ require "json"
7
+ require "securerandom"
8
+
9
+ module Zazu
10
+ # The main SDK entry point.
11
+ #
12
+ # zazu = Zazu::Client.new(api_key: "sk_live_...")
13
+ # zazu.entity.get
14
+ # zazu.accounts.list(limit: 50)
15
+ #
16
+ # All public state is set at construction time. The client is
17
+ # thread-safe in the sense that the underlying Faraday connection
18
+ # uses a connection pool via the HTTPX adapter — multiple threads
19
+ # can share one client.
20
+ class Client
21
+ DEFAULT_BASE_URL = "https://zazu.ma"
22
+ DEFAULT_TIMEOUT = 30
23
+ USER_AGENT = "zazu-ruby/#{VERSION}".freeze
24
+
25
+ attr_reader :api_key, :base_url, :api_version, :timeout, :logger
26
+
27
+ def initialize(
28
+ api_key: ENV.fetch("ZAZU_API_KEY", nil),
29
+ base_url: ENV.fetch("ZAZU_BASE_URL", DEFAULT_BASE_URL),
30
+ api_version: ENV.fetch("ZAZU_API_VERSION", nil),
31
+ timeout: Integer(ENV.fetch("ZAZU_TIMEOUT", DEFAULT_TIMEOUT)),
32
+ logger: nil
33
+ )
34
+ raise ConfigurationError, "Missing api_key. Pass api_key: or set ZAZU_API_KEY." if api_key.to_s.empty?
35
+
36
+ @api_key = api_key
37
+ @base_url = base_url.to_s.chomp("/")
38
+ @api_version = api_version
39
+ @timeout = timeout
40
+ @logger = logger
41
+ end
42
+
43
+ # Resource accessors — each returns a memoized resource module.
44
+ def accounts
45
+ @accounts ||= Resources::Accounts.new(self)
46
+ end
47
+
48
+ def customers
49
+ @customers ||= Resources::Customers.new(self)
50
+ end
51
+
52
+ def entity
53
+ @entity ||= Resources::Entity.new(self)
54
+ end
55
+
56
+ def invoices
57
+ @invoices ||= Resources::Invoices.new(self)
58
+ end
59
+
60
+ def payment_links
61
+ @payment_links ||= Resources::PaymentLinks.new(self)
62
+ end
63
+
64
+ def webhook_endpoints
65
+ @webhook_endpoints ||= Resources::WebhookEndpoints.new(self)
66
+ end
67
+
68
+ # Performs an HTTP request and returns a {Zazu::Response} on
69
+ # success. Translates non-2xx responses into the matching
70
+ # {Zazu::Error} subclass.
71
+ def request(method, path, params: nil, body: nil, headers: {})
72
+ raw = connection.send(method) do |req|
73
+ req.url(path)
74
+ req.params.update(params) if params
75
+ req.body = body unless body.nil?
76
+ headers.each { |k, v| req.headers[k] = v }
77
+ end
78
+
79
+ response = Response.new(raw)
80
+ return response if response.success?
81
+
82
+ raise build_error(response)
83
+ rescue Faraday::TimeoutError => e
84
+ raise ConnectionError, "Request timed out after #{timeout}s: #{e.message}"
85
+ rescue Faraday::ConnectionFailed => e
86
+ raise ConnectionError, "Connection failed: #{e.message}"
87
+ end
88
+
89
+ private
90
+
91
+ def connection
92
+ @connection ||= Faraday.new(url: base_url) do |f|
93
+ f.headers["Authorization"] = "Bearer #{api_key}"
94
+ f.headers["User-Agent"] = USER_AGENT
95
+ f.headers["Accept"] = "application/json"
96
+ f.headers["Zazu-Version"] = api_version if api_version
97
+ f.request :json
98
+ f.response :json, content_type: /\bjson$/
99
+ f.options.timeout = timeout
100
+ f.options.open_timeout = [timeout, 10].min
101
+ f.response :logger, logger if logger
102
+ f.adapter(*adapter_args)
103
+ end
104
+ end
105
+
106
+ # The HTTPX adapter ships its own WebMock plugin that wraps every
107
+ # connection. When VCR's WebMock library hook is also active and
108
+ # net-connect is allowed (recording mode), the two interceptors
109
+ # layer in a way that deadlocks on the first real request. For
110
+ # cassette recording we drop down to Net::HTTP, which has rock-
111
+ # solid WebMock + VCR integration. Cassettes are adapter-agnostic
112
+ # so replay continues to use the production HTTPX adapter.
113
+ def adapter_args
114
+ ENV["VCR_RECORD"] ? [:net_http] : [:httpx]
115
+ end
116
+
117
+ # Lookup table for status → (error class, default message). 5xx
118
+ # is matched separately because Range keys don't work in Hash
119
+ # lookup the way exact integers do.
120
+ ERROR_BY_STATUS = {
121
+ 401 => [AuthenticationError, "Authentication failed"],
122
+ 403 => [ForbiddenError, "Forbidden"],
123
+ 404 => [NotFoundError, "Not found"],
124
+ 422 => [ValidationError, "Validation failed"]
125
+ }.freeze
126
+ private_constant :ERROR_BY_STATUS
127
+
128
+ def build_error(response)
129
+ payload = error_payload(response.body)
130
+ message = payload["message"]
131
+ kwargs = error_kwargs(response, payload)
132
+
133
+ if (mapping = ERROR_BY_STATUS[response.status])
134
+ klass, default_message = mapping
135
+ return klass.new(message || default_message, **kwargs)
136
+ end
137
+
138
+ build_special_error(response, message, kwargs)
139
+ end
140
+
141
+ def error_payload(body)
142
+ return {} unless body.is_a?(Hash) && body["error"].is_a?(Hash)
143
+
144
+ body["error"]
145
+ end
146
+
147
+ def error_kwargs(response, payload)
148
+ {
149
+ status: response.status,
150
+ request_id: response.request_id,
151
+ type: payload["type"],
152
+ param: payload["param"],
153
+ body: response.body
154
+ }
155
+ end
156
+
157
+ def build_special_error(response, message, kwargs)
158
+ case response.status
159
+ when 429
160
+ retry_after = response.headers["retry-after"]&.to_i
161
+ RateLimitError.new(message || "Rate limited", retry_after: retry_after, **kwargs)
162
+ when 500..599
163
+ ServerError.new(message || "Server error (#{response.status})", **kwargs)
164
+ else
165
+ Error.new(message || "Unexpected status #{response.status}", **kwargs)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ # Base class for every Zazu SDK error.
5
+ #
6
+ # Carries the HTTP status, the API request id (header `X-Request-Id`),
7
+ # the parsed error type from the API (`error.type`), and the raw
8
+ # response body so callers can introspect anything the SDK didn't
9
+ # explicitly model.
10
+ class Error < StandardError
11
+ attr_reader :status, :request_id, :type, :param, :body
12
+
13
+ def initialize(message = nil, status: nil, request_id: nil, type: nil, param: nil, body: nil)
14
+ super(message)
15
+ @status = status
16
+ @request_id = request_id
17
+ @type = type
18
+ @param = param
19
+ @body = body
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ error: self.class.name.split("::").last,
25
+ message:,
26
+ status:,
27
+ request_id:,
28
+ type:,
29
+ param:
30
+ }.compact
31
+ end
32
+ end
33
+
34
+ # 401 — bearer token missing, malformed, or revoked.
35
+ class AuthenticationError < Error; end
36
+
37
+ # 403 — token valid but lacks the required scope, OR the entity is
38
+ # not yet active, OR the API feature flag is off for this entity.
39
+ class ForbiddenError < Error; end
40
+
41
+ # 404 — the requested resource does not exist (or this entity cannot
42
+ # see it).
43
+ class NotFoundError < Error; end
44
+
45
+ # 422 — request body or query params failed validation. `#param`
46
+ # carries the offending field name when the API supplies it.
47
+ class ValidationError < Error; end
48
+
49
+ # 429 — rate limited. Retry after the `Retry-After` header (seconds).
50
+ class RateLimitError < Error
51
+ attr_reader :retry_after
52
+
53
+ def initialize(message = nil, retry_after: nil, **)
54
+ super(message, **)
55
+ @retry_after = retry_after
56
+ end
57
+ end
58
+
59
+ # 5xx — server error. Worth retrying once with backoff.
60
+ class ServerError < Error; end
61
+
62
+ # Network timeout, connection refused, DNS failure — anything that
63
+ # prevents the SDK from hearing back from the API.
64
+ class ConnectionError < Error; end
65
+
66
+ # The SDK was misconfigured (no API key, invalid base URL, etc.).
67
+ # Raised before any HTTP request is attempted.
68
+ class ConfigurationError < Error; end
69
+
70
+ # Caller passed a value the SDK refuses to send (e.g. `limit > 100`).
71
+ # Distinct from `ValidationError`, which represents server-side
72
+ # validation rejection.
73
+ class ArgumentError < Error; end
74
+ end
data/lib/zazu/page.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ # A single page of a list endpoint's response.
5
+ #
6
+ # The Zazu API uses cursor pagination. Every list endpoint returns
7
+ # `{data: [...], has_more: bool, next_cursor: string|null}`. This
8
+ # class wraps that shape and exposes a cursor-walking helper.
9
+ #
10
+ # Pages are intentionally not auto-paginating. The SDK refuses to
11
+ # let callers iterate every record across many pages with a single
12
+ # call — that's the failure mode that caused unbounded fetches in
13
+ # the CLI's `--all` flag. Callers walk pages explicitly:
14
+ #
15
+ # page = client.invoices.list(limit: 100)
16
+ # while page
17
+ # page.data.each { |inv| ... }
18
+ # page = page.next
19
+ # end
20
+ #
21
+ # Or with a max-items cap that the caller can reason about:
22
+ #
23
+ # client.invoices.each_page(max_items: 500) { |inv| ... }
24
+ class Page
25
+ # Hard ceiling on per-page size. Server enforces this too; we
26
+ # refuse to send a larger value rather than silently get clamped.
27
+ MAX_PER_PAGE = 100
28
+
29
+ attr_reader :response, :data, :has_more, :next_cursor
30
+
31
+ def initialize(response, fetcher:)
32
+ @response = response
33
+ @fetcher = fetcher
34
+
35
+ body = response.body
36
+ unless body.is_a?(Hash) && body["data"].is_a?(Array)
37
+ raise Zazu::Error.new("List response missing 'data' array",
38
+ body:)
39
+ end
40
+
41
+ @data = body["data"]
42
+ @has_more = body.fetch("has_more", false)
43
+ @next_cursor = body["next_cursor"]
44
+ end
45
+
46
+ def request_id
47
+ response.request_id
48
+ end
49
+
50
+ # Fetches the next page. Returns nil when there are no more pages.
51
+ def next
52
+ return nil unless has_more && next_cursor
53
+
54
+ @fetcher.call(next_cursor)
55
+ end
56
+
57
+ def each(&)
58
+ data.each(&)
59
+ end
60
+
61
+ include Enumerable
62
+
63
+ def inspect
64
+ "#<#{self.class.name} count=#{data.size} has_more=#{has_more} next_cursor=#{next_cursor.inspect}>"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Accounts and their transactions.
6
+ class Accounts < Base
7
+ # GET /api/accounts
8
+ #
9
+ # @param status [String, nil] filter by account status
10
+ # @param currency_code [String, nil] e.g. "MAD" or "ZAR"
11
+ # @param limit [Integer] page size (max 100)
12
+ # @param cursor [String, nil] pagination cursor
13
+ # @return [Zazu::Page]
14
+ def list(status: nil, currency_code: nil, limit: MAX_PER_PAGE, cursor: nil)
15
+ list_page(
16
+ "api/accounts",
17
+ status: status,
18
+ currency_code: currency_code,
19
+ limit: limit,
20
+ cursor: cursor
21
+ )
22
+ end
23
+
24
+ # GET /api/accounts/:id
25
+ def get(id)
26
+ http_get(encode_path("api/accounts", id))
27
+ end
28
+
29
+ # GET /api/accounts/:account_id/transactions
30
+ #
31
+ # @param operation [String, nil] filter by movement operation
32
+ # @param posted_after [String, Time, nil] ISO-8601 timestamp lower bound
33
+ # @param posted_before [String, Time, nil] ISO-8601 timestamp upper bound
34
+ def list_transactions(account_id, operation: nil, posted_after: nil, posted_before: nil, limit: MAX_PER_PAGE,
35
+ cursor: nil)
36
+ list_page(
37
+ encode_path("api/accounts", account_id, "transactions"),
38
+ operation: operation,
39
+ posted_after: serialize_time(posted_after),
40
+ posted_before: serialize_time(posted_before),
41
+ limit: limit,
42
+ cursor: cursor
43
+ )
44
+ end
45
+
46
+ # GET /api/accounts/:account_id/transactions/:id
47
+ def get_transaction(account_id, transaction_id)
48
+ http_get(encode_path("api/accounts", account_id, "transactions", transaction_id))
49
+ end
50
+
51
+ private
52
+
53
+ def serialize_time(value)
54
+ return nil if value.nil?
55
+ return value if value.is_a?(String)
56
+
57
+ value.iso8601
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Shared scaffolding for every resource module. Carries a back-
6
+ # reference to the client and exposes thin HTTP helpers that
7
+ # delegate to {Zazu::Client#request}.
8
+ #
9
+ # Note on naming: the helpers are `http_get`, `http_post`, etc.
10
+ # rather than `get`/`post` so they do not shadow the public
11
+ # methods on resource subclasses. Public resources commonly
12
+ # define a `get(id)` method, and a same-named private helper on
13
+ # the base class would let `Base#list_page` accidentally dispatch
14
+ # to the subclass version when a list endpoint is hit.
15
+ #
16
+ # Pagination:
17
+ #
18
+ # Every resource that has a list endpoint exposes `#list` which
19
+ # returns a {Zazu::Page}. Callers can walk pages explicitly via
20
+ # `page.next` or use `each_page_record` for capped iteration.
21
+ class Base
22
+ MAX_PER_PAGE = Page::MAX_PER_PAGE
23
+
24
+ attr_reader :client
25
+
26
+ def initialize(client)
27
+ @client = client
28
+ end
29
+
30
+ private
31
+
32
+ def http_get(path, params: nil)
33
+ client.request(:get, path, params: params)
34
+ end
35
+
36
+ def http_post(path, body: nil)
37
+ client.request(:post, path, body: body)
38
+ end
39
+
40
+ def http_patch(path, body: nil)
41
+ client.request(:patch, path, body: body)
42
+ end
43
+
44
+ def http_delete(path)
45
+ client.request(:delete, path)
46
+ end
47
+
48
+ # Builds a paginated list. `path` is the collection endpoint;
49
+ # `params` is everything else (filters, etc.). `limit` is enforced
50
+ # at MAX_PER_PAGE; the caller can pass `cursor:` to fetch a
51
+ # specific page.
52
+ def list_page(path, limit: MAX_PER_PAGE, cursor: nil, **params)
53
+ validated_limit = validate_limit!(limit)
54
+
55
+ fetcher = lambda { |next_cursor|
56
+ query = params.merge(limit: validated_limit, cursor: next_cursor).compact
57
+ response = http_get(path, params: query)
58
+ Page.new(response, fetcher: fetcher)
59
+ }
60
+
61
+ initial_query = params.merge(limit: validated_limit, cursor: cursor).compact
62
+ response = http_get(path, params: initial_query)
63
+ Page.new(response, fetcher: fetcher)
64
+ end
65
+
66
+ # Iterates list-endpoint records up to `max_items`, fetching
67
+ # additional pages on demand. Caps ensure callers never
68
+ # accidentally pull a full table.
69
+ #
70
+ # Pass either a block or get an Enumerator back.
71
+ def each_page_record(path, max_items:, **params, &block)
72
+ return enum_for(:each_page_record, path, max_items: max_items, **params) unless block
73
+
74
+ unless max_items.is_a?(Integer) && max_items.positive?
75
+ raise ArgumentError,
76
+ "max_items must be a positive integer"
77
+ end
78
+
79
+ seen = 0
80
+ page = list_page(path, **params)
81
+
82
+ loop do
83
+ page.data.each do |record|
84
+ return seen if seen >= max_items
85
+
86
+ yield record
87
+ seen += 1
88
+ end
89
+
90
+ break unless page.has_more && seen < max_items
91
+
92
+ page = page.next
93
+ break if page.nil?
94
+ end
95
+
96
+ seen
97
+ end
98
+
99
+ def validate_limit!(limit)
100
+ return MAX_PER_PAGE if limit.nil?
101
+
102
+ raise Zazu::ArgumentError, "limit must be a positive integer (got #{limit.inspect})" unless limit.is_a?(Integer) && limit.positive?
103
+
104
+ raise Zazu::ArgumentError, "limit cannot exceed #{MAX_PER_PAGE} (got #{limit})" if limit > MAX_PER_PAGE
105
+
106
+ limit
107
+ end
108
+
109
+ # Builds a request path by joining a literal base path with one
110
+ # or more dynamic segments. The base is appended verbatim; each
111
+ # dynamic segment is percent-encoded so an ID containing `/` or
112
+ # other special characters cannot escape the intended path.
113
+ #
114
+ # encode_path('api/accounts', 'acc_xyz')
115
+ # # => "api/accounts/acc_xyz"
116
+ #
117
+ # encode_path('api/accounts', 'acc 1', 'transactions', 'tx 1')
118
+ # # => "api/accounts/acc%201/transactions/tx%201"
119
+ def encode_path(base, *segments)
120
+ encoded_segments = segments.map do |s|
121
+ str = s.to_s
122
+ # An empty segment would silently turn `/things/:id` into
123
+ # `/things/`, which on most APIs redispatches to the list
124
+ # endpoint and returns a Page-shaped body. Surface it loudly.
125
+ raise Zazu::ArgumentError, "path segment cannot be blank" if str.empty?
126
+
127
+ # CGI.escape replaces ' ' with '+', which is wrong for path
128
+ # segments. Use a manual escape that targets only characters
129
+ # that would change path semantics.
130
+ str.gsub(/[^A-Za-z0-9._~-]/) { |c| format("%%%02X", c.ord) }
131
+ end
132
+ ([base] + encoded_segments).join("/")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Customers — individuals or businesses the entity invoices.
6
+ class Customers < Base
7
+ # GET /api/customers
8
+ #
9
+ # @param q [String, nil] search query (matches company name, person name, email)
10
+ def list(q: nil, limit: MAX_PER_PAGE, cursor: nil)
11
+ list_page("api/customers", q: q, limit: limit, cursor: cursor)
12
+ end
13
+
14
+ # GET /api/customers/:id
15
+ def get(id)
16
+ http_get(encode_path("api/customers", id))
17
+ end
18
+
19
+ # POST /api/customers
20
+ #
21
+ # @param attributes [Hash] customer attributes — see API docs.
22
+ # Common keys: customer_type ("individual"|"business"),
23
+ # person_name, company_name, email, phone, tax_id, ice_number,
24
+ # billing_address (Hash with street/city/postal_code/country/country_code).
25
+ def create(**attributes)
26
+ http_post("api/customers", body: attributes)
27
+ end
28
+
29
+ # PATCH /api/customers/:id
30
+ def update(id, **attributes)
31
+ http_patch(encode_path("api/customers", id), body: attributes)
32
+ end
33
+
34
+ # DELETE /api/customers/:id
35
+ def delete(id)
36
+ http_delete(encode_path("api/customers", id))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # The current entity (the tenant the API key belongs to).
6
+ #
7
+ # client.entity.get # => Zazu::Response
8
+ class Entity < Base
9
+ def get
10
+ http_get("api/entity")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Invoices and their lifecycle actions.
6
+ class Invoices < Base
7
+ # GET /api/invoices
8
+ def list(status: nil, customer_id: nil, limit: MAX_PER_PAGE, cursor: nil)
9
+ list_page(
10
+ "api/invoices",
11
+ status: status,
12
+ customer_id: customer_id,
13
+ limit: limit,
14
+ cursor: cursor
15
+ )
16
+ end
17
+
18
+ # GET /api/invoices/:id
19
+ def get(id)
20
+ http_get(encode_path("api/invoices", id))
21
+ end
22
+
23
+ # POST /api/invoices
24
+ def create(**attributes)
25
+ http_post("api/invoices", body: attributes)
26
+ end
27
+
28
+ # PATCH /api/invoices/:id
29
+ def update(id, **attributes)
30
+ http_patch(encode_path("api/invoices", id), body: attributes)
31
+ end
32
+
33
+ # POST /api/invoices/:id/send
34
+ def send_invoice(id)
35
+ http_post(encode_path("api/invoices", id, "send"))
36
+ end
37
+
38
+ # POST /api/invoices/:id/mark_as_paid
39
+ def mark_as_paid(id)
40
+ http_post(encode_path("api/invoices", id, "mark_as_paid"))
41
+ end
42
+
43
+ # POST /api/invoices/:id/cancel
44
+ def cancel(id)
45
+ http_post(encode_path("api/invoices", id, "cancel"))
46
+ end
47
+
48
+ # POST /api/invoices/:id/credit_note
49
+ def credit_note(id)
50
+ http_post(encode_path("api/invoices", id, "credit_note"))
51
+ end
52
+
53
+ # DELETE /api/invoices/:id
54
+ def delete(id)
55
+ http_delete(encode_path("api/invoices", id))
56
+ end
57
+
58
+ # POST /api/invoices/:invoice_id/payment_link
59
+ #
60
+ # @param account_id [String] the funding account for the link
61
+ def create_payment_link(invoice_id, account_id:)
62
+ http_post(
63
+ encode_path("api/invoices", invoice_id, "payment_link"),
64
+ body: { account_id: account_id }
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Standalone payment links (not attached to an invoice).
6
+ class PaymentLinks < Base
7
+ # GET /api/payment_links
8
+ def list(status: nil, link_type: nil, limit: MAX_PER_PAGE, cursor: nil)
9
+ list_page(
10
+ "api/payment_links",
11
+ status: status,
12
+ link_type: link_type,
13
+ limit: limit,
14
+ cursor: cursor
15
+ )
16
+ end
17
+
18
+ # GET /api/payment_links/:id
19
+ def get(id)
20
+ http_get(encode_path("api/payment_links", id))
21
+ end
22
+
23
+ # POST /api/payment_links
24
+ def create(**attributes)
25
+ http_post("api/payment_links", body: attributes)
26
+ end
27
+
28
+ # POST /api/payment_links/:id/cancel
29
+ def cancel(id)
30
+ http_post(encode_path("api/payment_links", id, "cancel"))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ module Resources
5
+ # Webhook endpoint configuration.
6
+ class WebhookEndpoints < Base
7
+ # GET /api/webhook_endpoints
8
+ def list(limit: MAX_PER_PAGE, cursor: nil)
9
+ list_page("api/webhook_endpoints", limit: limit, cursor: cursor)
10
+ end
11
+
12
+ # GET /api/webhook_endpoints/:id
13
+ def get(id)
14
+ http_get(encode_path("api/webhook_endpoints", id))
15
+ end
16
+
17
+ # POST /api/webhook_endpoints
18
+ #
19
+ # @param url [String] the URL to deliver events to
20
+ # @param events [Array<String>] event names to subscribe to
21
+ # @param description [String, nil]
22
+ def create(url:, events:, description: nil)
23
+ http_post(
24
+ "api/webhook_endpoints",
25
+ body: { url: url, events: events, description: description }.compact
26
+ )
27
+ end
28
+
29
+ # PATCH /api/webhook_endpoints/:id
30
+ def update(id, **attributes)
31
+ http_patch(encode_path("api/webhook_endpoints", id), body: attributes)
32
+ end
33
+
34
+ # DELETE /api/webhook_endpoints/:id
35
+ def delete(id)
36
+ http_delete(encode_path("api/webhook_endpoints", id))
37
+ end
38
+
39
+ # POST /api/webhook_endpoints/:id/test
40
+ def test_endpoint(id)
41
+ http_post(encode_path("api/webhook_endpoints", id, "test"))
42
+ end
43
+
44
+ # POST /api/webhook_endpoints/:id/regenerate_secret
45
+ def regenerate_secret(id)
46
+ http_post(encode_path("api/webhook_endpoints", id, "regenerate_secret"))
47
+ end
48
+
49
+ # POST /api/webhook_endpoints/:id/enable
50
+ def enable(id)
51
+ http_post(encode_path("api/webhook_endpoints", id, "enable"))
52
+ end
53
+
54
+ # POST /api/webhook_endpoints/:id/disable
55
+ def disable(id)
56
+ http_post(encode_path("api/webhook_endpoints", id, "disable"))
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ # Wraps a Faraday response with the few helpers callers actually
5
+ # want. Cheap value object — no parsing or normalization is done
6
+ # eagerly; `body`, `data`, `headers` all read straight through.
7
+ #
8
+ # The SDK's resource methods return one of these directly when the
9
+ # endpoint is a single-record fetch. List endpoints return a
10
+ # {Zazu::Page} instead, which composes a Response.
11
+ class Response
12
+ attr_reader :raw, :request_id
13
+
14
+ def initialize(raw, request_id: nil)
15
+ @raw = raw
16
+ @request_id = request_id || raw.headers["x-request-id"]
17
+ end
18
+
19
+ def status
20
+ raw.status
21
+ end
22
+
23
+ def headers
24
+ raw.headers
25
+ end
26
+
27
+ def body
28
+ raw.body
29
+ end
30
+
31
+ def success?
32
+ status.between?(200, 299)
33
+ end
34
+
35
+ # Returns the response body without the `data` envelope when one
36
+ # is present. List endpoints wrap their items in `{"data": [...]}`
37
+ # — this returns the array. Single-record endpoints return the
38
+ # body as-is.
39
+ def data
40
+ return body unless body.is_a?(Hash)
41
+
42
+ body.key?("data") ? body["data"] : body
43
+ end
44
+
45
+ # The Zazu-Version header echoed by the server. Useful for
46
+ # debugging migration mismatches.
47
+ def api_version
48
+ headers["zazu-version"]
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ status:,
54
+ request_id:,
55
+ api_version:,
56
+ body:
57
+ }.compact
58
+ end
59
+
60
+ def inspect
61
+ "#<#{self.class.name} status=#{status} request_id=#{request_id.inspect}>"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zazu
4
+ VERSION = "0.1.0"
5
+ end
data/lib/zazu.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby SDK for the Zazu API.
4
+ #
5
+ # Usage:
6
+ #
7
+ # zazu = Zazu.new(api_key: ENV["ZAZU_API_KEY"])
8
+ # zazu.entity.get
9
+ # zazu.accounts.list(limit: 50)
10
+ #
11
+ # See README.md for full documentation.
12
+ module Zazu
13
+ # Module-level shortcut. Equivalent to Zazu::Client.new(...).
14
+ def self.new(**)
15
+ Client.new(**)
16
+ end
17
+ end
18
+
19
+ require_relative "zazu/version"
20
+ require_relative "zazu/errors"
21
+ require_relative "zazu/response"
22
+ require_relative "zazu/page"
23
+ require_relative "zazu/resources/base"
24
+ require_relative "zazu/resources/accounts"
25
+ require_relative "zazu/resources/customers"
26
+ require_relative "zazu/resources/entity"
27
+ require_relative "zazu/resources/invoices"
28
+ require_relative "zazu/resources/payment_links"
29
+ require_relative "zazu/resources/webhook_endpoints"
30
+ require_relative "zazu/client"
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zazu-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Zazu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-retry
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: httpx
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: Faraday-based Ruby SDK for the Zazu payment platform API. Wraps accounts,
55
+ customers, invoices, payment links, transactions, and webhook endpoints. HTTPX adapter
56
+ for HTTP/2 + persistent connections.
57
+ email:
58
+ - hello@get-zazu.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - lib/zazu.rb
67
+ - lib/zazu/client.rb
68
+ - lib/zazu/errors.rb
69
+ - lib/zazu/page.rb
70
+ - lib/zazu/resources/accounts.rb
71
+ - lib/zazu/resources/base.rb
72
+ - lib/zazu/resources/customers.rb
73
+ - lib/zazu/resources/entity.rb
74
+ - lib/zazu/resources/invoices.rb
75
+ - lib/zazu/resources/payment_links.rb
76
+ - lib/zazu/resources/webhook_endpoints.rb
77
+ - lib/zazu/response.rb
78
+ - lib/zazu/version.rb
79
+ homepage: https://github.com/getzazu/zazu-ruby
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ source_code_uri: https://github.com/getzazu/zazu-ruby/tree/main
84
+ changelog_uri: https://github.com/getzazu/zazu-ruby/blob/main/CHANGELOG.md
85
+ bug_tracker_uri: https://github.com/getzazu/zazu-ruby/issues
86
+ documentation_uri: https://github.com/getzazu/zazu-ruby#readme
87
+ rubygems_mfa_required: 'true'
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.3.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 4.0.6
103
+ specification_version: 4
104
+ summary: Ruby SDK for the Zazu API
105
+ test_files: []