Files
ersatztv/ErsatzTV/Extensions/ConcurrencyHeaders.cs
T
timothy 8878bf9e11
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
docs(review): record deferred If-Match 412-semantics refinement (#265) as an acceptable-defer
Codex re-review of the fix commit confirmed both prior findings resolved and raised one
new Medium: RFC 7232 would 412 (not 400) a syntactically-valid but non-matching If-Match
(non-canonical "03", weak W/"3", tag lists, empty, overflow). Deferred to #197 (cold
contract pass) as #265 — fail-safe today (the mutation is rejected, never applied) and no
first-party client is affected. Records the deferral where the #253 fan-out will copy the
parser: a code comment in ConcurrencyHeaders + a note in api-conventions §7a.

Refs #253 #265
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:18:35 +02:00

78 lines
3.4 KiB
C#

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}\"";
}