Files
ersatztv/ErsatzTV/Extensions/ApiResults.cs
T
timothyandClaude Opus 4.8 94ebf34ccd
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
feat(api): optimistic-concurrency contract for replace-all PUTs — PR1 infra + Block reference (#253)
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>
2026-07-11 16:51:59 +02:00

82 lines
3.5 KiB
C#

using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Extensions;
/// <summary>
/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
/// These are additive and must not change the behavior of the existing
/// <see cref="EitherToActionResult" /> / <see cref="OptionToActionResult" /> helpers,
/// which IptvController and other read endpoints depend on.
/// </summary>
[SuppressMessage("ReSharper", "VSTHRD003")]
public static class ApiResults
{
/// <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 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>(
this Either<BaseError, TR> either,
Func<TR, string> location,
Func<TR, object> body) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: value => new CreatedResult(location(value), body(value)));
/// <summary>Right: 200 with body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToUpdatedResult<TR>(this Either<BaseError, TR> either) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: value => (IActionResult)new OkObjectResult(value));
/// <summary>Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToDeletedResult(this Either<BaseError, Unit> either) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: _ => (IActionResult)new NoContentResult());
/// <summary>Some: 200 with body; None: 404.</summary>
public static IActionResult ToGetResult<T>(this Option<T> option) =>
option.Match(
Some: value => (IActionResult)new OkObjectResult(value),
None: () => NotFoundProblem());
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
/// <summary>
/// 409 <see cref="ProblemDetails" /> directly — for a mutation that races a background operation
/// holding a lock (e.g. a playout build in flight). Mirrors <see cref="NotFoundProblem" />.
/// </summary>
public static IActionResult ConflictProblem(string title, string detail) =>
new ConflictObjectResult(CreateProblemDetails(409, title, detail));
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
new()
{
Status = status,
Title = title,
Detail = detail
};
}