diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ed805..bd5aeb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,13 @@ ## Unreleased - **Security/reliability:** Mutations now default to a single attempt. Automatic header-replay - retries are limited to chart create/copy, event create, and workspace create, preventing - transient failures from duplicating holds or best-available results and from issuing extra - show-once credentials. + retries are limited to chart create/copy, template instantiation, event create, and workspace + create, preventing transient failures from duplicating holds or best-available results and from + issuing extra show-once credentials. + +- Added `templates().instantiateTemplate` for materializing published catalog templates as drafts, + with header-replay idempotency. +- Added ticket-release list, full-list replacement, and close methods to `events()`. ## 0.2.0 — 2026-08-12 diff --git a/README.md b/README.md index 1cfed33..a431026 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ Official Java server SDK for the [SeatLayer](https://seatlayer.io) reserved-seat io.seatlayer seatlayer-java - 0.2.0 + 0.4.0 ``` ```groovy -implementation 'io.seatlayer:seatlayer-java:0.2.0' +implementation 'io.seatlayer:seatlayer-java:0.4.0' ``` Requires Java 17 or newer. **Zero runtime dependencies** — the SDK uses @@ -36,8 +36,9 @@ import java.util.Map; SeatLayer seatlayer = new SeatLayer(System.getenv("SEATLAYER_SECRET_KEY")); -// 1. Provision a venue for a new organiser from one of your templates. -Map chart = (Map) seatlayer.charts().copy("c_template_arena").get("meta"); +// 1. Materialize a published catalog template as a draft for this organiser. +Map chart = (Map) seatlayer.templates() + .instantiateTemplate("your-published-template").get("meta"); seatlayer.charts().publish((String) chart.get("id")); // 2. Create an event on it. @@ -229,11 +230,11 @@ support requests. All are unchecked, so they do not force `throws` clauses throu ## Reliability **Retries and idempotency.** Reads (`GET`/`HEAD`) retry connection failures, 408, 429 and 5xx with -exponential backoff and full jitter; `Retry-After` wins when the server sends it. Four create -operations have the same retry behaviour with header replay: `charts().create`, `charts().copy`, -`events().create`, and `workspaces().create`. They generate an `Idempotency-Key` when absent and -reuse that key across every attempt. Overloads that accept a key let you provide a stable -provisioning key instead. +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Five +provisioning operations have the same retry behaviour with header replay: `charts().create`, +`charts().copy`, `templates().instantiateTemplate`, `events().create`, and +`workspaces().create`. They generate an `Idempotency-Key` when absent and reuse that key across +every attempt. Overloads that accept a key let you provide a stable provisioning key instead. All other mutations are single-attempt: holds, bookings, lifecycle changes, channel changes, show-once secret creation, and raw requests. The SDK does not generate a key for them. A supplied @@ -263,7 +264,8 @@ seatlayer.request("POST", "/v1/events/ev_1/some-new-route", null, Map.of("qty", | Resource | Methods | | --- | --- | | `charts()` | `list` `listAll` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` | -| `events()` | `list` `listAll` `create` `retrieve` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `retrieveReport` `retrieveLog` | +| `templates()` | `instantiateTemplate` | +| `events()` | `list` `listAll` `create` `retrieve` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` | | `channels()` | `list` `create` `update` `updateAssignments` `listAllocation` `retrieveAccessPreview` `retrieveReport` `pause` `unpause` `archive` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` | | `inventory()` | `hold` `holdBestAvailable` `bookBestAvailable` `extendHold` `retrieveHold` `release` `book` `bookLabels` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` `listBookings` `retrieveBooking` | | `sessions()` | `createManageSession` `revokeManageSession` `createDesignerSession` `revokeDesignerSession` | diff --git a/pom.xml b/pom.xml index a2e0664..86e6574 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.seatlayer seatlayer-java - 0.3.0 + 0.4.0 jar SeatLayer Java SDK diff --git a/src/main/java/io/seatlayer/Events.java b/src/main/java/io/seatlayer/Events.java index 15bb746..1cc263d 100644 --- a/src/main/java/io/seatlayer/Events.java +++ b/src/main/java/io/seatlayer/Events.java @@ -4,6 +4,7 @@ import static io.seatlayer.SeatLayerHttpClient.encode; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** Event lifecycle, metadata, reports. */ @@ -126,6 +127,31 @@ public Map updateHoldTtl(String eventKey, Long holdTtlMs) { return http.post("/v1/events/" + encode(eventKey) + "/hold-ttl", request); } + /** Lists the event's ticket releases, including current quota consumption. */ + public Map listTicketReleases(String eventKey) { + return http.get("/v1/events/" + encode(eventKey) + "/releases"); + } + + /** + * Replaces the event's complete, ordered ticket-release list. + * + *

Each map is a release input. Do not send response-only fields such as + * {@code position}, {@code soldOutAt}, {@code consumed}, or {@code remaining}; the server + * derives those from the ordered replacement and live inventory. + */ + public Map updateTicketReleases( + String eventKey, List> releases) { + return http.put( + "/v1/events/" + encode(eventKey) + "/releases", + body("releases", releases)); + } + + /** Ends one ticket release immediately. This mutation is intentionally single-attempt. */ + public Map closeTicketRelease(String eventKey, String releaseId) { + return http.post( + "/v1/events/" + encode(eventKey) + "/releases/" + encode(releaseId) + "/close"); + } + public Map retrieveReport(String eventKey) { return http.get("/v1/events/" + encode(eventKey) + "/report"); } diff --git a/src/main/java/io/seatlayer/SeatLayer.java b/src/main/java/io/seatlayer/SeatLayer.java index 2a532d2..29ba74e 100644 --- a/src/main/java/io/seatlayer/SeatLayer.java +++ b/src/main/java/io/seatlayer/SeatLayer.java @@ -22,6 +22,7 @@ public final class SeatLayer { private final Events events; private final Inventory inventory; private final Sessions sessions; + private final Templates templates; private final Webhooks webhooks; private final Workspaces workspaces; @@ -37,6 +38,7 @@ private SeatLayer(Builder builder) { this.events = new Events(http); this.inventory = new Inventory(http); this.sessions = new Sessions(http); + this.templates = new Templates(http); this.webhooks = new Webhooks(http); this.workspaces = new Workspaces(http); } @@ -66,6 +68,11 @@ public Sessions sessions() { return sessions; } + /** Published catalog templates that can be materialized as chart drafts. */ + public Templates templates() { + return templates; + } + public Webhooks webhooks() { return webhooks; } diff --git a/src/main/java/io/seatlayer/Templates.java b/src/main/java/io/seatlayer/Templates.java new file mode 100644 index 0000000..41c2912 --- /dev/null +++ b/src/main/java/io/seatlayer/Templates.java @@ -0,0 +1,42 @@ +package io.seatlayer; + +import static io.seatlayer.SeatLayerHttpClient.encode; + +import java.util.Map; + +/** Published catalog templates that can be materialized as workspace chart drafts. */ +public final class Templates { + + private final SeatLayerHttpClient http; + + Templates(SeatLayerHttpClient http) { + this.http = http; + } + + /** + * Instantiates a published template as a draft in the caller's workspace. + * + *

The API requires a JSON object even when no overrides are needed, so this sends + * {@code {}}. It uses the server's header-replay contract and therefore retries with + * one stable idempotency key. + */ + public Map instantiateTemplate(String templateId) { + return instantiateTemplate(templateId, Map.of()); + } + + /** Instantiates with optional {@code name}, {@code workspaceId}, or version-pinning overrides. */ + public Map instantiateTemplate(String templateId, Map params) { + return http.postWithHeaderReplay( + "/v1/templates/" + encode(templateId) + "/instantiate", + params == null ? Map.of() : params); + } + + /** Instantiates with a caller-owned idempotency key for exact server replay. */ + public Map instantiateTemplate( + String templateId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + "/v1/templates/" + encode(templateId) + "/instantiate", + params == null ? Map.of() : params, + idempotencyKey); + } +} diff --git a/src/test/java/io/seatlayer/ClientTest.java b/src/test/java/io/seatlayer/ClientTest.java index ec77e74..da26cb7 100644 --- a/src/test/java/io/seatlayer/ClientTest.java +++ b/src/test/java/io/seatlayer/ClientTest.java @@ -9,6 +9,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.DisplayName; @@ -124,6 +125,46 @@ void idempotencyKeyOnlyOnHeaderReplayMutations() { assertNull(call(2).headers().get("Idempotency-Key")); } + @Test + @DisplayName("instantiates templates with an object body and escaped identifier") + void instantiateTemplateUsesObjectBodyAndEscapedPath() { + SeatLayer sdk = client(List.of(Stub.of(201, "{\"meta\":{}}"))); + + sdk.templates().instantiateTemplate("tpl / main"); + + assertEquals("POST", call(0).method()); + assertEquals( + "https://api.seatlayer.io/v1/templates/tpl%20%2F%20main/instantiate", + call(0).url()); + assertEquals("{}", call(0).body()); + assertTrue(call(0).headers().get("Idempotency-Key").matches("[A-Za-z0-9._:-]{1,128}")); + } + + @Test + @DisplayName("sends ticket-release routes and whole-list request body") + void ticketReleaseRoutesAndBody() { + SeatLayer sdk = client(List.of( + Stub.of(200, "{\"releases\":[]}"), + Stub.of(200, "{\"releases\":[]}"), + Stub.of(200, "{\"releases\":[]}"))); + Map release = new LinkedHashMap<>(); + release.put("name", "Early bird"); + release.put("price", 18); + + sdk.events().listTicketReleases("ev / main"); + sdk.events().updateTicketReleases("ev / main", List.of(release)); + sdk.events().closeTicketRelease("ev / main", "rel / one"); + + assertEquals( + "https://api.seatlayer.io/v1/events/ev%20%2F%20main/releases", call(0).url()); + assertEquals("PUT", call(1).method()); + assertEquals( + "{\"releases\":[{\"name\":\"Early bird\",\"price\":18}]}", call(1).body()); + assertEquals( + "https://api.seatlayer.io/v1/events/ev%20%2F%20main/releases/rel%20%2F%20one/close", + call(2).url()); + } + @Test @DisplayName("rejects an idempotency key the API would reject") void rejectsBadIdempotencyKey() { @@ -238,6 +279,43 @@ void retriesAndReusesKey() { call(0).headers().get("Idempotency-Key"), call(1).headers().get("Idempotency-Key")); } + @Test + @DisplayName("template instantiation retries with one stable idempotency key") + void templateInstantiationRetriesAndReusesKey() { + SeatLayer sdk = client(List.of( + new Stub(429, "{\"error\":\"rate_limited\"}", Map.of("retry-after", "0")), + Stub.of(201, "{\"meta\":{}}"))); + + sdk.templates().instantiateTemplate("tpl_1"); + + assertEquals(2, calls.size()); + assertEquals( + call(0).headers().get("Idempotency-Key"), call(1).headers().get("Idempotency-Key")); + } + + @Test + @DisplayName("ticket-release mutations are single-attempt") + void ticketReleaseMutationsAreSingleAttempt() { + SeatLayer updater = client(List.of( + new Stub(429, "{\"error\":\"rate_limited\"}", Map.of("retry-after", "0")))); + Map release = new LinkedHashMap<>(); + release.put("name", "Early bird"); + release.put("price", 18); + assertThrows( + SeatLayerRateLimitException.class, + () -> updater.events().updateTicketReleases("ev_1", List.of(release))); + assertEquals(1, calls.size()); + assertNull(call(0).headers().get("Idempotency-Key")); + + SeatLayer closer = client(List.of( + new Stub(429, "{\"error\":\"rate_limited\"}", Map.of("retry-after", "0")))); + assertThrows( + SeatLayerRateLimitException.class, + () -> closer.events().closeTicketRelease("ev_1", "rel_123456789abc")); + assertEquals(1, calls.size()); + assertNull(call(0).headers().get("Idempotency-Key")); + } + @Test @DisplayName("read retries remain enabled without an idempotency key") void retriesReads() {