seatlayer 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +45 -17
- data/lib/seatlayer/channels.rb +45 -7
- data/lib/seatlayer/http_client.rb +38 -20
- data/lib/seatlayer/inventory.rb +62 -22
- data/lib/seatlayer/resources.rb +97 -16
- data/lib/seatlayer/version.rb +1 -1
- data/lib/seatlayer.rb +8 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7fdf50105cfef210f1afd2ae7073ce1b1da6c17a9ab3754204f3ebce145dcea7
|
|
4
|
+
data.tar.gz: 415f72edd18e2d97e603a563e2b33f4d920ae03f9e649e42bac287860cb4a3de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab1db651d6aafa3d2c2c238bc3ddab90d8c8d4c103bcd581fb34993e9aa25f0bc9028b8071cd992985d4ea641a03daa4b91349a3f1cafb815302ed0c6fdbb2c2
|
|
7
|
+
data.tar.gz: afc024d09db7ddeffc1fe399c04241e17f33a8fa52066d3372beae5032cd1cd08248214cea641a66a8c707919910f5d06e4ec766adbcd2bf12ed928b326dfff1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Added `templates.instantiate_template` and the ticket-release lifecycle on
|
|
6
|
+
`events` (`list_ticket_releases`, `update_ticket_releases`, and
|
|
7
|
+
`close_ticket_release`). Template instantiation sends `{}` when no overrides
|
|
8
|
+
are supplied, URI-escapes identifiers, and uses exact-response replay;
|
|
9
|
+
ticket-release writes remain single-attempt.
|
|
10
|
+
|
|
11
|
+
- **Security/reliability:** Mutations now default to a single attempt. Automatic header-replay
|
|
12
|
+
retries are limited to chart create/copy, template instantiation, event create, and workspace create, preventing
|
|
13
|
+
transient failures from duplicating holds or best-available results and from issuing extra
|
|
14
|
+
show-once credentials.
|
|
15
|
+
- Aligned event, inventory, webhook, manage-session, and Designer requests with the generated
|
|
16
|
+
public contract: event metadata, chart-update acknowledgement, trusted hold extension,
|
|
17
|
+
scheduled block release, hold-TTL reset, webhook envelopes and delivery filters, and Designer
|
|
18
|
+
safe-mode/feature-policy fields are now represented directly.
|
|
19
|
+
- Removed unsupported `state` and `cursor` keywords from buyer-access-session listing; the route
|
|
20
|
+
supports only `limit`.
|
|
21
|
+
- Added deterministic transport-contract coverage for stable error-code fallback, HTTP status,
|
|
22
|
+
decoded body and `X-Request-ID` exposure, typed 429 `Retry-After` precedence, non-JSON gateway
|
|
23
|
+
failures, and single-attempt unsafe mutations.
|
|
24
|
+
- Reached all 71 public operation wrappers with raw event-poster upload/removal and the complete
|
|
25
|
+
hosted access-link lifecycle. One-time capability reveals remain single-attempt.
|
|
26
|
+
- Added chart copy/metadata overrides, event-log pagination filters, and explicit-null support for
|
|
27
|
+
buyer-session and workspace fields.
|
|
28
|
+
|
|
3
29
|
## 0.2.0 — 2026-08-12
|
|
4
30
|
|
|
5
31
|
- Added the `channels` resource for allocation management, access previews,
|
data/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# SeatLayer Ruby SDK
|
|
2
2
|
|
|
3
|
+
[](https://github.com/seatlayer/seatlayer-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/seatlayer)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
3
8
|
Official Ruby server SDK for the [SeatLayer](https://seatlayer.io) reserved-seating API.
|
|
4
9
|
|
|
5
10
|
> **Server-side only.** This gem authenticates with your secret key. Never load it anywhere a
|
|
@@ -25,8 +30,8 @@ require "seatlayer"
|
|
|
25
30
|
|
|
26
31
|
client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY"))
|
|
27
32
|
|
|
28
|
-
# 1. Provision a venue for a new organiser from
|
|
29
|
-
chart = client.
|
|
33
|
+
# 1. Provision a venue for a new organiser from a public template.
|
|
34
|
+
chart = client.templates.instantiate_template("arena-standard")["meta"]
|
|
30
35
|
client.charts.publish(chart["id"])
|
|
31
36
|
|
|
32
37
|
# 2. Create an event on it.
|
|
@@ -38,6 +43,9 @@ held = client.inventory.hold_best_available(event["key"], qty: 4)
|
|
|
38
43
|
client.inventory.book(event["key"], hold_id: held["holdId"], booking_ref: "order-8842")
|
|
39
44
|
```
|
|
40
45
|
|
|
46
|
+
Nullable event-create fields distinguish omission from an explicit reset: passing, for example,
|
|
47
|
+
`venue: nil` sends JSON `null`; leaving `venue` out sends no field.
|
|
48
|
+
|
|
41
49
|
## Test vs live
|
|
42
50
|
|
|
43
51
|
Keys carry their own mode. `sk_test_…` keys can only touch test-mode events and `sk_live_…` only
|
|
@@ -150,11 +158,9 @@ session = client.sessions.create_manage_session(
|
|
|
150
158
|
)
|
|
151
159
|
```
|
|
152
160
|
|
|
153
|
-
`capabilities` is **required** by this SDK even though the API defaults
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
payment gateway**. That is real money, moved by a token you handed to a browser; it should not
|
|
157
|
-
arrive by forgetting an argument. Grant the smallest set the page needs.
|
|
161
|
+
`capabilities` is **required** by this SDK even though the raw API safely defaults an omitted list
|
|
162
|
+
to view-only (`event:view`). Keeping the argument required makes browser authority visible at every
|
|
163
|
+
call site. Grant the smallest set the page needs.
|
|
158
164
|
|
|
159
165
|
The full set, all opt-in:
|
|
160
166
|
|
|
@@ -166,13 +172,27 @@ The full set, all opt-in:
|
|
|
166
172
|
| `event:reports` | Read sales and availability reports |
|
|
167
173
|
| `event:channels:view` | Read sales channels and their allocations |
|
|
168
174
|
| `event:channels:manage` | Create, pause and archive channels; rotate access links |
|
|
175
|
+
| `event:orders:read` | Read SeatLayer-managed orders |
|
|
176
|
+
| `event:refund` | Refund a SeatLayer-managed order |
|
|
177
|
+
| `event:tickets:send` | Send SeatLayer-managed tickets |
|
|
178
|
+
| `event:door:view` | Read the door list |
|
|
179
|
+
| `event:door:checkin` | Check tickets in and out |
|
|
180
|
+
| `event:boxoffice` | Use the managed box-office surface |
|
|
169
181
|
|
|
170
182
|
The two `event:channels:*` capabilities are **not** in the default — a token minted before sales
|
|
171
183
|
channels existed must not silently acquire channel authority — so ask for them explicitly if the
|
|
172
184
|
page manages channels.
|
|
173
185
|
|
|
186
|
+
Designer minting returns the API envelope unchanged: read the token and effective safe-mode and
|
|
187
|
+
feature policy under `result["session"]`. Pass `safe_mode_options` only with `mode: "safe"`.
|
|
188
|
+
|
|
174
189
|
## Webhooks
|
|
175
190
|
|
|
191
|
+
Subscription responses use the wire envelopes exactly: `list` returns `{"subs" => [...]}`,
|
|
192
|
+
`create` returns `{"sub" => ..., "secret" => ...}` (the secret is shown once), and `update`
|
|
193
|
+
returns `{"sub" => ...}`. `SeatLayer::Webhooks::EVENT_NAMES` is the exact eight-name event set;
|
|
194
|
+
delivery history accepts `limit`, `status` (`"ok"` or `"failed"`), and `before`.
|
|
195
|
+
|
|
176
196
|
Verify every delivery against the **raw** body. Re-encoding a parsed Hash changes the bytes and
|
|
177
197
|
verification will fail.
|
|
178
198
|
|
|
@@ -231,15 +251,21 @@ error carries `status`, `code`, `body` and `request_id` — quote the request id
|
|
|
231
251
|
|
|
232
252
|
## Reliability
|
|
233
253
|
|
|
234
|
-
**Retries.** 429, 408 and 5xx
|
|
235
|
-
wins when the server sends it.
|
|
254
|
+
**Retries.** Reads (`GET`/`HEAD`) retry 429, 408 and 5xx with exponential backoff and full jitter;
|
|
255
|
+
`Retry-After` wins when the server sends it. Automatic mutation retries are limited to the five
|
|
256
|
+
operations backed by exact response replay: `charts.create`, `charts.copy`,
|
|
257
|
+
`templates.instantiate_template`, `events.create`, and `workspaces.create`. Other 4xx responses
|
|
258
|
+
are never retried.
|
|
236
259
|
|
|
237
|
-
**Idempotency.**
|
|
238
|
-
one
|
|
239
|
-
|
|
260
|
+
**Idempotency.** Those five replay-backed operations carry an `Idempotency-Key`, generated when you
|
|
261
|
+
do not supply one and reused across attempts. Other mutations are single-attempt and receive no
|
|
262
|
+
automatic key. A caller-supplied key is forwarded but does not enable retries. This includes
|
|
263
|
+
inventory holds and bookings, show-once credential or secret creation, unsupported operations, and
|
|
264
|
+
raw `request` mutations. Keep `booking_ref` in the booking body for reconciliation, but handle an
|
|
265
|
+
unknown network outcome explicitly instead of automatically repeating the sale.
|
|
240
266
|
|
|
241
267
|
```ruby
|
|
242
|
-
client.
|
|
268
|
+
client.events.create(chart_id: chart_id, idempotency_key: "provision-event-#{event_id}")
|
|
243
269
|
```
|
|
244
270
|
|
|
245
271
|
```ruby
|
|
@@ -252,7 +278,8 @@ SeatLayer::Client.new(
|
|
|
252
278
|
|
|
253
279
|
## Escape hatch
|
|
254
280
|
|
|
255
|
-
For surface this SDK does not wrap yet
|
|
281
|
+
For surface this SDK does not wrap yet, `request` keeps auth and error mapping. Raw reads retain the
|
|
282
|
+
read retry policy; raw mutations are always single-attempt because their replay contract is unknown:
|
|
256
283
|
|
|
257
284
|
```ruby
|
|
258
285
|
client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
|
|
@@ -263,9 +290,10 @@ client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
|
|
|
263
290
|
| Resource | Methods |
|
|
264
291
|
| --- | --- |
|
|
265
292
|
| `charts` | `list` `list_all` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` |
|
|
266
|
-
| `
|
|
293
|
+
| `templates` | `instantiate_template` |
|
|
294
|
+
| `events` | `list` `list_all` `create` `retrieve` `update` `delete` `update_poster` `delete_poster` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `list_ticket_releases` `update_ticket_releases` `close_ticket_release` `retrieve_report` `retrieve_log` |
|
|
267
295
|
| `inventory` | `hold` `hold_best_available` `book_best_available` `extend_hold` `retrieve_hold` `release` `book` `box_office_book` `unbook` `list_bookings` `retrieve_booking` `block` `unblock` `unblock_all` `retrieve_availability` `update_availability` |
|
|
268
|
-
| `channels` | `list_channels` `create_channel` `update_channel` `update_assignments` `list_allocation` `retrieve_access_preview` `retrieve_report` `pause` `unpause` `archive` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` |
|
|
296
|
+
| `channels` | `list_channels` `create_channel` `update_channel` `update_assignments` `list_allocation` `retrieve_access_preview` `retrieve_report` `pause` `unpause` `archive` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` `create_access_link` `list_access_links` `rotate_access_link` `revoke_access_link` |
|
|
269
297
|
| `sessions` | `create_manage_session` `revoke_manage_session` `create_designer_session` `revoke_designer_session` |
|
|
270
298
|
| `webhooks` | `list` `create` `update` `delete` `list_deliveries` |
|
|
271
299
|
| `workspaces` | `list` `create` `retrieve` `update` |
|
|
@@ -311,7 +339,7 @@ the public manifest, not just from the wrapper.
|
|
|
311
339
|
| Node.js (server) | [`@seatlayer/server`](https://www.npmjs.com/package/@seatlayer/server) |
|
|
312
340
|
| Python (server) | [`seatlayer`](https://pypi.org/project/seatlayer/) |
|
|
313
341
|
| PHP (server) | [`seatlayer/seatlayer-php`](https://packagist.org/packages/seatlayer/seatlayer-php) |
|
|
314
|
-
| Java (server) | [`io.seatlayer:seatlayer-java`](https://central.sonatype.com/artifact/io.seatlayer/seatlayer-java/0.
|
|
342
|
+
| Java (server) | [`io.seatlayer:seatlayer-java`](https://central.sonatype.com/artifact/io.seatlayer/seatlayer-java/0.3.0) |
|
|
315
343
|
| Go (server) | [`github.com/seatlayer/seatlayer-go`](https://pkg.go.dev/github.com/seatlayer/seatlayer-go) |
|
|
316
344
|
| .NET (server) | [`SeatLayer`](https://www.nuget.org/packages/SeatLayer) |
|
|
317
345
|
|
data/lib/seatlayer/channels.rb
CHANGED
|
@@ -65,26 +65,64 @@ module SeatLayer
|
|
|
65
65
|
# Explicit keywords document each security boundary carried by the token.
|
|
66
66
|
# rubocop:disable Metrics/ParameterLists
|
|
67
67
|
def create_buyer_access_session(event_key, include_public:, allowed_origin:, channel_ids: nil,
|
|
68
|
-
expires_in_seconds: nil, max_quantity:
|
|
69
|
-
partner_ref:
|
|
68
|
+
expires_in_seconds: nil, max_quantity: UNSET, buyer_ref: UNSET,
|
|
69
|
+
partner_ref: UNSET, client_request_id: UNSET, idempotency_key: nil)
|
|
70
70
|
body = compact({ "channelIds" => channel_ids, "includePublic" => include_public,
|
|
71
|
-
"allowedOrigin" => allowed_origin, "expiresInSeconds" => expires_in_seconds
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
"allowedOrigin" => allowed_origin, "expiresInSeconds" => expires_in_seconds })
|
|
72
|
+
body.merge!(supplied({ "maxQuantity" => max_quantity, "buyerRef" => buyer_ref,
|
|
73
|
+
"partnerRef" => partner_ref,
|
|
74
|
+
"clientRequestId" => client_request_id }))
|
|
74
75
|
@client.post("/v1/events/#{encode(event_key)}/buyer-access-sessions", body,
|
|
75
76
|
idempotency_key: idempotency_key)
|
|
76
77
|
end
|
|
77
78
|
# rubocop:enable Metrics/ParameterLists
|
|
78
79
|
|
|
79
|
-
def list_buyer_access_sessions(event_key,
|
|
80
|
+
def list_buyer_access_sessions(event_key, limit: nil)
|
|
80
81
|
@client.get("/v1/events/#{encode(event_key)}/buyer-access-sessions",
|
|
81
|
-
compact({ "
|
|
82
|
+
compact({ "limit" => limit }))
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def revoke_buyer_access_session(event_key, session_id)
|
|
85
86
|
@client.delete("/v1/events/#{encode(event_key)}/buyer-access-sessions/#{encode(session_id)}")
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
# The URL and capability in this response are revealed once. Persist them
|
|
90
|
+
# immediately; this mutation is deliberately never retried automatically.
|
|
91
|
+
# rubocop:disable Metrics/ParameterLists
|
|
92
|
+
def create_access_link(event_key, channel_id, label: UNSET, expires_at: nil,
|
|
93
|
+
max_redemptions: nil, max_quantity: nil,
|
|
94
|
+
session_ttl_seconds: nil, include_public: nil, reason: nil,
|
|
95
|
+
idempotency_key: nil)
|
|
96
|
+
body = compact({ "expiresAt" => expires_at, "maxRedemptions" => max_redemptions,
|
|
97
|
+
"maxQuantity" => max_quantity,
|
|
98
|
+
"sessionTtlSeconds" => session_ttl_seconds,
|
|
99
|
+
"includePublic" => include_public, "reason" => reason })
|
|
100
|
+
body.merge!(supplied({ "label" => label }))
|
|
101
|
+
@client.post(path(event_key, "/#{encode(channel_id)}/access-links"), body,
|
|
102
|
+
idempotency_key: idempotency_key)
|
|
103
|
+
end
|
|
104
|
+
# rubocop:enable Metrics/ParameterLists
|
|
105
|
+
|
|
106
|
+
# Status only: the API never returns a previously revealed capability.
|
|
107
|
+
def list_access_links(event_key, channel_id)
|
|
108
|
+
@client.get(path(event_key, "/#{encode(channel_id)}/access-links"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rotate_access_link(event_key, channel_id, link_id, end_active_sessions:, reason: nil)
|
|
112
|
+
body = compact({ "endActiveSessions" => end_active_sessions, "reason" => reason })
|
|
113
|
+
@client.post(
|
|
114
|
+
path(event_key, "/#{encode(channel_id)}/access-links/#{encode(link_id)}/rotate"), body
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def revoke_access_link(event_key, channel_id, link_id, end_active_sessions: false, reason: nil)
|
|
119
|
+
query = compact({ "endActiveSessions" => end_active_sessions ? "1" : nil,
|
|
120
|
+
"reason" => reason })
|
|
121
|
+
@client.delete(
|
|
122
|
+
path(event_key, "/#{encode(channel_id)}/access-links/#{encode(link_id)}"), query
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
88
126
|
private
|
|
89
127
|
|
|
90
128
|
def path(event_key, suffix = "") = "/v1/events/#{encode(event_key)}/channels#{suffix}"
|
|
@@ -62,7 +62,8 @@ module SeatLayer
|
|
|
62
62
|
URI.encode_www_form_component(segment.to_s).gsub("+", "%20")
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
def request(method, path, query: nil, body: nil,
|
|
65
|
+
def request(method, path, query: nil, body: nil, raw_body: nil, content_type: nil,
|
|
66
|
+
idempotency_key: nil, retry_policy: :none)
|
|
66
67
|
url = @base_url + path
|
|
67
68
|
if query
|
|
68
69
|
pairs = query.compact
|
|
@@ -74,56 +75,73 @@ module SeatLayer
|
|
|
74
75
|
"Accept" => "application/json",
|
|
75
76
|
"User-Agent" => "seatlayer-ruby"
|
|
76
77
|
}
|
|
77
|
-
payload =
|
|
78
|
-
if body
|
|
79
|
-
payload = JSON.generate(body)
|
|
80
|
-
headers["Content-Type"] = "application/json"
|
|
81
|
-
end
|
|
78
|
+
payload = build_payload(body, raw_body, content_type, headers)
|
|
82
79
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
80
|
+
# Only operations with exact server-side response replay get an automatic
|
|
81
|
+
# key. A caller key on any other mutation is forwarded, but cannot opt that
|
|
82
|
+
# operation into automatic retries.
|
|
86
83
|
unless %w[GET HEAD].include?(method)
|
|
87
|
-
key = idempotency_key
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
key = idempotency_key
|
|
85
|
+
key ||= SecureRandom.uuid if retry_policy == :header_replay
|
|
86
|
+
if key
|
|
87
|
+
self.class.validate_idempotency_key!(key)
|
|
88
|
+
headers["Idempotency-Key"] = key
|
|
89
|
+
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
retry_allowed = %w[GET HEAD].include?(method) || retry_policy == :header_replay
|
|
93
|
+
execute(method, url, headers, payload, retry_allowed: retry_allowed)
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
def get(path, query = nil)
|
|
96
97
|
request("GET", path, query: query)
|
|
97
98
|
end
|
|
98
99
|
|
|
99
|
-
def post(path, body = nil, idempotency_key: nil)
|
|
100
|
-
request("POST", path, body: body, idempotency_key: idempotency_key)
|
|
100
|
+
def post(path, body = nil, idempotency_key: nil, retry_policy: :none)
|
|
101
|
+
request("POST", path, body: body, idempotency_key: idempotency_key, retry_policy: retry_policy)
|
|
101
102
|
end
|
|
102
103
|
|
|
103
104
|
def put(path, body)
|
|
104
105
|
request("PUT", path, body: body)
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
def put_raw(path, raw_body, content_type: "application/octet-stream")
|
|
109
|
+
request("PUT", path, raw_body: raw_body, content_type: content_type)
|
|
110
|
+
end
|
|
111
|
+
|
|
107
112
|
def patch(path, body)
|
|
108
113
|
request("PATCH", path, body: body)
|
|
109
114
|
end
|
|
110
115
|
|
|
111
|
-
def delete(path)
|
|
112
|
-
request("DELETE", path)
|
|
116
|
+
def delete(path, query = nil)
|
|
117
|
+
request("DELETE", path, query: query)
|
|
113
118
|
end
|
|
114
119
|
|
|
115
120
|
private
|
|
116
121
|
|
|
122
|
+
def build_payload(body, raw_body, content_type, headers)
|
|
123
|
+
raise ArgumentError, "body and raw_body are mutually exclusive" if !body.nil? && !raw_body.nil?
|
|
124
|
+
|
|
125
|
+
unless raw_body.nil?
|
|
126
|
+
headers["Content-Type"] = content_type || "application/octet-stream"
|
|
127
|
+
return raw_body
|
|
128
|
+
end
|
|
129
|
+
return if body.nil?
|
|
130
|
+
|
|
131
|
+
headers["Content-Type"] = "application/json"
|
|
132
|
+
JSON.generate(body)
|
|
133
|
+
end
|
|
134
|
+
|
|
117
135
|
# The retry loop, extracted from #request so each piece stays readable: the
|
|
118
136
|
# public method builds the call, this one decides how many times to make it.
|
|
119
|
-
def execute(method, url, headers, payload)
|
|
137
|
+
def execute(method, url, headers, payload, retry_allowed:)
|
|
120
138
|
last_error = nil
|
|
121
139
|
|
|
122
140
|
@max_retries.times do |attempt|
|
|
123
141
|
begin
|
|
124
142
|
response = send_request(method, url, headers, payload)
|
|
125
143
|
rescue ConnectionError => e
|
|
126
|
-
raise e
|
|
144
|
+
raise e unless retry_allowed && attempt < @max_retries - 1
|
|
127
145
|
|
|
128
146
|
last_error = e
|
|
129
147
|
sleep(backoff(attempt, nil))
|
|
@@ -136,7 +154,7 @@ module SeatLayer
|
|
|
136
154
|
error_body = decode_error_body(response[:body])
|
|
137
155
|
retry_after = parse_retry_after(response[:headers], error_body)
|
|
138
156
|
|
|
139
|
-
if retryable?(status) && attempt < @max_retries - 1
|
|
157
|
+
if retry_allowed && retryable?(status) && attempt < @max_retries - 1
|
|
140
158
|
sleep(backoff(attempt, status == 429 ? retry_after : nil))
|
|
141
159
|
next
|
|
142
160
|
end
|
data/lib/seatlayer/inventory.rb
CHANGED
|
@@ -62,8 +62,13 @@ module SeatLayer
|
|
|
62
62
|
# the checkout window — invoiced sales, a phone order on hold. Releasing
|
|
63
63
|
# first hands the seats to whoever is racing for them in between. A hold that
|
|
64
64
|
# is gone, expired, or at its renewal cap answers 409 +cannot_extend+.
|
|
65
|
-
def extend_hold(event_key, hold_id, ttl_ms: nil
|
|
66
|
-
|
|
65
|
+
def extend_hold(event_key, hold_id, ttl_ms: nil, channel_ids: nil,
|
|
66
|
+
ignore_channel_restrictions: nil, reason: nil)
|
|
67
|
+
body = compact({ "holdId" => hold_id, "ttlMs" => ttl_ms,
|
|
68
|
+
"channelIds" => channel_ids,
|
|
69
|
+
"ignoreChannelRestrictions" => ignore_channel_restrictions,
|
|
70
|
+
"reason" => reason })
|
|
71
|
+
@client.post(path(event_key, "/extend"), body)
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
# Authoritative items and prices. Charge from this, not the browser.
|
|
@@ -98,8 +103,9 @@ module SeatLayer
|
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
# Hold inventory back from sale (house seats, production holds).
|
|
101
|
-
def block(event_key, labels:)
|
|
102
|
-
@client.post(path(event_key, "/block"),
|
|
106
|
+
def block(event_key, labels:, release_at: nil)
|
|
107
|
+
@client.post(path(event_key, "/block"),
|
|
108
|
+
compact({ "labels" => labels, "releaseAt" => release_at }))
|
|
103
109
|
end
|
|
104
110
|
|
|
105
111
|
def unblock(event_key, labels:)
|
|
@@ -147,24 +153,29 @@ module SeatLayer
|
|
|
147
153
|
# The governing rule: the SDK mints tokens, widgets consume them. Your secret
|
|
148
154
|
# key never reaches a browser.
|
|
149
155
|
class Sessions < Resource
|
|
150
|
-
CAPABILITIES = [
|
|
156
|
+
CAPABILITIES = [
|
|
157
|
+
"event:view", "event:block", "event:cancel", "event:reports",
|
|
158
|
+
"event:channels:view", "event:channels:manage", "event:orders:read",
|
|
159
|
+
"event:refund", "event:tickets:send", "event:door:view",
|
|
160
|
+
"event:door:checkin", "event:boxoffice"
|
|
161
|
+
].freeze
|
|
151
162
|
|
|
152
163
|
# Mint a manage-session token for the control room.
|
|
153
164
|
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def create_manage_session(event_key, allowed_origin:, capabilities:, expires_in_seconds: nil)
|
|
165
|
+
# The raw API defaults an omitted list to view-only (+event:view+). This SDK
|
|
166
|
+
# still requires an explicit set so browser authority is visible at each call.
|
|
167
|
+
def create_manage_session(event_key, allowed_origin:, capabilities:, expires_in_seconds: nil,
|
|
168
|
+
workspace_id: nil)
|
|
159
169
|
if capabilities.nil? || capabilities.empty?
|
|
160
170
|
raise ArgumentError,
|
|
161
|
-
|
|
162
|
-
'["event:view"]. Omitting it server-side grants event:cancel, ' \
|
|
163
|
-
"which can reverse paid bookings."
|
|
171
|
+
'capabilities is required: pass the smallest set the page needs, e.g. ["event:view"].'
|
|
164
172
|
end
|
|
173
|
+
unknown = capabilities - CAPABILITIES
|
|
174
|
+
raise ArgumentError, "unsupported manage capabilities: #{unknown.join(", ")}" unless unknown.empty?
|
|
165
175
|
|
|
166
176
|
body = compact({ "allowedOrigin" => allowed_origin, "capabilities" => capabilities,
|
|
167
|
-
"expiresInSeconds" => expires_in_seconds
|
|
177
|
+
"expiresInSeconds" => expires_in_seconds,
|
|
178
|
+
"workspaceId" => workspace_id })
|
|
168
179
|
@client.post("/v1/events/#{encode(event_key)}/manage-sessions", body)
|
|
169
180
|
end
|
|
170
181
|
|
|
@@ -174,13 +185,19 @@ module SeatLayer
|
|
|
174
185
|
|
|
175
186
|
# Mint a designer token so an organiser can edit a chart inside your own UI.
|
|
176
187
|
# Requires a chart id that already exists — create or copy one first.
|
|
188
|
+
# Explicit keywords keep each security and feature-policy boundary visible.
|
|
189
|
+
# rubocop:disable Metrics/ParameterLists
|
|
177
190
|
def create_designer_session(workspace_id:, chart_id:, allowed_origin:,
|
|
178
|
-
authority: nil,
|
|
191
|
+
authority: nil, can_publish: nil, mode: nil,
|
|
192
|
+
safe_mode_options: nil, features: nil, expires_in_seconds: nil)
|
|
179
193
|
body = compact({ "workspaceId" => workspace_id, "chartId" => chart_id,
|
|
180
194
|
"allowedOrigin" => allowed_origin, "authority" => authority,
|
|
181
|
-
"
|
|
195
|
+
"canPublish" => can_publish, "mode" => mode,
|
|
196
|
+
"safeModeOptions" => safe_mode_options, "features" => features,
|
|
197
|
+
"expiresInSeconds" => expires_in_seconds })
|
|
182
198
|
@client.post("/v1/designer/sessions", body)
|
|
183
199
|
end
|
|
200
|
+
# rubocop:enable Metrics/ParameterLists
|
|
184
201
|
|
|
185
202
|
def revoke_designer_session(session_id)
|
|
186
203
|
@client.delete("/v1/designer/sessions/#{encode(session_id)}")
|
|
@@ -189,15 +206,23 @@ module SeatLayer
|
|
|
189
206
|
|
|
190
207
|
# Manage webhook subscriptions. To VERIFY a delivery, see SeatLayer::Webhook.
|
|
191
208
|
class Webhooks < Resource
|
|
209
|
+
EVENT_NAMES = [
|
|
210
|
+
"seat.booked", "seat.released", "seat.blocked", "hold.expired",
|
|
211
|
+
"hold.created", "hold.extended", "event.created", "event.soldout"
|
|
212
|
+
].freeze
|
|
213
|
+
|
|
192
214
|
def list
|
|
193
215
|
@client.get("/v1/webhooks")
|
|
194
216
|
end
|
|
195
217
|
|
|
196
218
|
def create(url:, events:)
|
|
219
|
+
validate_events!(events)
|
|
197
220
|
@client.post("/v1/webhooks", { "url" => url, "events" => events })
|
|
198
221
|
end
|
|
199
222
|
|
|
200
|
-
def update(webhook_id,
|
|
223
|
+
def update(webhook_id, url: nil, events: nil, disabled: nil)
|
|
224
|
+
validate_events!(events) unless events.nil?
|
|
225
|
+
fields = compact({ "url" => url, "events" => events, "disabled" => disabled })
|
|
201
226
|
@client.patch("/v1/webhooks/#{encode(webhook_id)}", fields)
|
|
202
227
|
end
|
|
203
228
|
|
|
@@ -205,8 +230,20 @@ module SeatLayer
|
|
|
205
230
|
@client.delete("/v1/webhooks/#{encode(webhook_id)}")
|
|
206
231
|
end
|
|
207
232
|
|
|
208
|
-
def list_deliveries(webhook_id)
|
|
209
|
-
|
|
233
|
+
def list_deliveries(webhook_id, limit: nil, status: nil, before: nil)
|
|
234
|
+
raise ArgumentError, "status must be ok or failed" unless status.nil? || %w[ok failed].include?(status)
|
|
235
|
+
|
|
236
|
+
query = compact({ "limit" => limit, "status" => status, "before" => before })
|
|
237
|
+
@client.get("/v1/webhooks/#{encode(webhook_id)}/deliveries", query)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def validate_events!(events)
|
|
243
|
+
unknown = Array(events) - EVENT_NAMES
|
|
244
|
+
return if !Array(events).empty? && unknown.empty?
|
|
245
|
+
|
|
246
|
+
raise ArgumentError, "events must contain only supported SeatLayer webhook event names"
|
|
210
247
|
end
|
|
211
248
|
end
|
|
212
249
|
|
|
@@ -216,9 +253,12 @@ module SeatLayer
|
|
|
216
253
|
@client.get("/v1/workspaces")
|
|
217
254
|
end
|
|
218
255
|
|
|
219
|
-
def create(name:, external_ref:
|
|
220
|
-
body =
|
|
221
|
-
|
|
256
|
+
def create(name:, external_ref: UNSET, idempotency_key: nil)
|
|
257
|
+
body = { "name" => name }
|
|
258
|
+
body.merge!(supplied({ "externalRef" => external_ref }))
|
|
259
|
+
@client.post(
|
|
260
|
+
"/v1/workspaces", body, idempotency_key: idempotency_key, retry_policy: :header_replay
|
|
261
|
+
)
|
|
222
262
|
end
|
|
223
263
|
|
|
224
264
|
def retrieve(workspace_id)
|
data/lib/seatlayer/resources.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module SeatLayer
|
|
4
4
|
# Shared plumbing for the resource namespaces.
|
|
5
5
|
class Resource
|
|
6
|
+
UNSET = Object.new.freeze
|
|
7
|
+
|
|
6
8
|
def initialize(client)
|
|
7
9
|
@client = client
|
|
8
10
|
end
|
|
@@ -15,6 +17,11 @@ module SeatLayer
|
|
|
15
17
|
hash.compact
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# Drop only the private sentinel, preserving nil as explicit JSON null.
|
|
21
|
+
def supplied(hash)
|
|
22
|
+
hash.reject { |_key, value| value.equal?(UNSET) }
|
|
23
|
+
end
|
|
24
|
+
|
|
18
25
|
def encode(segment)
|
|
19
26
|
HTTPClient.encode(segment)
|
|
20
27
|
end
|
|
@@ -58,14 +65,16 @@ module SeatLayer
|
|
|
58
65
|
def create(name:, doc: nil, external_ref: nil, workspace_id: nil, idempotency_key: nil)
|
|
59
66
|
body = compact({ "name" => name, "doc" => doc,
|
|
60
67
|
"externalRef" => external_ref, "workspaceId" => workspace_id })
|
|
61
|
-
@client.post(
|
|
68
|
+
@client.post(
|
|
69
|
+
"/v1/charts", body, idempotency_key: idempotency_key, retry_policy: :header_replay
|
|
70
|
+
)
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
def retrieve(chart_id)
|
|
65
74
|
@client.get("/v1/charts/#{encode(chart_id)}")
|
|
66
75
|
end
|
|
67
76
|
|
|
68
|
-
# Replace a chart document.
|
|
77
|
+
# Replace a chart document or update metadata only.
|
|
69
78
|
#
|
|
70
79
|
# +expected_updated_at+ is required for optimistic concurrency and is not
|
|
71
80
|
# optional here either: without it two concurrent writers silently overwrite
|
|
@@ -74,8 +83,20 @@ module SeatLayer
|
|
|
74
83
|
#
|
|
75
84
|
# The Designer is the authoring surface. Use this for bulk programmatic edits
|
|
76
85
|
# and migrations, not for drawing.
|
|
77
|
-
def update(chart_id, doc
|
|
78
|
-
|
|
86
|
+
def update(chart_id, doc: UNSET, expected_updated_at: UNSET, name: nil, issues: nil,
|
|
87
|
+
external_ref: UNSET)
|
|
88
|
+
doc_supplied = !doc.equal?(UNSET)
|
|
89
|
+
expected_supplied = !expected_updated_at.equal?(UNSET)
|
|
90
|
+
unless doc_supplied == expected_supplied
|
|
91
|
+
raise ArgumentError, "doc and expected_updated_at must be supplied together"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
body = compact({ "name" => name, "issues" => issues })
|
|
95
|
+
if doc_supplied
|
|
96
|
+
body["doc"] = doc
|
|
97
|
+
body["expectedUpdatedAt"] = expected_updated_at
|
|
98
|
+
end
|
|
99
|
+
body.merge!(supplied({ "externalRef" => external_ref }))
|
|
79
100
|
@client.put("/v1/charts/#{encode(chart_id)}", body)
|
|
80
101
|
end
|
|
81
102
|
|
|
@@ -84,8 +105,14 @@ module SeatLayer
|
|
|
84
105
|
end
|
|
85
106
|
|
|
86
107
|
# Copy a chart — the usual way to provision a venue from a template.
|
|
87
|
-
def copy(chart_id, idempotency_key: nil
|
|
88
|
-
|
|
108
|
+
def copy(chart_id, idempotency_key: nil, name: nil, external_ref: UNSET,
|
|
109
|
+
workspace_id: nil)
|
|
110
|
+
body = compact({ "name" => name, "workspaceId" => workspace_id })
|
|
111
|
+
body.merge!(supplied({ "externalRef" => external_ref }))
|
|
112
|
+
@client.post(
|
|
113
|
+
"/v1/charts/#{encode(chart_id)}/duplicate", body.empty? ? nil : body,
|
|
114
|
+
idempotency_key: idempotency_key, retry_policy: :header_replay
|
|
115
|
+
)
|
|
89
116
|
end
|
|
90
117
|
|
|
91
118
|
def archive(chart_id)
|
|
@@ -102,6 +129,23 @@ module SeatLayer
|
|
|
102
129
|
end
|
|
103
130
|
end
|
|
104
131
|
|
|
132
|
+
# Published SeatLayer catalogue templates.
|
|
133
|
+
#
|
|
134
|
+
# Instantiation creates an independent draft chart. Publish that returned
|
|
135
|
+
# chart before creating an event from it.
|
|
136
|
+
class Templates < Resource
|
|
137
|
+
# Instantiate a public template into a new draft chart.
|
|
138
|
+
#
|
|
139
|
+
# +fields+ intentionally defaults to an empty Hash: the API requires a JSON
|
|
140
|
+
# object even when there are no overrides, and JSON.generate({}) is `{}`.
|
|
141
|
+
def instantiate_template(template_id, fields: {}, idempotency_key: nil)
|
|
142
|
+
@client.post(
|
|
143
|
+
"/v1/templates/#{encode(template_id)}/instantiate", fields,
|
|
144
|
+
idempotency_key: idempotency_key, retry_policy: :header_replay
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
105
149
|
# Event lifecycle, metadata and reports.
|
|
106
150
|
class Events < Resource
|
|
107
151
|
# One page of events.
|
|
@@ -131,13 +175,22 @@ module SeatLayer
|
|
|
131
175
|
end
|
|
132
176
|
end
|
|
133
177
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
178
|
+
# rubocop:disable Metrics/ParameterLists
|
|
179
|
+
def create(chart_id:, name: nil, slug: nil, starts_at: UNSET, venue: UNSET,
|
|
180
|
+
external_ref: UNSET, currency: UNSET, idempotency_key: nil,
|
|
181
|
+
description: UNSET, ends_at: UNSET, timezone: UNSET, locale: UNSET,
|
|
182
|
+
poster_asset_id: UNSET, mode: nil)
|
|
183
|
+
body = compact({ "chartId" => chart_id, "name" => name, "slug" => slug, "mode" => mode })
|
|
184
|
+
body.merge!(supplied({ "startsAt" => starts_at, "venue" => venue,
|
|
185
|
+
"externalRef" => external_ref, "currency" => currency,
|
|
186
|
+
"description" => description, "endsAt" => ends_at,
|
|
187
|
+
"timezone" => timezone, "locale" => locale,
|
|
188
|
+
"posterAssetId" => poster_asset_id }))
|
|
189
|
+
@client.post(
|
|
190
|
+
"/v1/events", body, idempotency_key: idempotency_key, retry_policy: :header_replay
|
|
191
|
+
)
|
|
140
192
|
end
|
|
193
|
+
# rubocop:enable Metrics/ParameterLists
|
|
141
194
|
|
|
142
195
|
def retrieve(event_key)
|
|
143
196
|
@client.get("/v1/events/#{encode(event_key)}")
|
|
@@ -151,9 +204,20 @@ module SeatLayer
|
|
|
151
204
|
@client.delete("/v1/events/#{encode(event_key)}")
|
|
152
205
|
end
|
|
153
206
|
|
|
207
|
+
# Upload raw PNG, JPEG, or WebP bytes (maximum 5 MiB).
|
|
208
|
+
def update_poster(event_key, image, content_type: "application/octet-stream")
|
|
209
|
+
@client.put_raw("/v1/events/#{encode(event_key)}/poster", image, content_type: content_type)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def delete_poster(event_key)
|
|
213
|
+
@client.delete("/v1/events/#{encode(event_key)}/poster")
|
|
214
|
+
end
|
|
215
|
+
|
|
154
216
|
# Move a live event onto the latest published version of its chart.
|
|
155
|
-
def update_chart(event_key)
|
|
156
|
-
|
|
217
|
+
def update_chart(event_key, acknowledge_dropped_assignments: nil, reason: nil)
|
|
218
|
+
body = compact({ "acknowledgeDroppedAssignments" => acknowledge_dropped_assignments,
|
|
219
|
+
"reason" => reason })
|
|
220
|
+
@client.post("/v1/events/#{encode(event_key)}/update-chart", body)
|
|
157
221
|
end
|
|
158
222
|
|
|
159
223
|
# Stop buyer sales. Existing holds keep their TTL.
|
|
@@ -174,15 +238,32 @@ module SeatLayer
|
|
|
174
238
|
end
|
|
175
239
|
|
|
176
240
|
def update_hold_ttl(event_key, hold_ttl_ms)
|
|
241
|
+
# +nil+ restores the event default and must remain an explicit JSON null.
|
|
177
242
|
@client.post("/v1/events/#{encode(event_key)}/hold-ttl", { "holdTtlMs" => hold_ttl_ms })
|
|
178
243
|
end
|
|
179
244
|
|
|
245
|
+
def list_ticket_releases(event_key)
|
|
246
|
+
@client.get("/v1/events/#{encode(event_key)}/releases")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Replace every ticket release for an event. This stays single-attempt: the
|
|
250
|
+
# route does not promise exact idempotent-response replay.
|
|
251
|
+
def update_ticket_releases(event_key, releases:)
|
|
252
|
+
@client.put("/v1/events/#{encode(event_key)}/releases", { "releases" => releases })
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Close one release while preserving its audit provenance.
|
|
256
|
+
def close_ticket_release(event_key, release_id)
|
|
257
|
+
@client.post("/v1/events/#{encode(event_key)}/releases/#{encode(release_id)}/close")
|
|
258
|
+
end
|
|
259
|
+
|
|
180
260
|
def retrieve_report(event_key)
|
|
181
261
|
@client.get("/v1/events/#{encode(event_key)}/report")
|
|
182
262
|
end
|
|
183
263
|
|
|
184
|
-
def retrieve_log(event_key)
|
|
185
|
-
@client.get("/v1/events/#{encode(event_key)}/log"
|
|
264
|
+
def retrieve_log(event_key, limit: nil, before: nil)
|
|
265
|
+
@client.get("/v1/events/#{encode(event_key)}/log",
|
|
266
|
+
compact({ "limit" => limit, "before" => before }))
|
|
186
267
|
end
|
|
187
268
|
end
|
|
188
269
|
end
|
data/lib/seatlayer/version.rb
CHANGED
data/lib/seatlayer.rb
CHANGED
|
@@ -19,7 +19,7 @@ require_relative "seatlayer/webhook"
|
|
|
19
19
|
module SeatLayer
|
|
20
20
|
# The SeatLayer client.
|
|
21
21
|
class Client
|
|
22
|
-
attr_reader :charts, :events, :inventory, :channels, :sessions, :webhooks, :workspaces
|
|
22
|
+
attr_reader :charts, :events, :inventory, :channels, :sessions, :webhooks, :workspaces, :templates
|
|
23
23
|
|
|
24
24
|
def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL,
|
|
25
25
|
max_retries: HTTPClient::DEFAULT_MAX_RETRIES,
|
|
@@ -32,6 +32,7 @@ module SeatLayer
|
|
|
32
32
|
@inventory = Inventory.new(@http)
|
|
33
33
|
@channels = Channels.new(@http)
|
|
34
34
|
@sessions = Sessions.new(@http)
|
|
35
|
+
@templates = Templates.new(@http)
|
|
35
36
|
@webhooks = Webhooks.new(@http)
|
|
36
37
|
@workspaces = Workspaces.new(@http)
|
|
37
38
|
end
|
|
@@ -46,10 +47,12 @@ module SeatLayer
|
|
|
46
47
|
@http.get("/health/ready")
|
|
47
48
|
end
|
|
48
49
|
|
|
49
|
-
# Escape hatch for surface this SDK does not wrap yet.
|
|
50
|
-
#
|
|
51
|
-
def request(method, path, query: nil, body: nil,
|
|
52
|
-
|
|
50
|
+
# Escape hatch for surface this SDK does not wrap yet. Reads retain retries;
|
|
51
|
+
# raw mutations are single-attempt because their replay contract is unknown.
|
|
52
|
+
def request(method, path, query: nil, body: nil, raw_body: nil, content_type: nil,
|
|
53
|
+
idempotency_key: nil)
|
|
54
|
+
@http.request(method, path, query: query, body: body, raw_body: raw_body,
|
|
55
|
+
content_type: content_type, idempotency_key: idempotency_key)
|
|
53
56
|
end
|
|
54
57
|
end
|
|
55
58
|
end
|