diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c15c5..79abc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## Unreleased +## 0.6.0 — 2026-08-23 + +- Added exact immutable Event configuration binding reads and compare-and-set + attach/detach through `Events.RetrieveConfigurationBindingAsync` and + `Events.UpdateConfigurationBindingAsync`. Updates remain deliberately single-attempt. ## 0.5.0 — 2026-08-21 diff --git a/README.md b/README.md index 3eeeb2c..4231e99 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route", | --- | --- | | `Charts` | `ListAsync` `ListAllAsync` `CreateAsync` `RetrieveAsync` `UpdateAsync` `DeleteAsync` `CopyAsync` `ArchiveAsync` `UnarchiveAsync` `PublishAsync` | | `Templates` | `InstantiateTemplateAsync` | -| `Events` | `ListAsync` `ListAllAsync` `CreateAsync` `RetrieveAsync` `UpdateAsync` `DeleteAsync` `UpdateChartAsync` `CloseAsync` `ReopenAsync` `ArchiveAsync` `RetrieveHoldTtlAsync` `UpdateHoldTtlAsync` `ListTicketReleasesAsync` `UpdateTicketReleasesAsync` `CloseTicketReleaseAsync` `RetrieveReportAsync` `RetrieveLogAsync` | +| `Events` | `ListAsync` `ListAllAsync` `CreateAsync` `RetrieveAsync` `RetrieveConfigurationBindingAsync` `UpdateConfigurationBindingAsync` `UpdateAsync` `DeleteAsync` `UpdateChartAsync` `CloseAsync` `ReopenAsync` `ArchiveAsync` `RetrieveHoldTtlAsync` `UpdateHoldTtlAsync` `ListTicketReleasesAsync` `UpdateTicketReleasesAsync` `CloseTicketReleaseAsync` `RetrieveReportAsync` `RetrieveLogAsync` | | `Channels` | `ListAsync` `CreateAsync` `UpdateAsync` `UpdateAssignmentsAsync` `ListAllocationAsync` `RetrieveAccessPreviewAsync` `RetrieveReportAsync` `PauseAsync` `UnpauseAsync` `ArchiveAsync` `CreateBuyerAccessSessionAsync` `ListBuyerAccessSessionsAsync` `RevokeBuyerAccessSessionAsync` | | `Inventory` | `HoldAsync` `HoldBestAvailableAsync` `BookBestAvailableAsync` `ExtendHoldAsync` `RetrieveHoldAsync` `ReleaseAsync` `BookAsync` `BookLabelsAsync` `BoxOfficeBookAsync` `UnbookAsync` `BlockAsync` `UnblockAsync` `UnblockAllAsync` `RetrieveAvailabilityAsync` `UpdateAvailabilityAsync` `ListBookingsAsync` `RetrieveBookingAsync` | | `Sessions` | `CreateManageSessionAsync` `RevokeManageSessionAsync` `CreateDesignerSessionAsync` `RevokeDesignerSessionAsync` | diff --git a/src/SeatLayer/SeatLayer.csproj b/src/SeatLayer/SeatLayer.csproj index acc9207..505be63 100644 --- a/src/SeatLayer/SeatLayer.csproj +++ b/src/SeatLayer/SeatLayer.csproj @@ -14,7 +14,7 @@ SeatLayer - 0.5.0 + 0.6.0 SeatLayer SeatLayer Official .NET server SDK for the SeatLayer reserved-seating API. Server-side only. diff --git a/src/SeatLayer/Services.cs b/src/SeatLayer/Services.cs index 15d1c95..6a05028 100644 --- a/src/SeatLayer/Services.cs +++ b/src/SeatLayer/Services.cs @@ -353,6 +353,27 @@ public sealed class TicketReleaseReplaceRequest public required IReadOnlyList Releases { get; set; } } +/// Selects one immutable published Event configuration version. +/// Configuration identity remains separate from the chart's venue geometry. +public sealed class EventConfigurationReference +{ + /// Configuration library id. + public required string Id { get; set; } + + /// Exact immutable version to attach. + public required long Version { get; set; } +} + +/// Compare-and-set request for an Event's configuration binding. +public sealed class EventConfigurationBindingUpdateRequest +{ + /// Revision returned by the latest binding read. + public required long ExpectedRevision { get; set; } + + /// Published version to attach, or null to explicitly detach. + public EventConfigurationReference? Configuration { get; set; } +} + /// Event lifecycle, metadata and reports. public sealed class EventsService { @@ -433,6 +454,36 @@ public async Task ListAsync( string eventKey, CancellationToken cancellationToken = default) => _client.GetAsync($"/v1/events/{SeatLayerClient.Escape(eventKey)}", null, cancellationToken); + /// Reads the Event's exact immutable configuration selection and audit trail. + public Task> RetrieveConfigurationBindingAsync( + string eventKey, CancellationToken cancellationToken = default) + => _client.GetAsync( + $"/v1/events/{SeatLayerClient.Escape(eventKey)}/event-configuration", + null, + cancellationToken); + + /// Attaches an exact published configuration version, or explicitly detaches it. + /// + /// The expected revision prevents concurrent administrative changes from silently overwriting + /// each other. This mutation remains single-attempt because it has no header-replay contract. + /// + public Task> UpdateConfigurationBindingAsync( + string eventKey, + EventConfigurationBindingUpdateRequest request, + CancellationToken cancellationToken = default) + { + // Body.Of removes null optional values, but null is the explicit detach instruction here. + var body = new Dictionary + { + ["expectedRevision"] = request.ExpectedRevision, + ["configuration"] = request.Configuration, + }; + return _client.PutAsync( + $"/v1/events/{SeatLayerClient.Escape(eventKey)}/event-configuration", + body, + cancellationToken); + } + /// Updates event metadata. public Task> UpdateAsync( string eventKey, IDictionary fields, CancellationToken cancellationToken = default) diff --git a/tests/SeatLayer.Tests/ClientTests.cs b/tests/SeatLayer.Tests/ClientTests.cs index c8b242d..efe38a1 100644 --- a/tests/SeatLayer.Tests/ClientTests.cs +++ b/tests/SeatLayer.Tests/ClientTests.cs @@ -203,6 +203,68 @@ await Assert.ThrowsAsync( Assert.False(handler.Calls[2].Headers.Contains("Idempotency-Key")); } + [Fact] + public async Task EventConfigurationBindingUsesExactVersionsExplicitDetachAndSingleAttempts() + { + const string binding = "{\"configuration\":{\"id\":\"ec_touring\",\"version\":3}," + + "\"revision\":7,\"changedBy\":\"api-key:key_1\",\"changedAt\":123,\"audit\":[" + + "{\"id\":\"eca_1\",\"from\":null,\"to\":{\"id\":\"ec_touring\",\"version\":3}," + + "\"revision\":7,\"actor\":\"api-key:key_1\",\"createdAt\":123}]}"; + var retryNow = new Dictionary { ["Retry-After"] = "0" }; + var (client, handler) = Build(new[] + { + Ok(binding), + Ok(binding), + Ok("{\"configuration\":null,\"revision\":8,\"changedBy\":null,\"changedAt\":null,\"audit\":[]}"), + Status((HttpStatusCode)429, "{\"error\":\"rate_limited\"}", retryNow), + }); + + var retrieved = await client.Events.RetrieveConfigurationBindingAsync("ev / main"); + var attached = await client.Events.UpdateConfigurationBindingAsync( + "ev / main", + new EventConfigurationBindingUpdateRequest + { + ExpectedRevision = 6, + Configuration = new EventConfigurationReference { Id = "ec_touring", Version = 3 }, + }); + var detached = await client.Events.UpdateConfigurationBindingAsync( + "ev / main", + new EventConfigurationBindingUpdateRequest { ExpectedRevision = 7, Configuration = null }); + await Assert.ThrowsAsync( + () => client.Events.UpdateConfigurationBindingAsync( + "ev_1", + new EventConfigurationBindingUpdateRequest { ExpectedRevision = 8, Configuration = null })); + + var configuration = Assert.IsAssignableFrom>( + retrieved["configuration"]); + Assert.Equal("ec_touring", configuration["id"]); + var audit = Assert.IsAssignableFrom>(retrieved["audit"]); + var firstAudit = Assert.IsAssignableFrom>(audit[0]); + Assert.Null(firstAudit["from"]); + Assert.NotNull(attached["configuration"]); + Assert.Null(detached["configuration"]); + + Assert.Equal(4, handler.Calls.Count); + for (var index = 0; index < 3; index++) + { + Assert.Equal( + "https://api.seatlayer.io/v1/events/ev%20%2F%20main/event-configuration", + handler.Calls[index].Url.AbsoluteUri); + } + Assert.Equal(HttpMethod.Get, handler.Calls[0].Method); + Assert.Equal(HttpMethod.Put, handler.Calls[1].Method); + Assert.Equal(HttpMethod.Put, handler.Calls[2].Method); + using var attachBody = JsonDocument.Parse(handler.Calls[1].Body!); + Assert.Equal(6, attachBody.RootElement.GetProperty("expectedRevision").GetInt64()); + Assert.Equal("ec_touring", attachBody.RootElement.GetProperty("configuration") + .GetProperty("id").GetString()); + using var detachBody = JsonDocument.Parse(handler.Calls[2].Body!); + Assert.Equal(JsonValueKind.Null, detachBody.RootElement.GetProperty("configuration").ValueKind); + Assert.False(handler.Calls[1].Headers.Contains("Idempotency-Key")); + Assert.False(handler.Calls[2].Headers.Contains("Idempotency-Key")); + Assert.False(handler.Calls[3].Headers.Contains("Idempotency-Key")); + } + [Fact] public async Task HonoursCallerIdempotencyKey() {