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