Merge remote-tracking branch 'origin/main' into feat/235-async-contract
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts: # docs/decisions.md
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,77 @@
|
||||
using System.Globalization;
|
||||
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. An ETag is an opaque token, so only the exact
|
||||
// canonical form we emit is accepted — a non-negative decimal with no sign, surrounding
|
||||
// whitespace, or leading zeros (`NumberStyles.None` + the leading-zero guard reject "+3",
|
||||
// " 3 ", and "03", which must NOT be treated as equal to the emitted "3").
|
||||
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||
{
|
||||
string inner = value[1..^1];
|
||||
if (inner.Length > 0 && (inner.Length == 1 || inner[0] != '0') &&
|
||||
int.TryParse(inner, NumberStyles.None, CultureInfo.InvariantCulture, out int version))
|
||||
{
|
||||
return new IfMatchCondition(IfMatchKind.Version, version);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else (a valid-but-non-canonical strong tag like "03", a weak tag W/"3", an
|
||||
// entity-tag list, or plain garbage) is treated as Malformed → 400. Strictly, RFC 7232 would
|
||||
// 412 a syntactically-valid tag that merely doesn't strong-match; that refinement (plus
|
||||
// weak-tag comparison, list support, and 412-vs-404 ordering) is deferred to the #197 cold
|
||||
// contract pass — see #265. This is fail-safe (the mutation is rejected, never applied) and the
|
||||
// first-party SPA only ever echoes the single canonical tag we emit.
|
||||
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