Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
17 changes: 15 additions & 2 deletions src/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@ public function request(

/**
* @param array<string, mixed>|null $query
* @param array<string, mixed>|string|null $body
* @param array<string, mixed>|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,
Expand Down Expand Up @@ -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<string, mixed> $body
*/
public function postObjectWithHeaderReplay(
string $path,
array $body = [],
?string $idempotencyKey = null,
): mixed {
return $this->performRequest('POST', $path, null, (object) $body, true, $idempotencyKey);
}

/** @param array<string, mixed> $body */
public function put(string $path, array $body): mixed
{
Expand Down
38 changes: 38 additions & 0 deletions src/Resources/Events.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,44 @@ public function updateHoldTtl(string $eventKey, ?int $holdTtlMs): mixed
);
}

/** @return array<string, mixed> */
public function listTicketReleases(string $eventKey): array
{
/** @var array<string, mixed> */
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<array<string, mixed>> $releases
* @return array<string, mixed>
*/
public function updateTicketReleases(string $eventKey, array $releases): array
{
/** @var array<string, mixed> */
return (array) $this->http->put(
'/v1/events/' . HttpClient::encode($eventKey) . '/releases',
['releases' => $releases],
);
}

/**
* Close one release while preserving its audit provenance.
*
* @return array<string, mixed>
*/
public function closeTicketRelease(string $eventKey, string $releaseId): array
{
/** @var array<string, mixed> */
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');
Expand Down
42 changes: 42 additions & 0 deletions src/Resources/Templates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace SeatLayer\Resources;

use SeatLayer\HttpClient;

/**
* Published SeatLayer catalogue templates.
*
* Instantiation creates an independent draft chart. Publish that returned chart
* before creating an event from it.
*/
final class Templates
{
public function __construct(private readonly HttpClient $http)
{
}

/**
* Instantiate a public template into a new draft chart.
*
* The API requires a JSON object even when no overrides are needed. This
* resource therefore serializes the default as `{}`, rather than PHP's `[]`.
*
* @param array<string, mixed> $fields Optional template overrides.
* @return array<string, mixed>
*/
public function instantiateTemplate(
string $templateId,
array $fields = [],
?string $idempotencyKey = null,
): array {
/** @var array<string, mixed> */
return (array) $this->http->postObjectWithHeaderReplay(
'/v1/templates/' . HttpClient::encode($templateId) . '/instantiate',
$fields,
$idempotencyKey,
);
}
}
3 changes: 3 additions & 0 deletions src/SeatLayer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -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);
}
Expand Down
59 changes: 59 additions & 0 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down