diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b72739..3342fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,13 @@ ## Unreleased +- Added `templates->instantiateTemplate()` and the ticket-release lifecycle on + `events` (`listTicketReleases`, `updateTicketReleases`, and + `closeTicketRelease`). Template instantiation sends `{}` when no overrides + are supplied, URI-escapes identifiers, and uses exact-response replay; + ticket-release writes remain single-attempt. - **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 + 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. diff --git a/README.md b/README.md index 8e94bee..b62f496 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ use SeatLayer\SeatLayer; $seatlayer = new SeatLayer(getenv('SEATLAYER_SECRET_KEY')); -// 1. Provision a venue for a new organiser from one of your templates. -$chart = $seatlayer->charts->copy('c_template_arena')['meta']; +// 1. Provision a venue for a new organiser from a public template. +$chart = $seatlayer->templates->instantiateTemplate('arena-standard')['meta']; $seatlayer->charts->publish($chart['id']); // 2. Create an event on it. @@ -245,9 +245,9 @@ support requests. ## 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 +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Five 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 +`templates->instantiateTemplate`, `events->create`, and `workspaces->create`. They generate an `Idempotency-Key` when absent and reuse that key across every attempt. You can supply a stable provisioning key instead: ```php @@ -289,7 +289,8 @@ suite runs without a network. | 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` | `listChannels` `createChannel` `updateChannel` `updateAssignments` `listAllocation` `retrieveAccessPreview` `retrieveReport` `pause` `unpause` `archive` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` | | `inventory` | `hold` `holdBestAvailable` `bookBestAvailable` `extendHold` `retrieveHold` `release` `book` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` `listBookings` `retrieveBooking` | | `sessions` | `createManageSession` `revokeManageSession` `createDesignerSession` `revokeDesignerSession` | diff --git a/src/HttpClient.php b/src/HttpClient.php index c1501b8..fe45096 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -88,13 +88,13 @@ public function request( /** * @param array|null $query - * @param array|string|null $body + * @param array|object|string|null $body */ private function performRequest( string $method, string $path, ?array $query, - array|string|null $body, + array|object|string|null $body, bool $headerReplay, ?string $idempotencyKey, ?string $contentType = null, @@ -191,6 +191,19 @@ public function postWithHeaderReplay( return $this->performRequest('POST', $path, null, $body, true, $idempotencyKey); } + /** + * POST a JSON object with exact response replay, including `{}` when empty. + * + * @param array $body + */ + public function postObjectWithHeaderReplay( + string $path, + array $body = [], + ?string $idempotencyKey = null, + ): mixed { + return $this->performRequest('POST', $path, null, (object) $body, true, $idempotencyKey); + } + /** @param array $body */ public function put(string $path, array $body): mixed { diff --git a/src/Resources/Events.php b/src/Resources/Events.php index 52200e1..c96e37d 100644 --- a/src/Resources/Events.php +++ b/src/Resources/Events.php @@ -201,6 +201,44 @@ public function updateHoldTtl(string $eventKey, ?int $holdTtlMs): mixed ); } + /** @return array */ + public function listTicketReleases(string $eventKey): array + { + /** @var array */ + return (array) $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/releases'); + } + + /** + * Replace every ticket release for an event. + * + * This is deliberately single-attempt: unlike template instantiation, the + * route does not promise exact idempotent-response replay. + * + * @param list> $releases + * @return array + */ + public function updateTicketReleases(string $eventKey, array $releases): array + { + /** @var array */ + return (array) $this->http->put( + '/v1/events/' . HttpClient::encode($eventKey) . '/releases', + ['releases' => $releases], + ); + } + + /** + * Close one release while preserving its audit provenance. + * + * @return array + */ + public function closeTicketRelease(string $eventKey, string $releaseId): array + { + /** @var array */ + return (array) $this->http->post( + '/v1/events/' . HttpClient::encode($eventKey) . '/releases/' . HttpClient::encode($releaseId) . '/close', + ); + } + public function retrieveReport(string $eventKey): mixed { return $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/report'); diff --git a/src/Resources/Templates.php b/src/Resources/Templates.php new file mode 100644 index 0000000..0fb5090 --- /dev/null +++ b/src/Resources/Templates.php @@ -0,0 +1,42 @@ + $fields Optional template overrides. + * @return array + */ + public function instantiateTemplate( + string $templateId, + array $fields = [], + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + '/v1/templates/' . HttpClient::encode($templateId) . '/instantiate', + $fields, + $idempotencyKey, + ); + } +} diff --git a/src/SeatLayer.php b/src/SeatLayer.php index 2e3ed4b..81b0469 100644 --- a/src/SeatLayer.php +++ b/src/SeatLayer.php @@ -9,6 +9,7 @@ use SeatLayer\Resources\Events; use SeatLayer\Resources\Inventory; use SeatLayer\Resources\Sessions; +use SeatLayer\Resources\Templates; use SeatLayer\Resources\Webhooks; use SeatLayer\Resources\Workspaces; @@ -25,6 +26,7 @@ final class SeatLayer public readonly Events $events; public readonly Inventory $inventory; public readonly Sessions $sessions; + public readonly Templates $templates; public readonly Webhooks $webhooks; public readonly Workspaces $workspaces; @@ -51,6 +53,7 @@ public function __construct( $this->events = new Events($this->http); $this->inventory = new Inventory($this->http); $this->sessions = new Sessions($this->http); + $this->templates = new Templates($this->http); $this->webhooks = new Webhooks($this->http); $this->workspaces = new Workspaces($this->http); } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index d87d837..a4c4e3a 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -101,6 +101,65 @@ public function testPercentEncodesPathParameters(): void self::assertSame('https://api.seatlayer.io/v1/events/ev%2F..%2Fadmin', $this->call(0)['url']); } + public function testTemplateInstantiationUsesAnObjectBodyAndReplaysItsResponse(): void + { + $sdk = $this->client([ + ['status' => 429, 'body' => ['error' => 'rate_limited'], 'headers' => ['retry-after' => '0']], + ['status' => 201, 'body' => ['meta' => ['id' => 'c_1']]], + ]); + $sdk->templates->instantiateTemplate('arena/standard'); + + self::assertCount(2, $this->calls); + self::assertSame( + 'https://api.seatlayer.io/v1/templates/arena%2Fstandard/instantiate', + $this->call(0)['url'], + ); + self::assertSame('{}', $this->call(0)['body']); + self::assertSame( + $this->call(0)['headers']['Idempotency-Key'], + $this->call(1)['headers']['Idempotency-Key'], + ); + } + + public function testTicketReleaseRoutesEncodeIdentifiersAndRemainSingleAttempt(): void + { + $sdk = $this->client([ + ['status' => 200, 'body' => ['releases' => []]], + ['status' => 200, 'body' => ['releases' => []]], + ['status' => 429, 'body' => ['error' => 'rate_limited'], 'headers' => ['retry-after' => '0']], + ]); + $sdk->events->listTicketReleases('ev/1'); + $sdk->events->updateTicketReleases('ev/1', [[ + 'id' => 'rel_1', + 'name' => 'Early', + 'price' => 2500, + 'action' => 'buy', + ]]); + + try { + $sdk->events->closeTicketRelease('ev/1', 'rel/1'); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(3, $this->calls); + self::assertSame('GET', $this->call(0)['method']); + self::assertSame('https://api.seatlayer.io/v1/events/ev%2F1/releases', $this->call(0)['url']); + self::assertSame( + ['releases' => [[ + 'id' => 'rel_1', + 'name' => 'Early', + 'price' => 2500, + 'action' => 'buy', + ]]], + json_decode((string) $this->call(1)['body'], true), + ); + self::assertSame( + 'https://api.seatlayer.io/v1/events/ev%2F1/releases/rel%2F1/close', + $this->call(2)['url'], + ); + self::assertArrayNotHasKey('Idempotency-Key', $this->call(2)['headers']); + } + } + public function testIdempotencyKeyOnlyOnHeaderReplayMutations(): void { $sdk = $this->client([