using System.Globalization;
using LanguageExt;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Extensions;
/// Classification of a request's If-Match header for the #253 concurrency contract.
public enum IfMatchKind
{
/// No If-Match header — Phase 1 force-write (Phase 2 will make this a 428).
Absent,
/// If-Match: * — the scripted force-write escape hatch; skip the version check.
Any,
/// A strong entity-tag of a decimal aggregate version, e.g. "3".
Version,
/// An unparseable value — the controller returns 400.
Malformed
}
public readonly record struct IfMatchCondition(IfMatchKind Kind, int Version)
{
/// The version to check against, or None for absent/wildcard (force-write).
public Option ExpectedVersion => Kind == IfMatchKind.Version ? Version : Option.None;
}
///
/// Parse/emit the optimistic-concurrency HTTP headers (issue #253). GET responses carry a strong
/// ETag of the aggregate's integer Version; PUT requests carry the last-seen version
/// in If-Match. See docs/api-conventions.md §7a.
///
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}\"";
}