feat(api): optimistic-concurrency contract for replace-all PUTs — PR1 infra + Block reference (#253)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds the shared optimistic-concurrency contract so a stale second tab can no longer silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate; PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472). Contract - `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule, Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection, RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration `AddAggregateVersions` (nullable:false, default 0). - Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch → 412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag. - `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`; `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent = Phase-1 force-write. Block reference wiring - Handler: standalone `Either` via `CheckVersion` AFTER validation (never through `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`, `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412). - `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the ETag on GET items and on the successful PUT. - SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock` If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412 opens a blocking "changed elsewhere — reload" dialog. Tests - Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success + bump, no-op save still bumps, and a two-context racing save → 412; proven non-vacuous (drop `.IsConcurrencyToken()` → the race test fails). - Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog. Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log. Refs #253 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,9 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/blocks/{id:int}/items")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get block items")]
|
||||
[EndpointDescription(
|
||||
"Returns the block's items and a strong ETag of the block's version. Pass that ETag back as " +
|
||||
"If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<BlockItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -139,6 +142,9 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the block's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, block.Map(b => b.Version).IfNone(0));
|
||||
|
||||
List<BlockItemViewModel> items = await mediator.Send(new GetBlockItems(id), cancellationToken);
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
@@ -148,16 +154,32 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Replace a block and its items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned " +
|
||||
"from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.")]
|
||||
"from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours. " +
|
||||
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
|
||||
"a successful response carries the new ETag.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(BlockWithItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceBlockRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return new BadRequestObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Invalid If-Match header",
|
||||
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
|
||||
});
|
||||
}
|
||||
|
||||
Option<BlockViewModel> maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
if (maybeBlock.IsNone)
|
||||
{
|
||||
@@ -167,7 +189,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToCommand(groupId, id), cancellationToken);
|
||||
await mediator.Send(request.ToCommand(groupId, id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
@@ -176,8 +198,12 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
Option<BlockViewModel> refreshed = await mediator.Send(new GetBlockById(id), cancellationToken);
|
||||
List<BlockItemViewModel> items = await mediator.Send(new GetBlockItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(
|
||||
ProjectToWithItemsResponseModel(vm, items)),
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ public record ReplaceBlockRequest(
|
||||
BlockStopScheduling StopScheduling,
|
||||
List<BlockItemRequest> Items)
|
||||
{
|
||||
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId) =>
|
||||
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
blockGroupId,
|
||||
blockId,
|
||||
Name,
|
||||
Minutes,
|
||||
StopScheduling,
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList());
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Extensions;
|
||||
@@ -14,11 +15,24 @@ namespace ErsatzTV.Extensions;
|
||||
[SuppressMessage("ReSharper", "VSTHRD003")]
|
||||
public static class ApiResults
|
||||
{
|
||||
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
|
||||
/// <summary>
|
||||
/// Maps a failure to 404 when it is a <see cref="NotFoundError" />, 412 when it is a
|
||||
/// <see cref="PreconditionFailedError" /> (optimistic-concurrency mismatch, issue #253),
|
||||
/// otherwise 422.
|
||||
/// </summary>
|
||||
public static IActionResult ToErrorResult(this BaseError error) =>
|
||||
error is NotFoundError
|
||||
? new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value))
|
||||
: new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value));
|
||||
error switch
|
||||
{
|
||||
NotFoundError =>
|
||||
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value)),
|
||||
PreconditionFailedError =>
|
||||
new ObjectResult(CreateProblemDetails(412, "Precondition Failed", error.Value))
|
||||
{
|
||||
StatusCode = StatusCodes.Status412PreconditionFailed
|
||||
},
|
||||
_ =>
|
||||
new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value))
|
||||
};
|
||||
|
||||
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToCreatedResult<TR>(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using LanguageExt;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ErsatzTV.Extensions;
|
||||
|
||||
/// <summary>Classification of a request's <c>If-Match</c> header for the #253 concurrency contract.</summary>
|
||||
public enum IfMatchKind
|
||||
{
|
||||
/// <summary>No <c>If-Match</c> header — Phase 1 force-write (Phase 2 will make this a 428).</summary>
|
||||
Absent,
|
||||
|
||||
/// <summary><c>If-Match: *</c> — the scripted force-write escape hatch; skip the version check.</summary>
|
||||
Any,
|
||||
|
||||
/// <summary>A strong entity-tag of a decimal aggregate version, e.g. <c>"3"</c>.</summary>
|
||||
Version,
|
||||
|
||||
/// <summary>An unparseable value — the controller returns 400.</summary>
|
||||
Malformed
|
||||
}
|
||||
|
||||
public readonly record struct IfMatchCondition(IfMatchKind Kind, int Version)
|
||||
{
|
||||
/// <summary>The version to check against, or <c>None</c> for absent/wildcard (force-write).</summary>
|
||||
public Option<int> ExpectedVersion => Kind == IfMatchKind.Version ? Version : Option<int>.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse/emit the optimistic-concurrency HTTP headers (issue #253). GET responses carry a strong
|
||||
/// <c>ETag</c> of the aggregate's integer <c>Version</c>; PUT requests carry the last-seen version
|
||||
/// in <c>If-Match</c>. See <c>docs/api-conventions.md</c> §7a.
|
||||
/// </summary>
|
||||
public static class ConcurrencyHeaders
|
||||
{
|
||||
public static IfMatchCondition ParseIfMatch(HttpRequest request)
|
||||
{
|
||||
StringValues raw = request.Headers.IfMatch;
|
||||
if (StringValues.IsNullOrEmpty(raw))
|
||||
{
|
||||
return new IfMatchCondition(IfMatchKind.Absent, 0);
|
||||
}
|
||||
|
||||
string value = raw.ToString().Trim();
|
||||
if (value == "*")
|
||||
{
|
||||
return new IfMatchCondition(IfMatchKind.Any, 0);
|
||||
}
|
||||
|
||||
// Strong entity-tag of a decimal version, e.g. "3". Weak tags (W/"…") are not honored:
|
||||
// this contract's ETags are always strong.
|
||||
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"' &&
|
||||
int.TryParse(value[1..^1], out int version))
|
||||
{
|
||||
return new IfMatchCondition(IfMatchKind.Version, version);
|
||||
}
|
||||
|
||||
return new IfMatchCondition(IfMatchKind.Malformed, 0);
|
||||
}
|
||||
|
||||
public static void SetETag(HttpResponse response, int version) =>
|
||||
response.Headers.ETag = $"\"{version}\"";
|
||||
}
|
||||
@@ -565,7 +565,7 @@
|
||||
"Blocks"
|
||||
],
|
||||
"summary": "Replace a block and its items",
|
||||
"description": "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.",
|
||||
"description": "Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -623,6 +623,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -643,6 +663,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "Precondition Failed",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -672,6 +712,7 @@
|
||||
"Blocks"
|
||||
],
|
||||
"summary": "Get block items",
|
||||
"description": "Returns the block's items and a strong ETag of the block's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
|
||||
Reference in New Issue
Block a user