Files
ersatztv/ErsatzTV.Core/VersionedAggregateExtensions.cs
T
timothyandClaude Opus 4.8 8090e10408 fix(api): #265 — If-Match evaluates per RFC 7232 (valid-but-non-matching → 412, not 400)
The shared optimistic-concurrency parser (ConcurrencyHeaders.ParseIfMatch) classified any
non-canonical/weak/list If-Match value as Malformed → 400. Per RFC 7232 §3.1 a syntactically
-valid entity-tag that simply doesn't strong-match must be 412; 400 is only for a genuine
grammar violation.

- Rewrite ParseIfMatch as a real RFC 7232 entity-tag/list parser: walks the comma-separated
  1#entity-tag list, validates each [W/]DQUOTE *etagc DQUOTE member, and collects the strong
  members whose opaque text is our canonical decimal. Weak / empty / non-canonical /
  out-of-range tags are valid but contribute no version (→ empty set → 412); genuine grammar
  violations (unquoted, SP-in-tag, unterminated, garbage) → 400.
- Reshape IfMatchCondition.ExpectedVersion : Option<int> → ExpectedVersions : Option<Seq<int>>
  and VersionedAggregateExtensions.CheckVersion → set membership (any strong match proceeds;
  empty set always 412). Threads through 10 replace/update commands + handlers + request
  mappers + 9 controllers.
- No wire-contract change (400 + 412 already declared on every PUT; the field is header-derived
  and internal — no DTO/route/response-type/OpenAPI change).
- Tests: ConcurrencyHeadersTests rewritten for the new classification (lists, weak, empty,
  non-canonical → Version/empty-set; grammar violations → Malformed) + new
  VersionedAggregateExtensionsTests for CheckVersion membership/empty-set/force-write.
- Docs: api-conventions.md §7a rewritten; decisions.md entry appended.

Refs #253 #197
fixes #265

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:09:36 +02:00

26 lines
1.4 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
namespace ErsatzTV.Core;
public static class VersionedAggregateExtensions
{
/// <summary>
/// Optimistic-concurrency pre-check (issue #253). When the caller supplied one or more expected
/// versions (the strong <c>If-Match</c> entity-tags), fail with <see cref="PreconditionFailedError" />
/// (→ 412) unless the loaded aggregate's version is one of them (RFC 7232 §3.1: any strong match
/// proceeds). An <b>empty</b> set never matches → always 412 (a weak/non-canonical tag was sent).
/// An absent expectation is a force-write (Phase 1 back-compat). This returns a standalone
/// <see cref="Either{L,R}" /> so the 412 is introduced AFTER the validation pipeline and never
/// flattened to 422 by <see cref="LanguageExtensions.Apply{T,TR}" /> (see api-conventions §7a).
/// </summary>
public static Either<BaseError, T> CheckVersion<T>(this T aggregate, Option<Seq<int>> expectedVersions)
where T : IVersionedAggregate =>
expectedVersions.Match(
Some: versions => versions.Contains(aggregate.Version)
? Right<BaseError, T>(aggregate)
: Left<BaseError, T>(new PreconditionFailedError(
"The resource was modified by another request. Reload and try again.")),
None: () => Right<BaseError, T>(aggregate));
}