Merge pull request '#253 PR1 — optimistic-concurrency contract (infra + Block reference)' (#263)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
This commit was merged in pull request #263.
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user