diff --git a/CLAUDE.md b/CLAUDE.md index 2bafa53..2f1ff3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ errors should stay structured, documented, and close to the code. * `FirstClassErrors.GenDoc` — error-documentation generator (+ `.UnitTests`) * `FirstClassErrors.GenDoc.Worker` — background worker for doc generation * `FirstClassErrors.Cli` — command-line tool +* `FirstClassErrors.RequestBinder` — request binder for the primary-adapter boundary (+ `.UnitTests`) * `FirstClassErrors.Usage` — usage examples * `doc/` — documentation, including the French translation @@ -93,7 +94,7 @@ The essentials, inlined so they hold even if `AGENTS.md` is not read: * Follow `.github/pull_request_template.md` for every pull request. * Do not open a pull request unless I explicitly ask for one. * PR titles, descriptions, commits, and branch names must be written in English. -* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit). +* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit). * Write every pull request title per [`CONTRIBUTING.md`](CONTRIBUTING.md): name the whole change in English; a single-intention PR mirrors its commit header (`type(scope): description`), a multi-intention PR uses a short descriptive title, and issue references stay in the description, not the title. * Enable the local commit-message hook once per clone with `git config core.hooksPath .githooks`; the same check runs in CI on every pull request. * In PR descriptions, do not invent testing results. Only check items that were actually run. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 717241b..55e9e6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -255,6 +255,7 @@ The scope MAY be provided. When present it MUST be lowercase and MUST be one of: |---|---| | `core` | `FirstClassErrors` — the runtime library (`Error`, `Outcome`, `ErrorCode`, `ErrorContextKey`, …) | | `analyzers` | `FirstClassErrors.Analyzers` — the Roslyn analyzers and their `FCExxx` diagnostics | +| `binder` | `FirstClassErrors.RequestBinder` — the request binder for the primary-adapter boundary | | `cli` | `FirstClassErrors.Cli` — the command-line tool | | `gendoc` | `FirstClassErrors.GenDoc` and its worker — the documentation generator | | `testing` | `FirstClassErrors.Testing` — the test-support package | diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs new file mode 100644 index 0000000..e5fc3c9 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -0,0 +1,396 @@ +#region Usings declarations + +using System.Linq.Expressions; +using System.Reflection; + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +/// +/// Pins the edges of the binding contract: the error families a converter may fail with, the wrapping of bare +/// nested failures, the selector plumbing, the guard clauses, the binding scope's read guards, and the binder's +/// own error documentation. +/// +public sealed class BindingContractTests { + + private static BookingRequest Request(string? email = "alice@example.org") { + return new BookingRequest(email, "REF-1", null, null, + new StayDto("2026-08-10", "2026-08-14"), + Tags: null, + Guests: [new GuestDto("Alice", null)]); + } + + #region Error families a converter may fail with + + [Fact(DisplayName = "A converter may fail with a PrimaryPortError: it is wrapped like a domain failure.")] + public void ConverterMayFailWithAPrimaryPortError() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure( + PrimaryPortError.Create(ErrorCode.Create("TEST_PORT_LEVEL_REJECTION"), "Rejected at the port.", Transience.NonTransient) + .WithPublicMessage("Rejected."))); + + Outcome outcome = bind.New(_ => "never"); + Error invalid = outcome.Error!.InnerErrors.Single(); + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_PORT_LEVEL_REJECTION"); + } + + [Fact(DisplayName = "A converter failing with any other error family is a contract violation, reported by throwing.")] + public void ConverterFailingWithAnotherFamilyIsABug() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + InfrastructureError foreignFamily = + InfrastructureError.Create(ErrorCode.Create("TEST_FOREIGN_FAMILY"), "A family the port envelope cannot hold.", + InteractionDirection.Outgoing, Transience.Unknown) + .WithPublicMessage("Foreign."); + + InvalidOperationException exception = Assert.Throws( + () => { bind.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure(foreignFamily)); }); + // The bug-channel exception must locate the bug: which argument, and which unsupported family. + Check.That(exception.Message).Contains("GuestEmail"); + Check.That(exception.Message).Contains("InfrastructureError"); + } + + #endregion + + #region Bare nested failures are wrapped so the path survives + + [Fact(DisplayName = "A nested binding that fails without building its envelope is wrapped, so the argument path survives.")] + public void BareNestedFailureIsWrapped() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) + .AsRequired(_ => Outcome.Failure(BookingDomainError.DateInvalid("raw"))); + + Outcome outcome = bind.New(_ => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay"); + Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_DATE_INVALID"); + } + + [Fact(DisplayName = "A list element whose nested binding fails without building its envelope is wrapped under its indexed path.")] + public void BareNestedElementFailureIsWrapped() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsRequired(_ => Outcome.Failure(BookingDomainError.EmailInvalid("raw"))); + + Outcome outcome = bind.New(_ => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Guests[0]"); + } + + private static PrimaryPortError LeafPortError() { + return PrimaryPortError.Create(ErrorCode.Create("TEST_NESTED_PORT_LEAF"), "A bare port-level leaf, not a build-terminal envelope.", + Transience.NonTransient) + .WithPublicMessage("Rejected."); + } + + [Fact(DisplayName = "A required nested binding that fails with a bare PrimaryPortError leaf (not its build-terminal envelope) is wrapped, so the path survives.")] + public void BareNestedPrimaryPortErrorLeafIsWrapped() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) + .AsRequired(_ => Outcome.Failure(LeafPortError())); + + Outcome outcome = bind.New(_ => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + // A leaf PrimaryPortError is NOT the nested build-terminal envelope, so it is wrapped under the path — exactly like a + // DomainError, and unlike the old by-type check that recorded it as-is and dropped the "Stay" context. + Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay"); + Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NESTED_PORT_LEAF"); + } + + [Fact(DisplayName = "An optional nested binding that fails with a bare PrimaryPortError leaf is wrapped under the path too.")] + public void BareOptionalNestedPrimaryPortErrorLeafIsWrapped() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) + .AsOptional(_ => Outcome.Failure(LeafPortError())); + + Outcome outcome = bind.New(_ => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Stay"); + Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NESTED_PORT_LEAF"); + } + + [Fact(DisplayName = "A list element whose nested binding fails with a bare PrimaryPortError leaf is wrapped under its indexed path.")] + public void BareNestedElementPrimaryPortErrorLeafIsWrapped() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsRequired(_ => Outcome.Failure(LeafPortError())); + + Outcome outcome = bind.New(_ => "never"); + Error wrapped = outcome.Error!.InnerErrors.Single(); + Check.That(wrapped.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(wrapped)).IsEqualTo("Guests[0]"); + Check.That(wrapped.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NESTED_PORT_LEAF"); + } + + #endregion + + #region Remaining list shapes + + [Fact(DisplayName = "A required list of complex properties that is missing records REQUEST_ARGUMENT_REQUIRED; its envelope is never invoked.")] + public void RequiredComplexListMissing() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, Guests: null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsRequired(g => g.New(_ => new Guest("never", null))); + + Outcome outcome = bind.New(_ => "never"); + Error required = outcome.Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Guests"); + } + + [Fact(DisplayName = "A null element that is the first failing element of a simple list is collected in order, like any other.")] + public void NullElementAsFirstFailureOfASimpleList() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, Tags: ["ok", null, "not ok"], null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) + .ContainsExactly("REQUEST_ARGUMENT_REQUIRED", "REQUEST_ARGUMENT_INVALID"); + Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("Tags[1]", "Tags[2]"); + } + + [Fact(DisplayName = "An optional list of complex properties that is present still collects its failing elements.")] + public void OptionalComplexListPresentCollectsFailures() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, Guests: [new GuestDto(null, null)])) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsOptional(g => { + RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + + return g.New(s => new Guest(s.Get(firstName), null)); + }); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_GUEST_INVALID"); + } + + #endregion + + #region Selector plumbing + + private sealed record Counted(int Count); + + [Fact(DisplayName = "The selector resolver unwraps the Convert node a boxing selector produces.")] + public void SelectorResolverUnwrapsConvertNodes() { + // Boxing a value-type property forces the compiler to emit a Convert node around the member access. + Expression> boxing = c => c.Count; + + PropertyInfo property = PropertySelectors.GetProperty(boxing); + + Check.That(property.Name).IsEqualTo("Count"); + } + + [Fact(DisplayName = "The selector resolver unwraps EVERY stacked Convert node, not just the outermost.")] + public void SelectorResolverUnwrapsNestedConvertNodes() { + // Widening then boxing a value-type property stacks two Convert nodes: Convert(Convert(c.Count, long), object). + Expression> doublyBoxed = c => (long)c.Count; + + PropertyInfo property = PropertySelectors.GetProperty(doublyBoxed); + + Check.That(property.Name).IsEqualTo("Count"); + } + + [Fact(DisplayName = "A null selector is a programming error and throws.")] + public void NullSelectorThrows() { + Check.ThatCode(() => PropertySelectors.GetProperty(null!)) + .Throws(); + } + + private sealed class WithField { + + internal int Field = 42; + + } + + [Fact(DisplayName = "A selector pointing at a field rather than a property is rejected: an argument path needs a property name.")] + public void FieldSelectorIsRejected() { + Expression> fieldSelector = w => w.Field; + + Check.ThatCode(() => PropertySelectors.GetProperty(fieldSelector)).Throws(); + } + + #endregion + + #region Non-nullable value-type DTO properties are rejected + + private sealed record ValueTypeRequest(int Count, int? OptionalCount); + + [Fact(DisplayName = "Selecting a non-nullable value-type property is rejected: a missing value would be indistinguishable from its default.")] + public void NonNullableValueTypePropertyIsRejected() { + var bind = Bind.PropertiesOf(new ValueTypeRequest(0, null)).FailWith(BookingEnvelopeError.CommandInvalid); + + // A non-nullable int cannot be null, so "missing" (deserialized to 0) is undetectable -> loud programming error. + ArgumentException exception = Assert.Throws(() => { bind.SimpleProperty(r => r.Count); }); + Check.That(exception.Message).Contains("Count"); + Check.That(exception.Message).Contains("nullable"); + + // A nullable value type is fine: an absent argument arrives as null and is detectable. + Check.ThatCode(() => bind.SimpleProperty(r => r.OptionalCount)).DoesNotThrow(); + } + + #endregion + + #region Guard clauses: every entry point rejects null collaborators + + [Fact(DisplayName = "Every entry point rejects a null collaborator with ArgumentNullException.")] + public void GuardClauses() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + Check.ThatCode(() => Bind.PropertiesOf(null!)).Throws(); + Check.ThatCode(() => Bind.PropertiesOf(Request()).FailWith(null!)).Throws(); + Check.ThatCode(() => bind.WithOptions(null!)).Throws(); + Check.ThatCode(() => bind.New(null!)).Throws(); + Check.ThatCode(() => bind.Create(null!)).Throws(); + + Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail).AsRequired(null!)).Throws(); + Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail).AsOptional(null!, "x")).Throws(); + Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(null!)).Throws(); + Check.ThatCode(() => bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(null!)).Throws(); + + Check.ThatCode(() => bind.ComplexProperty(r => r.Stay).FailWith(null!)).Throws(); + Check.ThatCode(() => bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(null!)).Throws(); + Check.ThatCode(() => bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(null!)).Throws(); + + Check.ThatCode(() => bind.ListOfSimpleProperties(r => r.Tags).AsRequired(null!)).Throws(); + Check.ThatCode(() => bind.ListOfSimpleProperties(r => r.Tags).AsOptional(null!)).Throws(); + Check.ThatCode(() => bind.ListOfComplexProperties(r => r.Guests).FailWith(null!)).Throws(); + Check.ThatCode(() => bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(null!)).Throws(); + Check.ThatCode(() => bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(null!)).Throws(); + + Check.ThatCode(() => new RequestBinderOptions(null!)).Throws(); + Check.ThatCode(() => RequestBinderOptions.Default.ArgumentNameProvider.GetArgumentNameFrom(null!)).Throws(); + } + + #endregion + + #region The binding scope guards its reads + + [Fact(DisplayName = "The binding scope rejects a null field and a field owned by a different binder — on every Get overload, programming errors both.")] + public void BindingScopeGuardsItsReads() { + var binderA = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField requiredOfA = binderA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + OptionalReferenceField optRefOfA = binderA.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + OptionalValueField optValOfA = binderA.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + // A null field, on each of the three Get overloads (the assembler runs because `bind` recorded no failure): + Check.ThatCode(() => bind.New(s => s.Get((RequiredField)null!).Value)).Throws(); + Check.ThatCode(() => bind.New(s => s.Get((OptionalReferenceField)null!) is null)).Throws(); + Check.ThatCode(() => bind.New(s => s.Get((OptionalValueField)null!).HasValue)).Throws(); + + // A field owned by a different binder is a cross-binder mix-up — rejected loudly on every overload, not read silently. + Check.ThatCode(() => bind.New(s => s.Get(requiredOfA).Value)).Throws(); + Check.ThatCode(() => bind.New(s => s.Get(optRefOfA) is null)).Throws(); + Check.ThatCode(() => bind.New(s => s.Get(optValOfA).HasValue)).Throws(); + } + + [Fact(DisplayName = "A cross-binder field read is rejected with a message naming the different-binder cause, not a blank exception.")] + public void CrossBinderReadMessageNamesTheCause() { + var binderA = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField requiredOfA = binderA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + InvalidOperationException exception = Assert.Throws( + () => { bind.New(s => s.Get(requiredOfA).Value); }); + Check.That(exception.Message).Contains("different binder"); + } + + #endregion + + #region The binder-owned errors carry their coded messages + + [Fact(DisplayName = "REQUEST_ARGUMENT_REQUIRED carries its public summary, and its detailed and diagnostic messages name the argument path.")] + public void RequiredArgumentErrorCarriesItsMessages() { + PrimaryPortError error = RequestBindingError.ArgumentRequired("GuestEmail"); + + Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(error.ShortMessage).IsEqualTo("A required argument is missing."); + Check.That(error.DetailedMessage).Contains("GuestEmail"); + Check.That(error.DiagnosticMessage).Contains("GuestEmail"); + Check.That(error.DiagnosticMessage).Contains("required"); + } + + [Fact(DisplayName = "REQUEST_ARGUMENT_INVALID carries its public summary, its detailed message names the path, and it wraps the cause.")] + public void InvalidArgumentErrorCarriesItsMessages() { + PrimaryPortError error = RequestBindingError.ArgumentInvalid("GuestEmail", BookingDomainError.EmailInvalid("x")); + + Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(error.ShortMessage).IsEqualTo("An argument is invalid."); + Check.That(error.DetailedMessage).Contains("GuestEmail"); + Check.That(error.DiagnosticMessage).Contains("GuestEmail"); + Check.That(error.DiagnosticMessage).Contains("invalid"); + Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); + } + + #endregion + + #region The binder's own errors are documented + + [Fact(DisplayName = "Every binder-owned error factory is documented: its documentation method yields a titled documentation with a live example.")] + public void BinderOwnedErrorsAreDocumented() { + MethodInfo[] documented = typeof(RequestBindingError) + .GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + .Where(m => m.GetCustomAttribute() is not null) + .ToArray(); + + Check.That(documented).HasSize(2); + + foreach (MethodInfo factory in documented) { + string documentationMethodName = factory.GetCustomAttribute()!.MethodName; + MethodInfo documentationMethod = typeof(RequestBindingError) + .GetMethod(documentationMethodName, BindingFlags.Static | BindingFlags.NonPublic)!; + + var documentation = (ErrorDocumentation)documentationMethod.Invoke(null, null)!; + + Check.That(documentation.Title).IsNotEmpty(); + Check.That(documentation.Examples).Not.IsEmpty(); + } + } + + [Fact(DisplayName = "The REQUEST_ARGUMENT_REQUIRED documentation lists both diagnoses, classified external then internal.")] + public void RequiredArgumentDocumentationClassifiesItsDiagnoses() { + MethodInfo documentationMethod = typeof(RequestBindingError) + .GetMethod("ArgumentRequiredDocumentation", BindingFlags.Static | BindingFlags.NonPublic)!; + var documentation = (ErrorDocumentation)documentationMethod.Invoke(null, null)!; + + // Both causes are documented, and a client omitting an argument is an EXTERNAL fault while the name-provider + // mismatch is INTERNAL. A dropped diagnosis or a flipped origin would mislead whoever triages the error. + Check.That(documentation.Diagnostics.Select(d => d.Origin)) + .ContainsExactly(ErrorOrigin.External, ErrorOrigin.Internal); + } + + [Fact(DisplayName = "The REQUEST_ARGUMENT_INVALID documentation lists both diagnoses, classified external then internal.")] + public void InvalidArgumentDocumentationClassifiesItsDiagnoses() { + MethodInfo documentationMethod = typeof(RequestBindingError) + .GetMethod("ArgumentInvalidDocumentation", BindingFlags.Static | BindingFlags.NonPublic)!; + var documentation = (ErrorDocumentation)documentationMethod.Invoke(null, null)!; + + Check.That(documentation.Diagnostics.Select(d => d.Origin)) + .ContainsExactly(ErrorOrigin.External, ErrorOrigin.Internal); + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs new file mode 100644 index 0000000..fda6d04 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs @@ -0,0 +1,96 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +/// +/// End-to-end scenario mirroring the reference example of the design spec (issue #126): a full booking command +/// bound in one pass, exercising every converter shape, then rejoining the Outcome pipeline. +/// +public sealed class BookingEndToEndTests { + + private static Outcome BindStay(RequestBinder stay) { + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + } + + private static Outcome BindGuest(RequestBinder guest) { + RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + + return guest.New(s => new Guest(s.Get(firstName), s.Get(email))); + } + + private static Outcome BindCommand(BookingRequest request) { + var bind = Bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + OptionalReferenceField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + return bind.New(s => new BookingCommand( + s.Get(email), s.Get(reference), s.Get(currency), s.Get(maxNights), + s.Get(stay), s.Get(tags), s.Get(guests))); + } + + [Fact(DisplayName = "A fully valid request binds into the complete command and flows through Then/Finally.")] + public void FullyValidRequestBindsAndRejoinsThePipeline() { + BookingRequest request = new( + "alice@example.org", "REF-42", null, "7", + new StayDto("2026-08-10", "2026-08-14"), + ["vip"], + [new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)]); + + string confirmation = BindCommand(request) + .Then(command => Outcome.Success($"{command.Reference}:{command.Currency.Code}:{command.MaxNights}:{command.Guests.Count}")) + .Finally(result => result, error => $"rejected:{error.Code}"); + + Check.That(confirmation).IsEqualTo("REF-42:EUR:7:2"); + } + + [Fact(DisplayName = "A request failing at every level yields the full error tree of the spec, and no exception.")] + public void InvalidRequestYieldsTheFullTree() { + BookingRequest request = new( + "not-an-email", null, "EURO", "0", + new StayDto("not-a-date", "2026-08-14"), + ["ok", "not ok"], + [new GuestDto("Alice", null), new GuestDto(null, "still-not-an-email")]); + + Outcome outcome = BindCommand(request); + + Check.That(outcome.IsFailure).IsTrue(); + Error envelope = outcome.Error!; + Check.That(envelope.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID"); + + // Envelope children, in declaration order: + // GuestEmail invalid, Reference missing, Currency invalid, MaxNights invalid, + // Stay envelope (present but invalid), Tags[1] invalid, Guests[1] envelope. + Check.That(envelope.InnerErrors).HasSize(7); + Check.That(envelope.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly( + "REQUEST_ARGUMENT_INVALID", // GuestEmail + "REQUEST_ARGUMENT_REQUIRED", // Reference + "REQUEST_ARGUMENT_INVALID", // Currency + "REQUEST_ARGUMENT_INVALID", // MaxNights + "TEST_STAY_INVALID", // Stay (nested envelope) + "REQUEST_ARGUMENT_INVALID", // Tags[1] + "TEST_GUEST_INVALID"); // Guests[1] (nested envelope) + + Error stayEnvelope = envelope.InnerErrors[4]; + Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Stay.CheckIn"); + Check.That(stayEnvelope.InnerErrors[0].InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_DATE_INVALID"); + + Error guestEnvelope = envelope.InnerErrors[6]; + Check.That(guestEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("Guests[1].FirstName", "Guests[1].Email"); + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs new file mode 100644 index 0000000..f732883 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs @@ -0,0 +1,87 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class ComplexPropertyBindingTests { + + private static Outcome BindStay(RequestBinder stay) { + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + } + + private static BookingRequest RequestWith(StayDto? stay) { + return new BookingRequest("alice@example.org", "REF-1", "EUR", null, stay, Tags: null, Guests: null); + } + + [Fact(DisplayName = "A required complex property binds through its nested binder when every field is valid.")] + public void RequiredComplexBinds() { + var bind = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + + Outcome outcome = bind.New(s => s.Get(stay)); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow().CheckIn.Value).IsEqualTo(new DateOnly(2026, 8, 10)); + } + + [Fact(DisplayName = "A failed nested binding surfaces as its own envelope, whose inner paths are prefixed with the property name.")] + public void NestedFailureSurfacesAsItsEnvelopeWithPrefixedPaths() { + var bind = Bind.PropertiesOf(RequestWith(new StayDto("not-a-date", null))).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + + Error stayEnvelope = outcome.Error!.InnerErrors.Single(); + Check.That(stayEnvelope.Code.ToString()).IsEqualTo("TEST_STAY_INVALID"); + + // Both nested failures are collected, and their paths carry the "Stay." prefix. + Check.That(stayEnvelope.InnerErrors).HasSize(2); + Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("Stay.CheckIn", "Stay.CheckOut"); + Check.That(stayEnvelope.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(stayEnvelope.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + } + + [Fact(DisplayName = "A required complex property that is missing records REQUEST_ARGUMENT_REQUIRED; its envelope is never invoked.")] + public void RequiredComplexMissing() { + var bind = Bind.PropertiesOf(RequestWith(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + + Outcome outcome = bind.New(_ => "never"); + Error required = outcome.Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Stay"); + } + + [Fact(DisplayName = "An optional complex property yields null when absent — recording nothing — and binds when present.")] + public void OptionalComplex() { + var absent = Bind.PropertiesOf(RequestWith(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalReferenceField none = absent.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); + + var present = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalReferenceField some = present.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + Check.That(present.New(s => s.Get(some)!.CheckOut.Value).GetResultOrThrow()).IsEqualTo(new DateOnly(2026, 8, 14)); + } + + [Fact(DisplayName = "An optional complex property that is present but invalid records its envelope.")] + public void OptionalComplexPresentButInvalidRecords() { + var bind = Bind.PropertiesOf(RequestWith(new StayDto(null, null))).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_STAY_INVALID"); + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj b/FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj new file mode 100644 index 0000000..0aec062 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs new file mode 100644 index 0000000..153ff26 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -0,0 +1,172 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class ListBindingTests { + + private static Outcome BindGuest(RequestBinder guest) { + RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + + return guest.New(s => new Guest(s.Get(firstName), s.Get(email))); + } + + private static BookingRequest RequestWith(IReadOnlyList? tags = null, IReadOnlyList? guests = null) { + return new BookingRequest("alice@example.org", "REF-1", "EUR", null, Stay: null, tags, guests); + } + + [Fact(DisplayName = "A required list of simple properties binds every element, in order.")] + public void RequiredSimpleListBinds() { + var bind = Bind.PropertiesOf(RequestWith(tags: ["vip", "late-checkout"])).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + + Outcome> outcome = bind.New(s => s.Get(tags)); + Check.That(outcome.GetResultOrThrow().Select(t => t.Value)).ContainsExactly("vip", "late-checkout"); + } + + [Fact(DisplayName = "A required list that is missing records REQUEST_ARGUMENT_REQUIRED.")] + public void RequiredSimpleListMissing() { + var bind = Bind.PropertiesOf(RequestWith(tags: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + + Outcome outcome = bind.New(_ => "never"); + Error required = outcome.Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Tags"); + } + + [Fact(DisplayName = "Every failing element of a list is collected, each under its indexed path — one bad element never hides the others.")] + public void EveryFailingElementIsCollected() { + var bind = Bind.PropertiesOf(RequestWith(tags: ["ok", "not ok", null, "fine"])).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.Error!.InnerErrors).HasSize(2); + Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("Tags[1]", "Tags[2]"); + Check.That(outcome.Error!.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(outcome.Error!.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + } + + [Fact(DisplayName = "An optional list that is absent binds an empty list — never null — and records nothing.")] + public void OptionalListAbsentBindsEmpty() { + var bind = Bind.PropertiesOf(RequestWith(tags: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + + Outcome> outcome = bind.New(s => s.Get(tags)); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEmpty(); + } + + [Fact(DisplayName = "A required list of complex properties binds every element through its own nested binder.")] + public void RequiredComplexListBinds() { + var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)])) + .FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField> guests = + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + Outcome> outcome = bind.New(s => s.Get(guests)); + Check.That(outcome.GetResultOrThrow().Select(g => g.FirstName)).ContainsExactly("Alice", "Bob"); + Check.That(outcome.GetResultOrThrow()[1].Email).IsNull(); + } + + [Fact(DisplayName = "Each failing element of a complex list records its own envelope, whose inner paths carry the indexed prefix.")] + public void FailingComplexElementsRecordTheirEnvelopes() { + var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), new GuestDto(null, "nope"), new GuestDto(null, null)])) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.Error!.InnerErrors).HasSize(2); + + Error second = outcome.Error!.InnerErrors[0]; + Check.That(second.Code.ToString()).IsEqualTo("TEST_GUEST_INVALID"); + Check.That(second.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("Guests[1].FirstName", "Guests[1].Email"); + + Error third = outcome.Error!.InnerErrors[1]; + Check.That(third.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Guests[2].FirstName"); + } + + [Fact(DisplayName = "A null element of a complex list records REQUEST_ARGUMENT_REQUIRED under its indexed path.")] + public void NullComplexElementIsRequired() { + var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), null])) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + Outcome outcome = bind.New(_ => "never"); + Error required = outcome.Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Guests[1]"); + } + + [Fact(DisplayName = "A null element does not hide the failing elements that follow it — each is still collected under its own indexed path.")] + public void NullComplexElementDoesNotHideLaterFailures() { + var bind = Bind.PropertiesOf(RequestWith(guests: [null, new GuestDto(null, "nope")])) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + Outcome outcome = bind.New(_ => "never"); + + // Both the null element AND the invalid element after it must be collected — the null must not short-circuit. + Check.That(outcome.Error!.InnerErrors).HasSize(2); + + Error nullElement = outcome.Error!.InnerErrors[0]; + Check.That(nullElement.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(nullElement)).IsEqualTo("Guests[0]"); + + Error secondEnvelope = outcome.Error!.InnerErrors[1]; + Check.That(secondEnvelope.Code.ToString()).IsEqualTo("TEST_GUEST_INVALID"); + Check.That(secondEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("Guests[1].FirstName", "Guests[1].Email"); + } + + [Fact(DisplayName = "An optional complex list that is absent binds an empty list and records nothing.")] + public void OptionalComplexListAbsentBindsEmpty() { + var bind = Bind.PropertiesOf(RequestWith(guests: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField> guests = + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); + + Outcome> outcome = bind.New(s => s.Get(guests)); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEmpty(); + } + + [Fact(DisplayName = "A custom argument-name provider renames the inner paths of complex-list elements: the element binder inherits the parent options.")] + public void CustomNameProviderRenamesComplexListElementPaths() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, null, null, Guests: [new GuestDto(null, "nope")])) + .FailWith(BookingEnvelopeError.CommandInvalid) + .WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())); + + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + + Outcome outcome = bind.New(_ => "never"); + Error guestEnvelope = outcome.Error!.InnerErrors.Single(e => e.Code.ToString() == "TEST_GUEST_INVALID"); + // FirstName (required, absent) and Email ("nope", invalid) are both collected; their names must be + // snake_cased by the INHERITED provider, not the default PascalCase — proving the element binder inherits options. + Check.That(guestEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("guests[0].first_name", "guests[0].email"); + } + + private sealed class SnakeCaseNameProvider : IArgumentNameProvider { + + public string GetArgumentNameFrom(System.Reflection.PropertyInfo property) { + return string.Concat(property.Name.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + char.ToLowerInvariant(c) : char.ToLowerInvariant(c).ToString())); + } + + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs new file mode 100644 index 0000000..ad52e17 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -0,0 +1,212 @@ +#region Usings declarations + +using System.Reflection; + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class RequestBinderTests { + + private static Outcome BindStay(RequestBinder stay) { + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + } + + private static readonly ErrorCode CheckOutNotAfterCheckIn = ErrorCode.Create("TEST_CHECKOUT_NOT_AFTER_CHECKIN"); + + // A validating factory: every field is already bound, yet a cross-field rule — check-out strictly after check-in — + // can still reject an all-valid combination. That is exactly what Create (unlike New) can express. + private static Outcome AssembleStay(BookingDate checkIn, BookingDate checkOut) { + return checkOut.Value > checkIn.Value + ? Outcome.Success(new Stay(checkIn, checkOut)) + : Outcome.Failure(DomainError.Create(CheckOutNotAfterCheckIn, "Check-out must be after check-in.") + .WithPublicMessage("The stay dates are inconsistent.")); + } + + [Fact(DisplayName = "New assembles the command exactly once when every property bound.")] + public void NewAssemblesOnceOnSuccess() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", "EUR", null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + int assembled = 0; + Outcome outcome = bind.New(s => { + assembled++; + + return s.Get(email).Value; + }); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(assembled).IsEqualTo(1); + } + + [Fact(DisplayName = "New never runs the assembler when a failure was recorded — field reads are safe by construction.")] + public void NewNeverAssemblesOnFailure() { + var bind = Bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + bool assembled = false; + Outcome outcome = bind.New(_ => { + assembled = true; + + return "never"; + }); + + Check.That(outcome.IsFailure).IsTrue(); + Check.That(assembled).IsFalse(); + } + + [Fact(DisplayName = "Create flattens the validating factory's success into Outcome — the command itself, not a nested Outcome.")] + public void CreateFlattensFactorySuccess() { + var bind = Bind.PropertiesOf(new StayDto("2026-08-10", "2026-08-14")) + .FailWith(BookingEnvelopeError.StayInvalid); + RequiredField checkIn = bind.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = bind.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + Outcome outcome = bind.Create(s => AssembleStay(s.Get(checkIn), s.Get(checkOut))); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow().CheckOut.Value).IsEqualTo(new DateOnly(2026, 8, 14)); + } + + [Fact(DisplayName = "Create returns the validating factory's own failure as-is — a cross-field rule surfaces undisguised, not wrapped in the binder envelope.")] + public void CreateReturnsFactoryFailureAsIs() { + var bind = Bind.PropertiesOf(new StayDto("2026-08-14", "2026-08-10")) + .FailWith(BookingEnvelopeError.StayInvalid); + RequiredField checkIn = bind.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = bind.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + Outcome outcome = bind.Create(s => AssembleStay(s.Get(checkIn), s.Get(checkOut))); + + Check.That(outcome.IsFailure).IsTrue(); + // The factory's error is returned directly: its own code, and NOT wrapped under the binder's TEST_STAY_INVALID envelope. + Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_CHECKOUT_NOT_AFTER_CHECKIN"); + Check.That(outcome.Error).IsInstanceOf(); + } + + [Fact(DisplayName = "Create never calls the validating factory when a binding failed — it returns the envelope, factory untouched.")] + public void CreateNeverCallsFactoryOnBindingFailure() { + var bind = Bind.PropertiesOf(new StayDto(null, "2026-08-14")) + .FailWith(BookingEnvelopeError.StayInvalid); + RequiredField checkIn = bind.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = bind.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + bool factoryCalled = false; + Outcome outcome = bind.Create(s => { + factoryCalled = true; + + return AssembleStay(s.Get(checkIn), s.Get(checkOut)); + }); + + Check.That(outcome.IsFailure).IsTrue(); + Check.That(factoryCalled).IsFalse(); + Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_STAY_INVALID"); + Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly("REQUEST_ARGUMENT_REQUIRED"); + } + + [Fact(DisplayName = "Every failing property is collected into the envelope, in declaration order — collect-all, not first-failure.")] + public void CollectsEveryFailure() { + var bind = Bind.PropertiesOf(new BookingRequest("nope", null, "EURO", null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + bind.SimpleProperty(r => r.Reference).AsRequired(); + bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID"); + Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) + .ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED", "REQUEST_ARGUMENT_INVALID"); + Check.That(outcome.Error!.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("GuestEmail", "Reference", "Currency"); + } + + [Fact(DisplayName = "A fully invalid request binds without a single exception being thrown.")] + public void FullyInvalidRequestThrowsNothing() { + Check.ThatCode(() => { + var bind = Bind.PropertiesOf(new BookingRequest("nope", null, "EURO", "-1", new StayDto(null, "bad"), ["a b", null], [null, new GuestDto(null, "x")])) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + bind.SimpleProperty(r => r.Reference).AsRequired(); + bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(g => { + RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + + return g.New(s => new Guest(s.Get(firstName), null)); + }); + + return bind.New(_ => "never"); + }) + .DoesNotThrow(); + } + + [Fact(DisplayName = "A converter that throws is a bug: the exception propagates to the host, undisguised.")] + public void ThrowingConverterPropagates() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail) + .AsRequired(_ => throw new FormatException("converter bug"))) + .Throws(); + } + + [Fact(DisplayName = "A selector that is not a direct property access is a programming error and throws.")] + public void InvalidSelectorThrows() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + // A method call on the property — not a direct member access; the exception echoes the offending selector. + ArgumentException exception = Assert.Throws( + () => { bind.SimpleProperty(r => r.GuestEmail!.ToUpperInvariant()); }); + Check.That(exception.Message).Contains("ToUpperInvariant"); + // A nested property access — the member's base is not the request parameter. + Check.ThatCode(() => bind.SimpleProperty(r => r.Stay!.CheckIn)) + .Throws(); + } + + [Fact(DisplayName = "A custom argument-name provider renames the paths — and nested binders inherit it.")] + public void CustomNameProviderRenamesPaths() { + var bind = Bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, new StayDto(null, null), null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid) + .WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + + Outcome outcome = bind.New(_ => "never"); + Error stayEnvelope = outcome.Error!.InnerErrors.Single(e => e.Code.ToString() == "TEST_STAY_INVALID"); + Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)) + .ContainsExactly("stay.check_in", "stay.check_out"); + } + + [Fact(DisplayName = "Binding failures are non-transient: resubmitting the same request cannot succeed.")] + public void MissingArgumentIsNonTransient() { + var bind = Bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)) + .FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(_ => "never"); + var required = (InfrastructureError)outcome.Error!.InnerErrors.Single(); + Check.That(required.Transience).IsEqualTo(Transience.NonTransient); + } + + private sealed class SnakeCaseNameProvider : IArgumentNameProvider { + + public string GetArgumentNameFrom(PropertyInfo property) { + return string.Concat(property.Name.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + char.ToLowerInvariant(c) : char.ToLowerInvariant(c).ToString())); + } + + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs new file mode 100644 index 0000000..0acb03a --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -0,0 +1,155 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +public sealed class SimplePropertyBindingTests { + + private static BookingRequest Request(string? email = "alice@example.org", + string? currency = "EUR", + string? nights = "3") { + return new BookingRequest(email, "REF-1", currency, nights, Stay: null, Tags: null, Guests: null); + } + + [Fact(DisplayName = "A required property that is present and valid binds its value.")] + public void RequiredPresentAndValidBinds() { + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(s => s.Get(email).Value); + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo("alice@example.org"); + } + + [Fact(DisplayName = "A required property that is missing fails the build with REQUEST_ARGUMENT_REQUIRED, carrying the argument path.")] + public void RequiredMissingFails() { + var bind = Bind.PropertiesOf(Request(email: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID"); + + Error required = outcome.Error!.InnerErrors.Single(); + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("GuestEmail"); + } + + [Fact(DisplayName = "A required property that is present but invalid fails with REQUEST_ARGUMENT_INVALID wrapping the converter's error.")] + public void RequiredInvalidWrapsTheConverterError() { + var bind = Bind.PropertiesOf(Request(email: "not-an-email")).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + + Error invalid = outcome.Error!.InnerErrors.Single(); + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("GuestEmail"); + Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); + } + + [Fact(DisplayName = "A required property without conversion binds the raw value when present, and fails when missing.")] + public void RequiredWithoutConversion() { + var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField reference = present.SimpleProperty(r => r.Reference).AsRequired(); + Check.That(present.New(s => s.Get(reference)).GetResultOrThrow()).IsEqualTo("REF-1"); + + var missing = Bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)).FailWith(BookingEnvelopeError.CommandInvalid); + missing.SimpleProperty(r => r.Reference).AsRequired(); + Outcome outcome = missing.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + } + + [Fact(DisplayName = "An optional property with a fallback converts the provided value when present, and the fallback when absent.")] + public void OptionalWithFallback() { + var provided = Bind.PropertiesOf(Request(currency: "USD")).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField providedCurrency = provided.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + Check.That(provided.New(s => s.Get(providedCurrency).Code).GetResultOrThrow()).IsEqualTo("USD"); + + var absent = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid); + RequiredField defaulted = absent.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + Check.That(absent.New(s => s.Get(defaulted).Code).GetResultOrThrow()).IsEqualTo("EUR"); + } + + [Fact(DisplayName = "An optional property that is present but invalid still records an error: optional never means malformed.")] + public void OptionalPresentButInvalidRecords() { + var bind = Bind.PropertiesOf(Request(currency: "EURO")).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + + Outcome outcome = bind.New(_ => "never"); + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + } + + [Fact(DisplayName = "An optional fallback that does not convert is a developer bug and throws, naming the misconfigured argument.")] + public void InvalidFallbackIsABug() { + var bind = Bind.PropertiesOf(Request(currency: null)).FailWith(BookingEnvelopeError.CommandInvalid); + + InvalidOperationException exception = Assert.Throws( + () => { bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "NOT-A-CURRENCY"); }); + Check.That(exception.Message).Contains("Currency"); + // The exception must carry the converter's DIAGNOSTIC detail (the raw offending value), not the sanitized + // public summary — so a developer can see WHY the configured fallback is rejected. + Check.That(exception.Message).Contains("NOT-A-CURRENCY"); + } + + [Fact(DisplayName = "An optional reference property yields null when absent — recording nothing — and the value when present.")] + public void OptionalReference() { + var absent = Bind.PropertiesOf(Request(email: null)).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalReferenceField none = absent.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); + + var present = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalReferenceField some = present.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + Check.That(present.New(s => s.Get(some)!.Value).GetResultOrThrow()).IsEqualTo("alice@example.org"); + } + + [Fact(DisplayName = "An optional reference property that is present but invalid records REQUEST_ARGUMENT_INVALID wrapping the converter's error.")] + public void OptionalReferencePresentButInvalidRecords() { + var bind = Bind.PropertiesOf(Request(email: "nope")).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + + Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("GuestEmail"); + Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); + } + + [Fact(DisplayName = "An optional value property yields a real null when absent — never default(T): an absent count is null, not 0.")] + public void OptionalValueYieldsNullWhenAbsent() { + var absent = Bind.PropertiesOf(Request(nights: null)).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalValueField none = absent.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + // Project to a non-null bool: New's TCommand cannot itself be int? (Nullable is not `notnull`); + // a real consumer flows s.Get(none) straight into a command constructor argument instead. + Outcome absentOutcome = absent.New(s => s.Get(none).HasValue); + Check.That(absentOutcome.IsSuccess).IsTrue(); + Check.That(absentOutcome.GetResultOrThrow()).IsFalse(); + + var present = Bind.PropertiesOf(Request(nights: "5")).FailWith(BookingEnvelopeError.CommandInvalid); + OptionalValueField some = present.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + Check.That(present.New(s => s.Get(some) ?? 0).GetResultOrThrow()).IsEqualTo(5); + } + + [Fact(DisplayName = "An optional value property that is present but invalid records REQUEST_ARGUMENT_INVALID wrapping the converter's error.")] + public void OptionalValuePresentButInvalidRecords() { + var bind = Bind.PropertiesOf(Request(nights: "-2")).FailWith(BookingEnvelopeError.CommandInvalid); + + bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + + Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); + Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("MaxNights"); + Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NOT_A_POSITIVE_NUMBER"); + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/TestModel.cs b/FirstClassErrors.RequestBinder.UnitTests/TestModel.cs new file mode 100644 index 0000000..abd0da3 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/TestModel.cs @@ -0,0 +1,196 @@ +namespace FirstClassErrors.RequestBinder.UnitTests; + +#region Request DTOs + +internal sealed record BookingRequest( + string? GuestEmail, + string? Reference, + string? Currency, + string? MaxNights, + StayDto? Stay, + IReadOnlyList? Tags, + IReadOnlyList? Guests); + +internal sealed record StayDto(string? CheckIn, string? CheckOut); + +internal sealed record GuestDto(string? FirstName, string? Email); + +#endregion + +#region Bound command & value objects + +internal sealed record BookingCommand( + EmailAddress GuestEmail, + string Reference, + Currency Currency, + int? MaxNights, + Stay? Stay, + IReadOnlyList Tags, + IReadOnlyList Guests); + +internal sealed record Stay(BookingDate CheckIn, BookingDate CheckOut); + +internal sealed record Guest(string FirstName, EmailAddress? Email); + +internal sealed class EmailAddress { + + private EmailAddress(string value) { + Value = value; + } + + public string Value { get; } + + public static Outcome Parse(string raw) { + return raw.Contains('@') + ? Outcome.Success(new EmailAddress(raw)) + : Outcome.Failure(BookingDomainError.EmailInvalid(raw)); + } + +} + +internal sealed class BookingDate { + + private BookingDate(DateOnly value) { + Value = value; + } + + public DateOnly Value { get; } + + public static Outcome Parse(string raw) { + return DateOnly.TryParse(raw, out DateOnly parsed) + ? Outcome.Success(new BookingDate(parsed)) + : Outcome.Failure(BookingDomainError.DateInvalid(raw)); + } + +} + +internal sealed class Currency { + + private Currency(string code) { + Code = code; + } + + public string Code { get; } + + public static Outcome Parse(string raw) { + return raw.Length == 3 + ? Outcome.Success(new Currency(raw)) + : Outcome.Failure(BookingDomainError.CurrencyInvalid(raw)); + } + +} + +internal sealed class Tag { + + private Tag(string value) { + Value = value; + } + + public string Value { get; } + + public static Outcome Parse(string raw) { + return raw.Length > 0 && !raw.Contains(' ') + ? Outcome.Success(new Tag(raw)) + : Outcome.Failure(BookingDomainError.TagInvalid(raw)); + } + +} + +internal static class PositiveInt { + + public static Outcome Parse(string raw) { + return int.TryParse(raw, out int parsed) && parsed > 0 + ? Outcome.Success(parsed) + : Outcome.Failure(BookingDomainError.NotAPositiveNumber(raw)); + } + +} + +#endregion + +#region Test error factories + +/// The leaf domain errors the test value objects fail with. +internal static class BookingDomainError { + + internal static DomainError EmailInvalid(string raw) { + return DomainError.Create(Code.EmailInvalid, $"'{raw}' is not a valid email address.") + .WithPublicMessage("The email address is invalid."); + } + + internal static DomainError DateInvalid(string raw) { + return DomainError.Create(Code.DateInvalid, $"'{raw}' is not a valid date.") + .WithPublicMessage("The date is invalid."); + } + + internal static DomainError CurrencyInvalid(string raw) { + return DomainError.Create(Code.CurrencyInvalid, $"'{raw}' is not a valid ISO currency code.") + .WithPublicMessage("The currency is invalid."); + } + + internal static DomainError TagInvalid(string raw) { + return DomainError.Create(Code.TagInvalid, $"'{raw}' is not a valid tag.") + .WithPublicMessage("The tag is invalid."); + } + + internal static DomainError NotAPositiveNumber(string raw) { + return DomainError.Create(Code.NotAPositiveNumber, $"'{raw}' is not a strictly positive number.") + .WithPublicMessage("The number must be strictly positive."); + } + + private static class Code { + + public static readonly ErrorCode EmailInvalid = ErrorCode.Create("TEST_EMAIL_INVALID"); + public static readonly ErrorCode DateInvalid = ErrorCode.Create("TEST_DATE_INVALID"); + public static readonly ErrorCode CurrencyInvalid = ErrorCode.Create("TEST_CURRENCY_INVALID"); + public static readonly ErrorCode TagInvalid = ErrorCode.Create("TEST_TAG_INVALID"); + public static readonly ErrorCode NotAPositiveNumber = ErrorCode.Create("TEST_NOT_A_POSITIVE_NUMBER"); + + } + +} + +/// The envelope factories the test binders fail with. +internal static class BookingEnvelopeError { + + internal static PrimaryPortError CommandInvalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.CommandInvalid, "The booking command is invalid.", violations) + .WithPublicMessage("We could not accept your booking request."); + } + + internal static PrimaryPortError StayInvalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.StayInvalid, "The stay is invalid.", violations) + .WithPublicMessage("The stay dates are invalid."); + } + + internal static PrimaryPortError GuestInvalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.GuestInvalid, "The guest information is invalid.", violations) + .WithPublicMessage("A guest's information is invalid."); + } + + private static class Code { + + public static readonly ErrorCode CommandInvalid = ErrorCode.Create("TEST_BOOKING_COMMAND_INVALID"); + public static readonly ErrorCode StayInvalid = ErrorCode.Create("TEST_STAY_INVALID"); + public static readonly ErrorCode GuestInvalid = ErrorCode.Create("TEST_GUEST_INVALID"); + + } + +} + +#endregion + +#region Shared assertion helpers + +internal static class BindingAssertions { + + /// The full argument path recorded in a binding error's context ("RequestArgument" entry). + internal static string? ArgumentPathOf(Error error) { + error.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); + + return path as string; + } + +} + +#endregion diff --git a/FirstClassErrors.RequestBinder/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs new file mode 100644 index 0000000..c0a34b8 --- /dev/null +++ b/FirstClassErrors.RequestBinder/Bind.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The entry point of request binding: converts an incoming request DTO into a typed command or query of value +/// objects at the primary-adapter boundary, collecting every failure — instead of stopping at the first — +/// into a single coded tree. +/// +/// +/// +/// var bind = Bind.PropertiesOf(request).FailWith(InvalidBookingCommandError.Invalid); +/// +/// var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +/// var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); +/// +/// Outcome<PlaceBookingCommand> command = +/// bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); +/// +/// +public static class Bind { + + #region Statics members declarations + + /// + /// Starts binding the properties of a request DTO. Declare the failure envelope next, with + /// . + /// + /// The type of the request DTO. + /// The request DTO to bind. + /// The stage on which the failure envelope is declared. + /// Thrown when is null. + public static RequestBinderEnvelopeStage PropertiesOf(TRequest request) { + if (request is null) { throw new ArgumentNullException(nameof(request)); } + + return new RequestBinderEnvelopeStage(request); + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/BindingAssembler.cs b/FirstClassErrors.RequestBinder/BindingAssembler.cs new file mode 100644 index 0000000..a8cead1 --- /dev/null +++ b/FirstClassErrors.RequestBinder/BindingAssembler.cs @@ -0,0 +1,16 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Assembles the bound command inside , reading each bound value +/// from the supplied — the only channel through which a bound value is reachable — and +/// returning the command directly (a total new that cannot fail). For a command produced by a validating +/// factory that returns an , use instead. +/// +/// +/// A dedicated delegate — rather than Func<BindingScope, TCommand> — is required because +/// is a ref struct and cannot be used as a generic type argument. +/// +/// The type of the assembled command or query. +/// The scope through which bound values are read. +/// The assembled command. +public delegate TCommand BindingAssembler(BindingScope scope); diff --git a/FirstClassErrors.RequestBinder/BindingScope.cs b/FirstClassErrors.RequestBinder/BindingScope.cs new file mode 100644 index 0000000..18a3ad6 --- /dev/null +++ b/FirstClassErrors.RequestBinder/BindingScope.cs @@ -0,0 +1,87 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The reader handed to a build terminal's assembler ( / +/// ): the only channel through which a bound value can +/// be obtained from a field token ( and its optional siblings). +/// +/// +/// +/// Safety by construction. is a readonly ref struct: it cannot be +/// captured, boxed, stored in a field, or returned, so it lives only for the duration of the assembler it is +/// passed to. And a build terminal ( / +/// ) creates one only on its success branch — +/// after it has verified that not a single binding failure was recorded. A field token exposes no public value +/// member, so the one way to read a bound value is Get through this scope, and this scope only ever +/// exists where every binding is known to have succeeded. Reading a value before a build terminal runs, or +/// outside its assembler, is therefore not merely discouraged: it does not compile. +/// +/// +/// A token produced by a different binder is rejected with : +/// mixing tokens across binders is a programming error, so it throws loudly rather than reading a value this +/// scope never validated. +/// +/// +public readonly ref struct BindingScope { + + #region Fields declarations + + private readonly object _owner; + + #endregion + + #region Constructors declarations + + internal BindingScope(object owner) { + _owner = owner; + } + + #endregion + + /// Reads a required bound value. + /// The type of the bound value. + /// The token returned when the property was bound. + /// The bound value. + /// Thrown when is null. + /// Thrown when was produced by a different binder. + public TProperty Get(RequiredField field) { + if (field is null) { throw new ArgumentNullException(nameof(field)); } + EnsureOwned(field.Owner); + + return field.Value; + } + + /// Reads an optional reference-type bound value, or null when the argument was absent. + /// The reference type of the bound value. + /// The token returned when the property was bound. + /// The bound value, or null when the argument was absent. + /// Thrown when is null. + /// Thrown when was produced by a different binder. + public TProperty? Get(OptionalReferenceField field) where TProperty : class { + if (field is null) { throw new ArgumentNullException(nameof(field)); } + EnsureOwned(field.Owner); + + return field.Value; + } + + /// Reads an optional value-type bound value as a real null when absent. + /// The value type of the bound value. + /// The token returned when the property was bound. + /// The bound value, or null when the argument was absent. + /// Thrown when is null. + /// Thrown when was produced by a different binder. + public TProperty? Get(OptionalValueField field) where TProperty : struct { + if (field is null) { throw new ArgumentNullException(nameof(field)); } + EnsureOwned(field.Owner); + + return field.Value; + } + + private void EnsureOwned(object owner) { + if (!ReferenceEquals(owner, _owner)) { + throw new InvalidOperationException( + "This bound field was produced by a different binder. A field can only be read inside the New/Create terminal of the binder that bound it."); + } + } + +} diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs new file mode 100644 index 0000000..67294c8 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -0,0 +1,96 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds a complex request property through a nested binder: the binding function receives a child +/// — prefixed with this property's path, inheriting the parent options, +/// failing with the envelope declared on the previous stage — and typically lives in a dedicated method, passed +/// as a method group. +/// +/// The type of the request DTO. +/// The type of the nested DTO. +public sealed class ComplexPropertyConverter { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly TArgument? _value; + private readonly bool _isMissing; + private readonly Func _envelope; + + #endregion + + #region Constructors declarations + + internal ComplexPropertyConverter(RequestBinder binder, + string argumentPath, + TArgument? value, + bool isMissing, + Func envelope) { + _binder = binder; + _argumentPath = argumentPath; + _value = value; + _isMissing = isMissing; + _envelope = envelope; + } + + #endregion + + /// + /// Binds a required complex argument: missing records REQUEST_ARGUMENT_REQUIRED; a failed nested + /// binding records its envelope, whose inner errors carry the full, prefixed argument paths. + /// + /// The type the nested binding produces. + /// The nested binding function (typically a method group such as BindStay). + /// The bound field token. + /// Thrown when is null. + public RequiredField AsRequired(Func, Outcome> bindNested) where TProperty : notnull { + if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } + + if (_isMissing) { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + + return new RequiredField(_binder, default!); + } + + RequestBinder nested = NestedBinder(); + Outcome outcome = bindNested(nested); + if (outcome.IsFailure) { + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath)); + + return new RequiredField(_binder, default!); + } + + return new RequiredField(_binder, outcome.GetResultOrThrow()); + } + + /// + /// Binds an optional complex argument: absent yields a null + /// value and records nothing; a present-but-invalid nested + /// binding records its envelope. + /// + /// The reference type the nested binding produces. + /// The nested binding function (typically a method group such as BindAddress). + /// The bound field token. + /// Thrown when is null. + public OptionalReferenceField AsOptional(Func, Outcome> bindNested) where TProperty : class { + if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } + + if (_isMissing) { return new OptionalReferenceField(_binder, value: null); } + + RequestBinder nested = NestedBinder(); + Outcome outcome = bindNested(nested); + if (outcome.IsFailure) { + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath)); + + return new OptionalReferenceField(_binder, value: null); + } + + return new OptionalReferenceField(_binder, outcome.GetResultOrThrow()); + } + + private RequestBinder NestedBinder() { + return new RequestBinder(_value!, _envelope, _binder.Options, _argumentPath); + } + +} diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs b/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs new file mode 100644 index 0000000..04ccb80 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs @@ -0,0 +1,44 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The mandatory envelope stage of a complex property: declares the envelope error the nested binding's failures +/// are grouped into, before AsRequired / AsOptional become available. +/// +/// The type of the request DTO. +/// The type of the nested DTO. +public sealed class ComplexPropertyEnvelopeStage { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly TArgument? _value; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal ComplexPropertyEnvelopeStage(RequestBinder binder, string argumentPath, TArgument? value, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _value = value; + _isMissing = isMissing; + } + + #endregion + + /// + /// Declares the nested envelope: the factory producing the under which the + /// nested binding's failures are grouped. + /// + /// The nested envelope factory, receiving the collected failures. + /// The converter stage offering AsRequired / AsOptional. + /// Thrown when is null. + public ComplexPropertyConverter FailWith(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new ComplexPropertyConverter(_binder, _argumentPath, _value, _isMissing, envelope); + } + +} diff --git a/FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs b/FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs new file mode 100644 index 0000000..6df9218 --- /dev/null +++ b/FirstClassErrors.RequestBinder/DefaultArgumentNameProvider.cs @@ -0,0 +1,26 @@ +#region Usings declarations + +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// The default : the argument name is the C# property name, unchanged. +/// +/// +/// It deliberately reads no serialization attribute: which serializer names the wire keys is the host's +/// knowledge, not this library's. Provide your own through +/// when the error paths must match serialized names. +/// +internal sealed class DefaultArgumentNameProvider : IArgumentNameProvider { + + /// + public string GetArgumentNameFrom(PropertyInfo property) { + if (property is null) { throw new ArgumentNullException(nameof(property)); } + + return property.Name; + } + +} diff --git a/FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj b/FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj new file mode 100644 index 0000000..d89691e --- /dev/null +++ b/FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj @@ -0,0 +1,70 @@ + + + + + netstandard2.0 + enable + enable + latest + + + true + + + 0.1.0-dev + + + FirstClassErrors.RequestBinder + Sylvain AURAT + Reefact + + + + Fluent, framework-agnostic request binding for FirstClassErrors: converts an incoming request DTO into a typed command or query of value objects at the primary-adapter boundary, collecting every failure into a coded, documented PrimaryPortError tree — with no exception thrown on the invalid-input path. + + + + error-handling;errors;request-binding;validation;value-objects;command;cqrs;hexagonal;primary-adapter;outcome;firstclasserrors + Apache-2.0 + false + + + https://github.com/Reefact/first-class-errors + https://github.com/Reefact/first-class-errors.git + git + + © Reefact 2026 + + + icon.png + readme.md + Initial preview release of the FirstClassErrors request binder. + + + + + + + <_Parameter1>FirstClassErrors.RequestBinder.UnitTests + + + + + + + + + + + + + + + + + + diff --git a/FirstClassErrors.RequestBinder/IArgumentNameProvider.cs b/FirstClassErrors.RequestBinder/IArgumentNameProvider.cs new file mode 100644 index 0000000..21acb23 --- /dev/null +++ b/FirstClassErrors.RequestBinder/IArgumentNameProvider.cs @@ -0,0 +1,24 @@ +#region Usings declarations + +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// Provides the argument name used in error paths for a bound DTO property. +/// +/// +/// The default () uses the C# property name. Plug a +/// serializer-aware implementation (reading, for example, JsonPropertyName attributes or a naming +/// policy) so the paths reported in errors match the keys the client actually sent. +/// +public interface IArgumentNameProvider { + + /// Returns the argument name to use in error paths for the given DTO property. + /// The DTO property being bound. + /// The client-facing argument name. + string GetArgumentNameFrom(PropertyInfo property); + +} diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs new file mode 100644 index 0000000..94ce653 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -0,0 +1,108 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds a list request property whose elements are each bound by a nested binder. Every failing element +/// records its own envelope, whose inner errors carry the full, indexed argument paths +/// (Guests[1].FirstName) — so one bad element never hides the others. +/// +/// The type of the request DTO. +/// The element type of the DTO list. +public sealed class ListOfComplexPropertiesConverter { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + private readonly Func _envelope; + + #endregion + + #region Constructors declarations + + internal ListOfComplexPropertiesConverter(RequestBinder binder, + string argumentPath, + IEnumerable? values, + bool isMissing, + Func envelope) { + _binder = binder; + _argumentPath = argumentPath; + _values = values; + _isMissing = isMissing; + _envelope = envelope; + } + + #endregion + + /// + /// Binds a required list: a missing list records REQUEST_ARGUMENT_REQUIRED; each failing element + /// records its envelope under its indexed path. + /// + /// The type each element's nested binding produces. + /// The nested binding function applied to each element (typically a method group). + /// The bound field token. + /// Thrown when is null. + public RequiredField> AsRequired(Func, Outcome> bindElement) where TProperty : notnull { + if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); } + + if (_isMissing) { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + + return new RequiredField>(_binder, default!); + } + + return BindElements(bindElement); + } + + /// + /// Binds an optional list: absent yields an empty list (never null) and records nothing; each + /// failing element of a present list still records its envelope under its indexed path. + /// + /// The type each element's nested binding produces. + /// The nested binding function applied to each element (typically a method group). + /// The bound field token. + /// Thrown when is null. + public RequiredField> AsOptional(Func, Outcome> bindElement) where TProperty : notnull { + if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); } + + if (_isMissing) { + IReadOnlyList empty = new List(); + + return new RequiredField>(_binder, empty); + } + + return BindElements(bindElement); + } + + private RequiredField> BindElements(Func, Outcome> bindElement) where TProperty : notnull { + List bound = new(); + int index = 0; + + foreach (TArgument? element in _values!) { + string elementPath = $"{_argumentPath}[{index}]"; + index++; + + if (element is null) { + _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); + + continue; + } + + RequestBinder nested = new(element!, _envelope, _binder.Options, elementPath); + Outcome outcome = bindElement(nested); + if (outcome.IsFailure) { + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath)); + + continue; + } + + bound.Add(outcome.GetResultOrThrow()); + } + + // The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded — + // i.e. only when every element bound — so `bound` is the complete list exactly when it is observed. + return new RequiredField>(_binder, bound); + } + +} diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs new file mode 100644 index 0000000..72aeafd --- /dev/null +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs @@ -0,0 +1,44 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The mandatory envelope stage of a list of complex properties: declares the envelope error each failing +/// element's nested binding is grouped into, before AsRequired / AsOptional become available. +/// +/// The type of the request DTO. +/// The element type of the DTO list. +public sealed class ListOfComplexPropertiesEnvelopeStage { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal ListOfComplexPropertiesEnvelopeStage(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _values = values; + _isMissing = isMissing; + } + + #endregion + + /// + /// Declares the per-element envelope: the factory producing the under which + /// each failing element's nested-binding failures are grouped. + /// + /// The per-element envelope factory, receiving the collected failures of one element. + /// The converter stage offering AsRequired / AsOptional. + /// Thrown when is null. + public ListOfComplexPropertiesConverter FailWith(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new ListOfComplexPropertiesConverter(_binder, _argumentPath, _values, _isMissing, envelope); + } + +} diff --git a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs new file mode 100644 index 0000000..b0c587b --- /dev/null +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -0,0 +1,101 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds a list request property whose elements are converted by a plain value-object converter. Each failing +/// element is recorded individually, under its indexed path (Tags[2]), so one bad element never hides the +/// others. +/// +/// The type of the request DTO. +/// The element type of the DTO list. +public sealed class ListOfSimplePropertiesConverter { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal ListOfSimplePropertiesConverter(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _values = values; + _isMissing = isMissing; + } + + #endregion + + /// + /// Binds a required list: a missing list records REQUEST_ARGUMENT_REQUIRED; each failing element + /// records REQUEST_ARGUMENT_INVALID under its indexed path. + /// + /// The type of the element value object. + /// The value-object converter applied to each element. + /// The bound field token. + /// Thrown when is null. + public RequiredField> AsRequired(Func> convertElement) where TProperty : notnull { + if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } + + if (_isMissing) { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + + return new RequiredField>(_binder, default!); + } + + return ConvertElements(convertElement); + } + + /// + /// Binds an optional list: absent yields an empty list (never null) and records nothing; each + /// failing element of a present list still records REQUEST_ARGUMENT_INVALID under its indexed path. + /// + /// The type of the element value object. + /// The value-object converter applied to each element. + /// The bound field token. + /// Thrown when is null. + public RequiredField> AsOptional(Func> convertElement) where TProperty : notnull { + if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); } + + if (_isMissing) { + IReadOnlyList empty = new List(); + + return new RequiredField>(_binder, empty); + } + + return ConvertElements(convertElement); + } + + private RequiredField> ConvertElements(Func> convertElement) where TProperty : notnull { + List converted = new(); + int index = 0; + + foreach (TArgument? element in _values!) { + string elementPath = $"{_argumentPath}[{index}]"; + index++; + + if (element is null) { + _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); + + continue; + } + + Outcome outcome = convertElement(element!); + if (outcome.IsFailure) { + _binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!)); + + continue; + } + + converted.Add(outcome.GetResultOrThrow()); + } + + // The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded — + // i.e. only when every element converted — so `converted` is the complete list exactly when it is observed. + return new RequiredField>(_binder, converted); + } + +} diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs new file mode 100644 index 0000000..8dd138c --- /dev/null +++ b/FirstClassErrors.RequestBinder/NestedFailure.cs @@ -0,0 +1,33 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Groups the failure of a nested binding (a complex property, or one element of a complex list) under its +/// argument path — the single decision shared by and +/// . +/// +internal static class NestedFailure { + + #region Statics members declarations + + /// + /// Decides how a nested binding's failure joins the parent envelope. The nested binder's own failure + /// envelope — , self-describing because its inner errors already carry the + /// prefixed paths — is recorded as-is. Anything else, including a bare leaf a + /// converter returned directly, is wrapped in REQUEST_ARGUMENT_INVALID so the argument path survives + /// — exactly as a would be. The test is by reference, not by type: a leaf that + /// merely happens to be a is not this binder's envelope and must not skip the + /// path. + /// + /// The failure the nested binding returned. + /// The envelope the nested binder's build terminal produced, or null when it built none. + /// The argument path to attach when wrapping. + /// The error to record on the parent binder. + internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath) { + return ReferenceEquals(error, nestedEnvelope) + ? (PrimaryPortError)error + : RequestBindingError.ArgumentInvalid(argumentPath, error); + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/OptionalReferenceField.cs b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs new file mode 100644 index 0000000..645cdc6 --- /dev/null +++ b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs @@ -0,0 +1,38 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A token standing for a bound optional reference-type property. Read through +/// , its value is the bound value, or +/// null when the argument was absent from the request. +/// +/// +/// Returned by the AsOptionalReference family. Inside a build terminal +/// ( / ) +/// a null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on +/// the binder, so the assembler never runs and that state is never observed. +/// +/// The reference type of the bound property. +public sealed class OptionalReferenceField where TProperty : class { + + #region Fields declarations + + private readonly TProperty? _value; + + #endregion + + #region Constructors declarations + + internal OptionalReferenceField(object owner, TProperty? value) { + Owner = owner; + _value = value; + } + + #endregion + + /// The binder that produced this token; used to reject a token read through a different binder's scope. + internal object Owner { get; } + + /// The bound value, or null when absent. Internal: consumers reach it only through . + internal TProperty? Value => _value; + +} diff --git a/FirstClassErrors.RequestBinder/OptionalValueField.cs b/FirstClassErrors.RequestBinder/OptionalValueField.cs new file mode 100644 index 0000000..1467790 --- /dev/null +++ b/FirstClassErrors.RequestBinder/OptionalValueField.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A token standing for a bound optional value-type property. Read through +/// , its value is the bound value as a +/// real null when the argument was absent, never default(T) (an absent +/// count is null, not 0). +/// +/// +/// Returned by the AsOptionalValue family. Inside a build terminal +/// ( / ) +/// a null read means exactly "the argument was absent": a present-but-invalid argument recorded a failure on +/// the binder, so the assembler never runs and that state is never observed. +/// +/// The value type of the bound property. +public sealed class OptionalValueField where TProperty : struct { + + #region Fields declarations + + private readonly TProperty? _value; + + #endregion + + #region Constructors declarations + + internal OptionalValueField(object owner, TProperty? value) { + Owner = owner; + _value = value; + } + + #endregion + + /// The binder that produced this token; used to reject a token read through a different binder's scope. + internal object Owner { get; } + + /// The bound value, or null when absent. Internal: consumers reach it only through . + internal TProperty? Value => _value; + +} diff --git a/FirstClassErrors.RequestBinder/PropertySelectors.cs b/FirstClassErrors.RequestBinder/PropertySelectors.cs new file mode 100644 index 0000000..917ec61 --- /dev/null +++ b/FirstClassErrors.RequestBinder/PropertySelectors.cs @@ -0,0 +1,42 @@ +#region Usings declarations + +using System.Linq.Expressions; +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// Resolves the behind a property-selector lambda (r => r.GuestEmail). +/// +/// +/// Only a direct property access on the lambda parameter is a valid selector: the property name is what the +/// argument path is built from, so an arbitrary expression has no meaningful name. An invalid selector is a +/// programming error and is reported by throwing (the binder's bug channel). +/// +internal static class PropertySelectors { + + #region Statics members declarations + + internal static PropertyInfo GetProperty(Expression> selector) { + if (selector is null) { throw new ArgumentNullException(nameof(selector)); } + + // The compiler may wrap the member access in a Convert node (e.g. IReadOnlyList -> IEnumerable). + Expression body = selector.Body; + while (body is UnaryExpression { NodeType: ExpressionType.Convert } convert) { + body = convert.Operand; + } + + if (body is not MemberExpression { Member: PropertyInfo property } member || member.Expression is not ParameterExpression) { + throw new ArgumentException( + $"The selector '{selector}' must be a direct property access on the request parameter (e.g. r => r.GuestEmail).", + nameof(selector)); + } + + return property; + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md new file mode 100644 index 0000000..ed368ba --- /dev/null +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -0,0 +1,20 @@ +# FirstClassErrors.RequestBinder + +Fluent, framework-agnostic request binding for [FirstClassErrors](https://github.com/Reefact/first-class-errors): convert an incoming request DTO (body + route) into a typed command or query of value objects at the primary-adapter boundary. + +* **Collect-all**: every failing field is reported at once — no fix-one-resubmit loop. +* **First-class errors**: failures are coded, documented `PrimaryPortError` trees (code + argument path + public/diagnostic messages), not flat strings. +* **No throw on the invalid-input path**: converters return `Outcome`; the binder returns `Outcome`. Exceptions are reserved for genuine bugs. +* **Framework-agnostic**: works the same for HTTP controllers, message consumers, CLIs and gRPC handlers. + +```csharp +var bind = Bind.PropertiesOf(request).FailWith(InvalidBookingCommandError.Invalid); + +var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); + +Outcome command = + bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); +``` + +See the [request-binder guide](https://github.com/Reefact/first-class-errors/blob/main/doc/RequestBinder.en.md) for the full walkthrough, and the [repository documentation](https://github.com/Reefact/first-class-errors) for the rest of FirstClassErrors. diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs new file mode 100644 index 0000000..b92f386 --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -0,0 +1,231 @@ +#region Usings declarations + +using System.Linq.Expressions; +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// Binds the properties of a request DTO into value objects, collecting every failure — instead of +/// stopping at the first — and grouping them under the envelope declared with +/// . +/// +/// +/// +/// No throw on the invalid-input path. Converters return ; every failure is +/// recorded as a coded error and surfaces once, as the failure of the build terminal +/// ( / ). A request +/// whose every field is invalid raises zero exceptions. Exceptions are reserved for programming errors +/// (a converter that throws, an invalid selector, a mis-declared fallback): the binder catches nothing, so a +/// genuine bug propagates to the host's exception boundary instead of being disguised as a client error. +/// +/// +/// Instances are created through and are not thread-safe: a binder +/// binds one request, in one scope. +/// +/// +/// The type of the request DTO. +public sealed class RequestBinder { + + #region Fields declarations + + private readonly TRequest _request; + private readonly Func _envelope; + private readonly string? _argumentPrefix; + private readonly List _errors = new(); + + #endregion + + #region Constructors declarations + + internal RequestBinder(TRequest request, Func envelope, RequestBinderOptions options, string? argumentPrefix) { + _request = request; + _envelope = envelope; + Options = options; + _argumentPrefix = argumentPrefix; + } + + #endregion + + /// The options this binder (and every binder nested under it) binds with. + internal RequestBinderOptions Options { get; private set; } + + /// + /// The envelope instance the most recent failing build terminal + /// ( / ) produced, or null when + /// no build has failed. A parent binder compares a nested failure against this by reference to tell this + /// binder's own self-describing envelope (recorded as-is) from a leaf error a nested binding returned directly + /// (wrapped under the argument path). + /// + internal PrimaryPortError? BuiltEnvelope { get; private set; } + + /// + /// Replaces the binder options (for example to plug a serializer-aware + /// ). Call it before binding any property; nested binders inherit the + /// options in effect when they are created. + /// + /// The options to bind with. + /// This binder, for chaining. + /// Thrown when is null. + public RequestBinder WithOptions(RequestBinderOptions options) { + if (options is null) { throw new ArgumentNullException(nameof(options)); } + + Options = options; + + return this; + } + + /// + /// Selects a scalar property, converted by a plain value-object converter + /// (Func<TArgument, Outcome<T>>). + /// + /// The type of the DTO property. + /// A direct property access on the request parameter (e.g. r => r.GuestEmail). + /// The converter stage offering AsRequired / AsOptional and their variants. + /// + /// Thrown when points at a non-nullable value-type property: a missing value + /// would be indistinguishable from its default (0, false, ...), so such a property must be declared + /// nullable (e.g. int?) for the binder to detect an absent argument. + /// + public SimplePropertyConverter SimpleProperty(Expression> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new SimplePropertyConverter(this, path, (TArgument?)value, value is null); + } + + /// + /// Selects a complex property, bound by a nested binder. Declare the nested envelope next, with + /// . + /// + /// The type of the nested DTO. + /// A direct property access on the request parameter (e.g. r => r.Stay). + /// The stage on which the nested envelope is declared. + public ComplexPropertyEnvelopeStage ComplexProperty(Expression> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ComplexPropertyEnvelopeStage(this, path, (TArgument?)value, value is null); + } + + /// + /// Selects a list property whose elements are converted by a plain value-object converter. + /// + /// The element type of the DTO list. + /// A direct property access on the request parameter (e.g. r => r.Tags). + /// The converter stage offering AsRequired / AsOptional. + public ListOfSimplePropertiesConverter ListOfSimpleProperties(Expression?>> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfSimplePropertiesConverter(this, path, (IEnumerable?)value, value is null); + } + + /// + /// Selects a list property whose elements are bound by a nested binder (one per element). Declare the + /// per-element envelope next, with + /// . + /// + /// The element type of the DTO list. + /// A direct property access on the request parameter (e.g. r => r.Guests). + /// The stage on which the per-element envelope is declared. + public ListOfComplexPropertiesEnvelopeStage ListOfComplexProperties(Expression?>> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfComplexPropertiesEnvelopeStage(this, path, (IEnumerable?)value, value is null); + } + + /// + /// Terminal (total assembler): builds the command with a new — when, and only when, no binding failure + /// was recorded; otherwise returns the failure of the envelope grouping every recorded error. The assembler + /// receives a and reads each bound value through it; because that scope is created + /// only on this success branch, every read is valid by construction, and the assembler itself cannot fail. + /// + /// + /// Mirror the shape of the assembler at the call site: takes a new — a total + /// constructor, because all validation already happened field by field. When the command is produced by a + /// validating factory returning — one that may still reject an all-valid combination + /// through a cross-field rule (CheckOut > CheckIn) — call instead. + /// + /// The type of the bound command or query. + /// The assembler, reading the bound values from the supplied . + /// The bound command, or the envelope failure. + /// Thrown when is null. + public Outcome New(BindingAssembler assemble) where TCommand : notnull { + if (assemble is null) { throw new ArgumentNullException(nameof(assemble)); } + + if (_errors.Count == 0) { return Outcome.Success(assemble(new BindingScope(this))); } + + return Outcome.Failure(BuildFailureEnvelope()); + } + + /// + /// Terminal (validating assembler): builds the command through a factory returning — + /// when, and only when, no binding failure was recorded — and flattens the result, so a cross-field rule + /// the factory enforces (CheckOut > CheckIn) surfaces directly instead of nesting a second + /// . When a binding failure was recorded, the factory is never called and the envelope + /// grouping every recorded error is returned. + /// + /// + /// The factory runs only on the zero-error branch — every field is already bound and readable through the + /// supplied — so a cross-field rule can assume all its inputs are present and valid. + /// Its failure is returned as-is: the factory owns that error, and only field-binding failures are grouped + /// under the envelope declared with . For a total + /// constructor that cannot fail, call . + /// + /// The type of the bound command or query. + /// + /// The validating assembler, reading the bound values from the supplied and returning + /// an . + /// + /// The bound command, the factory's own failure, or the envelope failure. + /// Thrown when is null. + public Outcome Create(ValidatingAssembler assemble) where TCommand : notnull { + if (assemble is null) { throw new ArgumentNullException(nameof(assemble)); } + + if (_errors.Count == 0) { return assemble(new BindingScope(this)); } + + return Outcome.Failure(BuildFailureEnvelope()); + } + + private PrimaryPortError BuildFailureEnvelope() { + PrimaryPortInnerErrors innerErrors = new(); + foreach (PrimaryPortError error in _errors) { + innerErrors.Add(error); + } + + // Remember the exact envelope instance produced here, so a parent binder can tell this self-describing + // envelope (recorded as-is) from any other failure a nested binding returned (wrapped under the path). + BuiltEnvelope = _envelope(innerErrors); + + return BuiltEnvelope; + } + + /// Records a binding failure; it will surface in the envelope built by / . + internal void Record(PrimaryPortError error) { + _errors.Add(error); + } + + /// Prepends this binder's argument prefix to a path segment ("CheckIn" -> "Stay.CheckIn"). + internal string PathOf(string argumentName) { + return _argumentPrefix is null ? argumentName : $"{_argumentPrefix}.{argumentName}"; + } + + private (string Path, object? Value) ResolveArgument(Expression> selector) { + PropertyInfo property = PropertySelectors.GetProperty(selector); + + // A non-nullable value-type property can never be null, so a missing argument (deserialized to default(T) — + // 0, false, ...) is indistinguishable from a legitimately-sent default: absence would be silently lost. The + // information does not exist at runtime, so reject the mis-declaration loudly (the binder's programming-error + // channel) — the DTO property must be declared nullable so that an absent argument arrives as null. + if (property.PropertyType.IsValueType && Nullable.GetUnderlyingType(property.PropertyType) is null) { + throw new ArgumentException( + $"The request property '{property.Name}' is a non-nullable value type ({property.PropertyType.Name}); a missing value would be indistinguishable from its default. Declare it as {property.PropertyType.Name}? so the binder can detect an absent argument.", + nameof(selector)); + } + + string path = PathOf(Options.ArgumentNameProvider.GetArgumentNameFrom(property)); + + return (path, property.GetValue(_request)); + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs new file mode 100644 index 0000000..5ef071e --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The mandatory first stage of a request binder: declares the envelope error every binding failure is grouped +/// into. Mirrors the staged-builder pattern the library uses for errors themselves (an error can never be left +/// without its public message; a binder can never be left without its envelope). +/// +/// The type of the request DTO. +public sealed class RequestBinderEnvelopeStage { + + #region Fields declarations + + private readonly TRequest _request; + + #endregion + + #region Constructors declarations + + internal RequestBinderEnvelopeStage(TRequest request) { + _request = request; + } + + #endregion + + /// + /// Declares the envelope: the factory producing the single under which every + /// failure recorded during the binding is grouped — typically an aggregate factory of the application's error + /// catalog, passed as a method group. + /// + /// The envelope factory, receiving the collected failures. + /// The request binder. + /// Thrown when is null. + public RequestBinder FailWith(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new RequestBinder(_request, envelope, RequestBinderOptions.Default, argumentPrefix: null); + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs new file mode 100644 index 0000000..ec8074e --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -0,0 +1,33 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The binding options of a . Options are per-binder — passed through +/// and inherited by nested binders — never global mutable +/// state. +/// +public sealed class RequestBinderOptions { + + #region Statics members declarations + + /// The default options: argument names are the C# property names. + public static RequestBinderOptions Default { get; } = new(new DefaultArgumentNameProvider()); + + #endregion + + #region Constructors declarations + + /// Instantiates options with the given argument-name provider. + /// The provider resolving the argument name of a bound DTO property. + /// Thrown when is null. + public RequestBinderOptions(IArgumentNameProvider argumentNameProvider) { + if (argumentNameProvider is null) { throw new ArgumentNullException(nameof(argumentNameProvider)); } + + ArgumentNameProvider = argumentNameProvider; + } + + #endregion + + /// The provider resolving the argument name of a bound DTO property (see ). + public IArgumentNameProvider ArgumentNameProvider { get; } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBindingError.cs b/FirstClassErrors.RequestBinder/RequestBindingError.cs new file mode 100644 index 0000000..8b3d4eb --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBindingError.cs @@ -0,0 +1,135 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Provides the structural binding errors raised by the request binder itself: an argument that is missing, and +/// an argument that is present but does not convert. Both carry the full argument path (for example +/// Guests[1].FirstName) in their context, so the failing field is identifiable without parsing messages. +/// +/// +/// These are the only errors the binder manufactures on its own. Every other error in a binding failure tree +/// comes from the application: the conversion errors returned by the value-object converters, and the envelope +/// errors declared with FailWith. +/// +[ProvidesErrorsFor("RequestBinding", + Description = "Structural validation of an incoming request at the primary-adapter boundary: required arguments and value conversions.")] +public static class RequestBindingError { + + #region Statics members declarations + + /// + /// The argument at is required but was absent from the request. + /// + /// Non-transient by nature: resubmitting the same request cannot succeed. + [DocumentedBy(nameof(ArgumentRequiredDocumentation))] + internal static PrimaryPortError ArgumentRequired(string argumentPath) { + return PrimaryPortError.Create( + Code.ArgumentRequired, + $"Argument '{argumentPath}' is required but was missing from the request.", + Transience.NonTransient, + ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) + .WithPublicMessage( + "A required argument is missing.", + $"The argument '{argumentPath}' is required."); + } + + /// + /// The argument at was present but failed to convert; the conversion error is + /// attached as the inner error. + /// + /// The full path of the failing argument. + /// + /// The error the converter failed with. Converters must fail with a or a + /// — the two families a accepts. Any + /// other family is a converter bug, reported by throwing (the binder's bug channel), never recorded. + /// + /// Thrown when belongs to another error family. + [DocumentedBy(nameof(ArgumentInvalidDocumentation))] + internal static PrimaryPortError ArgumentInvalid(string argumentPath, Error cause) { + PrimaryPortInnerErrors innerErrors = new(); + switch (cause) { + case DomainError domainError: + innerErrors.Add(domainError); + + break; + case PrimaryPortError primaryPortError: + innerErrors.Add(primaryPortError); + + break; + default: + throw new InvalidOperationException( + $"The converter of argument '{argumentPath}' failed with a {cause.GetType().Name}; a converter must fail with a DomainError or a PrimaryPortError."); + } + + return PrimaryPortError.Create( + Code.ArgumentInvalid, + $"Argument '{argumentPath}' is invalid.", + innerErrors, + ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) + .WithPublicMessage( + "An argument is invalid.", + $"The argument '{argumentPath}' is invalid."); + } + + private static ErrorDocumentation ArgumentRequiredDocumentation() { + return DescribeError.WithTitle("Required request argument missing") + .WithDescription("An incoming request omits an argument that the bound command requires. The full path of the missing argument is carried in the error context.") + .WithRule("Every argument bound with AsRequired must be present in the request.") + .WithDiagnostic("The client did not send the argument (wrong payload shape, renamed field, or version mismatch between client and API).", + ErrorOrigin.External, + "Compare the request payload with the API contract; check the client and API versions.") + .AndDiagnostic("The argument name reported in the path does not match the wire format (the binder uses the C# property name unless an IArgumentNameProvider is configured).", + ErrorOrigin.Internal, + "Configure an IArgumentNameProvider aligned with the serializer naming policy.") + .WithExamples(() => ArgumentRequired("Guests[1].FirstName")); + } + + private static ErrorDocumentation ArgumentInvalidDocumentation() { + return DescribeError.WithTitle("Request argument invalid") + .WithDescription("An incoming request carries an argument that fails to convert into its value object. The full path of the failing argument is carried in the error context, and the precise conversion error is attached as the inner error.") + .WithRule("Every bound argument must convert successfully into its target value object.") + .WithDiagnostic("The client sent a malformed value (wrong format, out of range, unknown identifier).", + ErrorOrigin.External, + "Read the inner error: it is the value object's own coded error and states the violated rule.") + .AndDiagnostic("The converter rejects values the contract intends to accept (over-strict parsing rule).", + ErrorOrigin.Internal, + "Review the value object's parsing rule against the API contract.") + .WithExamples(() => ArgumentInvalid("GuestEmail", SampleCause())); + } + + /// A representative converter failure used only by the documentation example above. + private static DomainError SampleCause() { + return DomainError.Create( + ErrorCode.Create("EMAIL_ADDRESS_INVALID"), + "The value 'not-an-email' is not a valid email address.") + .WithPublicMessage("The email address is invalid."); + } + + #endregion + + #region Nested types declarations + + private static class Code { + + #region Statics members declarations + + public static readonly ErrorCode ArgumentRequired = ErrorCode.Create("REQUEST_ARGUMENT_REQUIRED"); + public static readonly ErrorCode ArgumentInvalid = ErrorCode.Create("REQUEST_ARGUMENT_INVALID"); + + #endregion + + } + + private static class ErrCtxKey { + + #region Statics members declarations + + public static readonly ErrorContextKey RequestArgument = + ErrorContextKey.Create("RequestArgument", "Full path of the request argument that failed to bind (e.g. 'Guests[1].FirstName')."); + + #endregion + + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/RequiredField.cs b/FirstClassErrors.RequestBinder/RequiredField.cs new file mode 100644 index 0000000..ba7577a --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequiredField.cs @@ -0,0 +1,39 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A token standing for a bound required property (or an optional property with a fallback). It carries no public +/// value: the bound value is reachable only through +/// , inside a build terminal +/// ( / ) +/// — where every binding is known to have succeeded. +/// +/// +/// Returned by the AsRequired family. When the binding failed, the failure has already been recorded on the +/// binder (so it surfaces in the envelope built by the terminal) and this token stands in with no readable value — +/// harmless, because the terminal never runs the assembler when any failure was recorded. +/// +/// The type of the bound property. +public sealed class RequiredField { + + #region Fields declarations + + private readonly TProperty _value; + + #endregion + + #region Constructors declarations + + internal RequiredField(object owner, TProperty value) { + Owner = owner; + _value = value; + } + + #endregion + + /// The binder that produced this token; used to reject a token read through a different binder's scope. + internal object Owner { get; } + + /// The bound value. Internal: consumers reach it only through . + internal TProperty Value => _value; + +} diff --git a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs new file mode 100644 index 0000000..23cb94b --- /dev/null +++ b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs @@ -0,0 +1,164 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Converts a scalar request property into a value object, through a plain converter +/// (Func<TArgument, Outcome<TProperty>>). +/// +/// +/// A converter fails by returning Outcome.Failure with a or a +/// — never by throwing. The binder catches nothing: a converter that throws is a +/// bug, and the exception propagates to the host's exception boundary. +/// +/// The type of the request DTO. +/// The type of the DTO property. +public sealed class SimplePropertyConverter { + + #region Fields declarations + + private readonly RequestBinder _binder; + private readonly string _argumentPath; + private readonly TArgument? _value; + private readonly bool _isMissing; + + #endregion + + #region Constructors declarations + + internal SimplePropertyConverter(RequestBinder binder, string argumentPath, TArgument? value, bool isMissing) { + _binder = binder; + _argumentPath = argumentPath; + _value = value; + _isMissing = isMissing; + } + + #endregion + + /// + /// Binds a required argument: missing records REQUEST_ARGUMENT_REQUIRED, a failed conversion records + /// REQUEST_ARGUMENT_INVALID wrapping the converter's error. + /// + /// The type of the value object. + /// The value-object converter. + /// The bound field token. + /// Thrown when is null. + public RequiredField AsRequired(Func> convert) where TProperty : notnull { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (_isMissing) { return RequiredMissing(); } + + return RecordIfInvalid(convert(_value!)); + } + + /// + /// Binds a required argument without conversion: only the presence is checked, and the raw value is the bound + /// value. + /// + /// The bound field token. + public RequiredField AsRequired() { + if (_isMissing) { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + + return new RequiredField(_binder, default!); + } + + return new RequiredField(_binder, _value!); + } + + /// + /// Binds an optional argument with a fallback: when the argument is absent, + /// is converted instead, so the bound property always has a value. A present-but-invalid argument still + /// records REQUEST_ARGUMENT_INVALID — optional means "may be absent", never "may be malformed". + /// + /// The type of the value object. + /// The value-object converter. + /// The raw value converted when the argument is absent. + /// The bound field token. + /// Thrown when is null. + /// + /// Thrown when itself fails to convert: a fallback is developer-supplied + /// configuration, so an invalid one is a bug, not a client error. + /// + public RequiredField AsOptional(Func> convert, TArgument rawFallback) where TProperty : notnull { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (!_isMissing) { return RecordIfInvalid(convert(_value!)); } + + Outcome fallback = convert(rawFallback); + if (fallback.IsFailure) { + throw new InvalidOperationException( + $"The configured fallback of optional argument '{_argumentPath}' does not convert: {fallback.Error!.DiagnosticMessage}"); + } + + return new RequiredField(_binder, fallback.GetResultOrThrow()); + } + + /// + /// Binds an optional reference-type argument without a fallback: absent yields a null + /// value and records nothing; a present-but-invalid + /// argument records REQUEST_ARGUMENT_INVALID. + /// + /// The reference type of the value object. + /// The value-object converter. + /// The bound field token. + /// Thrown when is null. + public OptionalReferenceField AsOptionalReference(Func> convert) where TProperty : class { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (_isMissing) { return new OptionalReferenceField(_binder, value: null); } + + Outcome outcome = convert(_value!); + if (outcome.IsFailure) { + RecordInvalid(outcome.Error!); + + return new OptionalReferenceField(_binder, value: null); + } + + return new OptionalReferenceField(_binder, outcome.GetResultOrThrow()); + } + + /// + /// Binds an optional value-type argument without a fallback: absent yields a null + /// value — a real null, never + /// default(TProperty) — and records nothing; a present-but-invalid argument records + /// REQUEST_ARGUMENT_INVALID. + /// + /// The value type of the value object. + /// The value-object converter. + /// The bound field token. + /// Thrown when is null. + public OptionalValueField AsOptionalValue(Func> convert) where TProperty : struct { + if (convert is null) { throw new ArgumentNullException(nameof(convert)); } + + if (_isMissing) { return new OptionalValueField(_binder, value: null); } + + Outcome outcome = convert(_value!); + if (outcome.IsFailure) { + RecordInvalid(outcome.Error!); + + return new OptionalValueField(_binder, value: null); + } + + return new OptionalValueField(_binder, outcome.GetResultOrThrow()); + } + + private RequiredField RequiredMissing() { + _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + + return new RequiredField(_binder, default!); + } + + private RequiredField RecordIfInvalid(Outcome outcome) where TProperty : notnull { + if (outcome.IsFailure) { + RecordInvalid(outcome.Error!); + + return new RequiredField(_binder, default!); + } + + return new RequiredField(_binder, outcome.GetResultOrThrow()); + } + + private void RecordInvalid(Error cause) { + _binder.Record(RequestBindingError.ArgumentInvalid(_argumentPath, cause)); + } + +} diff --git a/FirstClassErrors.RequestBinder/ValidatingAssembler.cs b/FirstClassErrors.RequestBinder/ValidatingAssembler.cs new file mode 100644 index 0000000..96109a5 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ValidatingAssembler.cs @@ -0,0 +1,16 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Assembles the bound command inside , reading each bound +/// value from the supplied and returning an — so the assembly +/// step itself may still fail, typically a validating factory (Command.Create(...)) enforcing a cross-field +/// rule (CheckOut > CheckIn) that no single field could check on its own. +/// +/// +/// A dedicated delegate — rather than Func<BindingScope, Outcome<TCommand>> — is required because +/// is a ref struct and cannot be used as a generic type argument. +/// +/// The type of the assembled command or query. +/// The scope through which bound values are read. +/// The assembled command, or the failure of a cross-field rule. +public delegate Outcome ValidatingAssembler(BindingScope scope) where TCommand : notnull; diff --git a/FirstClassErrors.sln b/FirstClassErrors.sln index 7bc5950..f1c34ca 100644 --- a/FirstClassErrors.sln +++ b/FirstClassErrors.sln @@ -41,6 +41,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.Cli.UnitTe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.PropertyTests", "FirstClassErrors.PropertyTests\FirstClassErrors.PropertyTests.csproj", "{BB28120C-1D68-469D-928A-51AE454D2487}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBinder", "FirstClassErrors.RequestBinder\FirstClassErrors.RequestBinder.csproj", "{7991C766-2720-43E5-A9CB-2F3D6C2025FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBinder.UnitTests", "FirstClassErrors.RequestBinder.UnitTests\FirstClassErrors.RequestBinder.UnitTests.csproj", "{75EA57DD-0128-4EB9-ADBE-567BFF602A93}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -205,6 +209,30 @@ Global {BB28120C-1D68-469D-928A-51AE454D2487}.Release|x64.Build.0 = Release|Any CPU {BB28120C-1D68-469D-928A-51AE454D2487}.Release|x86.ActiveCfg = Release|Any CPU {BB28120C-1D68-469D-928A-51AE454D2487}.Release|x86.Build.0 = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x64.Build.0 = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Debug|x86.Build.0 = Debug|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|Any CPU.Build.0 = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x64.ActiveCfg = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x64.Build.0 = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x86.ActiveCfg = Release|Any CPU + {7991C766-2720-43E5-A9CB-2F3D6C2025FC}.Release|x86.Build.0 = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x64.ActiveCfg = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x64.Build.0 = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x86.ActiveCfg = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Debug|x86.Build.0 = Debug|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|Any CPU.Build.0 = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x64.ActiveCfg = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x64.Build.0 = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x86.ActiveCfg = Release|Any CPU + {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 81ba0ef..6bb6c77 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ dotnet add package FirstClassErrors The package targets **.NET Standard 2.0**. Its Roslyn analyzers are bundled automatically; no separate analyzer package is required. +To bind incoming requests into typed commands or queries at the primary-adapter boundary, add the optional request binder: + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + To generate documentation, install the CLI: ```bash @@ -161,6 +167,7 @@ For security vulnerabilities, follow the private process in [SECURITY.md](SECURI - [Writing Errors Guide](doc/WritingErrorsGuide.en.md) - [Usage Patterns](doc/UsagePatterns.en.md) +- [Binding requests at the boundary (RequestBinder)](doc/RequestBinder.en.md) - [Best Practices](doc/BestPractices.en.md) - [Testing Guide](doc/Testing.en.md) - [Deterministic Error Tests](doc/DeterministicTesting.en.md) diff --git a/doc/README.fr.md b/doc/README.fr.md index 44ad3ab..924dbf0 100644 --- a/doc/README.fr.md +++ b/doc/README.fr.md @@ -112,6 +112,12 @@ dotnet add package FirstClassErrors Le package cible **.NET Standard 2.0**. Les analyseurs Roslyn sont inclus automatiquement ; aucun package d’analyse supplémentaire n’est nécessaire. +Pour lier les requêtes entrantes en commandes ou requêtes typées à la frontière de l’adaptateur primaire, ajoutez le binder de requêtes optionnel : + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + Pour générer la documentation, installez le CLI : ```bash @@ -161,6 +167,7 @@ Pour les vulnérabilités de sécurité, suivez le processus privé décrit dans - [Guide d’écriture des erreurs](WritingErrorsGuide.fr.md) - [Cas d’usage](UsagePatterns.fr.md) +- [Lier les requêtes à la frontière (RequestBinder)](RequestBinder.fr.md) - [Bonnes pratiques](BestPractices.fr.md) - [Guide des tests](Testing.fr.md) - [Tests d’erreur déterministes](DeterministicTesting.fr.md) diff --git a/doc/RequestBinder.en.md b/doc/RequestBinder.en.md new file mode 100644 index 0000000..98ba5c9 --- /dev/null +++ b/doc/RequestBinder.en.md @@ -0,0 +1,473 @@ +# Binding requests at the boundary + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./RequestBinder.fr.md) + +`FirstClassErrors.RequestBinder` converts an incoming request DTO — the loose, +nullable shape a controller, message consumer, CLI, or gRPC handler receives — +into a typed command or query of value objects, at the primary-adapter boundary. +It collects **every** invalid field into one documented `PrimaryPortError` +instead of stopping at the first, and it never throws on the invalid-input path. + +This page is the focused guide to declaring a binder, converting properties, +reading bound values, assembling the command, and handling the errors it +produces. If you are new to `Outcome` and error factories, read +[Getting Started](GettingStarted.en.md) and the [Outcome Guide](OutcomeGuide.en.md) +first — the binder builds directly on both. + +## 🧭 The model in one minute + +```mermaid +flowchart LR + A[Request DTO] --> B[Bind each property into a value object] + B --> C{Every field valid?} + C -->|yes| D[New / Create assembles the command] + C -->|no| E[PrimaryPortError envelope grouping every failure] + D --> F[Outcome of TCommand success] + E --> F2[Outcome of TCommand failure] +``` + +A binder reads each property, converts it through a value-object factory, and +**records** every failure rather than raising it. When the terminal runs, either +every field bound — and the command is assembled — or at least one failed, and +the result is the failure of a single envelope error carrying all of them. + +## Install the package + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + +It targets **.NET Standard 2.0** and ships on the same release train as +`FirstClassErrors`, at the same version — so the two always resolve together. + +## The shape of a binding + +Every binding has the same three parts: **start** from the request and declare +the envelope, **select and convert** each property, then **assemble** the +command. The running example is a hotel-booking endpoint whose DTO is the loose +wire shape, and whose command is a value-object aggregate. + +```csharp +// The incoming DTO: everything nullable, everything a primitive. +public sealed record BookingRequest( + string? GuestEmail, + string? Reference, + string? Currency, + string? MaxNights, + StayDto? Stay, + IReadOnlyList? Tags, + IReadOnlyList? Guests); + +// The command: value objects, non-null where presence is required. +public sealed record PlaceBookingCommand( + EmailAddress GuestEmail, + string Reference, + Currency Currency, + int? MaxNights, + Stay Stay, + IReadOnlyList Tags, + IReadOnlyList Guests); +``` + +```csharp +public Outcome Bind(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + + // Assemble the command from the bound fields (the full version is at the end of this guide): + return bind.New(s => new PlaceBookingCommand( + s.Get(email), + s.Get(reference), + s.Get(currency), + /* MaxNights, Stay, Tags, Guests — bound the same way, shown below */)); +} +``` + +- **`Bind.PropertiesOf(request)`** starts a binder over the DTO. +- **`.FailWith(PlaceBookingError.Invalid)`** is mandatory: it declares the single + envelope error — one factory from *your* catalog — under which every failure is + grouped. A binder can never be left without an envelope, exactly as an error can + never be left without a public message. +- Each `SimpleProperty(...)` selects one property and converts it, returning a + **field token**, not a value. +- **`bind.New(...)`** assembles the command, reading each token through the scope + `s`. It returns `Outcome` — success when every field bound, + the envelope failure otherwise. + +The envelope factory is an ordinary aggregate error from your catalog: + +```csharp +[ProvidesErrorsFor("PlaceBooking")] +public static class PlaceBookingError { + + [DocumentedBy(nameof(InvalidDocumentation))] + internal static PrimaryPortError Invalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.Invalid, "The booking request is invalid.", violations) + .WithPublicMessage("We could not accept your booking request."); + } + + // ... Code, documentation, nested envelopes (StayInvalid, GuestInvalid) omitted for brevity. +} +``` + +## Converting a scalar property + +`SimpleProperty(r => r.X)` selects one property; the converter stage that follows +offers four ways to bind it. All of them take a value-object factory +(`Func>` — typically a method group such as +`EmailAddress.Parse`) and **fail by returning** `Outcome.Failure`, never by +throwing. + +| Method | Absent argument | Present but invalid | Bound value | +| --- | --- | --- | --- | +| `AsRequired(convert)` | records `REQUEST_ARGUMENT_REQUIRED` | records `REQUEST_ARGUMENT_INVALID` | `RequiredField` | +| `AsRequired()` | records `REQUEST_ARGUMENT_REQUIRED` | — (no conversion) | `RequiredField` (raw) | +| `AsOptional(convert, fallback)` | converts `fallback` instead | records `REQUEST_ARGUMENT_INVALID` | `RequiredField` (always present) | +| `AsOptionalReference(convert)` | `null`, records nothing | records `REQUEST_ARGUMENT_INVALID` | `OptionalReferenceField` | +| `AsOptionalValue(convert)` | `null`, records nothing | records `REQUEST_ARGUMENT_INVALID` | `OptionalValueField` | + +```csharp +// Required with conversion: EmailAddress.Parse turns the raw string into a value object. +RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + +// Required without conversion: presence is checked, the raw value is bound as-is. +RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + +// Optional with a fallback: absent uses "EUR"; a present-but-invalid value still records an error. +RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + +// Optional value type: absent yields a real Nullable null — never default(int). +OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + +// AsOptionalReference is the reference-type sibling of AsOptionalValue (absent yields a null value +// object, records nothing). It is shown on the guest's optional email under "Lists", below. +``` + +**Optional means "may be absent", never "may be malformed".** A present argument +that fails to convert is always an error — even on an optional binding. Only a +*missing* optional argument is silent (falling back, yielding `null`, or an empty +list). + +**A fallback is developer configuration, not client input.** If the `fallback` +you pass to `AsOptional` does not itself convert, that is a bug in your code, so +it throws `InvalidOperationException` rather than being reported as a client +error. + +## Reading bound values: the binding scope + +A field token exposes **no** public value. The only way to read one is +`s.Get(token)`, inside the assembler passed to `New` or `Create`: + +```csharp +return bind.New(s => new PlaceBookingCommand( + s.Get(email), // EmailAddress — required + s.Get(reference), // string — required, raw + s.Get(currency), // Currency — optional with fallback, always present + s.Get(maxNights), // int? — optional value, null when absent + ...)); +``` + +This is safety **by construction**, not by convention. The scope `s` is a +`readonly ref struct`: it cannot be stored, captured, or returned, so it lives +only for the duration of the assembler. And the terminal creates it **only** on +its success branch — after verifying that not a single failure was recorded. So a +bound value can be read *only* where every binding is known to have succeeded: +reading one before the terminal, or outside its assembler, does not compile. + +The token's type carries the nullability: + +| Token | `s.Get(...)` returns | +| --- | --- | +| `RequiredField` | `T` | +| `OptionalReferenceField` | `T?` (`null` when the argument was absent) | +| `OptionalValueField` | `T?` — a real `Nullable`, `null` when absent, never `default(T)` | + +## Assembling the command: `New` and `Create` + +There are two terminals. Pick the one that matches the shape of your assembler — +the name mirrors what you write inside it. + +| Terminal | Your assembler | Use when | +| --- | --- | --- | +| `New(s => new Command(...))` | returns the command | the constructor is total: every field was already validated one by one | +| `Create(s => Command.Create(...))` | returns `Outcome` | a validating factory enforces a **cross-field** rule that can still reject an all-valid combination | + +`New` wraps the constructed command in a success outcome: + +```csharp +return bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(reference), ...)); +``` + +`Create` runs a factory that may still fail — a cross-field rule such as +"check-out must be after check-in" that no single field could check on its own — +and **flattens** its result, so you never get an `Outcome>`: + +```csharp +return bind.Create(s => PlaceBookingCommand.Create( + s.Get(checkIn), s.Get(checkOut), s.Get(guests))); +// PlaceBookingCommand.Create(...) returns Outcome. +``` + +The factory runs **only** on the zero-error branch — every field is already +bound — so a cross-field rule can assume its inputs are present and valid. Its +failure is returned **as-is**: the factory owns that error (it is a domain rule, +not an argument-binding failure), so it is not re-wrapped in the binder envelope. +A consumer of `Create` therefore sees either the binder's envelope (a field was +missing or malformed) or the factory's own error (all fields were fine, but the +combination was rejected). The decision behind these two names is recorded in +[ADR-0007](../maintainers/adr/0007-name-the-binder-terminals-new-and-create.md). + +## Collect-all, not fail-fast + +The whole point of a binder is that a client fixing one field does not discover +the next only on resubmit. Every failing property is recorded and reported at +once, in declaration order: + +```csharp +var bind = Bind.PropertiesOf(new BookingRequest( + GuestEmail: "not-an-email", // invalid + Reference: null, // missing + Currency: "EURO", // invalid (not 3 letters) + /* ... */)) + .FailWith(PlaceBookingError.Invalid); + +bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +bind.SimpleProperty(r => r.Reference).AsRequired(); +bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + +Outcome outcome = bind.New(s => /* never reached */ null!); + +// outcome.Error is PlaceBookingError.Invalid, with three inner errors: +// REQUEST_ARGUMENT_INVALID (GuestEmail) +// REQUEST_ARGUMENT_REQUIRED (Reference) +// REQUEST_ARGUMENT_INVALID (Currency) +``` + +A request whose every field is invalid raises **zero** exceptions: the binder +never throws on the invalid-input path. + +## The two binder error codes + +The binder manufactures exactly two errors of its own. Everything else in a +failure tree comes from *your* code — the conversion errors your value objects +return, and the envelope errors you declare with `FailWith`. + +| Code | Meaning | Inner error | +| --- | --- | --- | +| `REQUEST_ARGUMENT_REQUIRED` | a required argument was absent from the request | — | +| `REQUEST_ARGUMENT_INVALID` | a present argument failed to convert | the converter's own error | + +Both carry the **full argument path** in their context under the key +`RequestArgument` (for example `Guests[1].FirstName`), so the failing field is +identifiable without parsing messages: + +```csharp +Error required = outcome.Error!.InnerErrors.First(); +required.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); +// path == "Reference" +``` + +`REQUEST_ARGUMENT_REQUIRED` is **non-transient**: resubmitting the same request +cannot succeed. `REQUEST_ARGUMENT_INVALID` wraps the converter's `DomainError` or +`PrimaryPortError` as its inner error — read it to learn the precise rule the +value violated. Both codes are documented in the generated catalog like any +other error (see the [documentation pipeline](ArchitectureOfTheDocumentationPipeline.en.md)). + +A converter must fail with a `DomainError` or a `PrimaryPortError` — the two +families a failure tree accepts. Failing with any other family is a converter +bug, reported by throwing, never recorded as a client error. + +## Nested objects + +A complex property is bound by a **nested binder**, declared with its own +envelope. The nested binding typically lives in a dedicated method, passed as a +method group: + +```csharp +RequiredField stay = bind.ComplexProperty(r => r.Stay) + .FailWith(PlaceBookingError.StayInvalid) + .AsRequired(BindStay); + +private static Outcome BindStay(RequestBinder stay) { + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); +} +``` + +The nested binder inherits the parent's options and **prefixes** its argument +paths, so a failure inside `Stay` reports `Stay.CheckIn`, not just `CheckIn`. A +missing complex property records `REQUEST_ARGUMENT_REQUIRED`; a nested binding +that fails contributes its own envelope, whose inner errors already carry the +prefixed paths. Use `AsOptional` instead of `AsRequired` for a nullable nested +object: absent yields `null` and records nothing. + +## Lists + +Two selectors bind list properties, each producing indexed paths so one bad +element never hides the others. + +**A list of scalars** converts each element through a value-object factory: + +```csharp +RequiredField> tags = + bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); +// A failing element records REQUEST_ARGUMENT_INVALID under Tags[2]. +``` + +**A list of complex elements** binds each element with a nested binder, under a +per-element envelope: + +```csharp +RequiredField> guests = + bind.ListOfComplexProperties(r => r.Guests) + .FailWith(PlaceBookingError.GuestInvalid) + .AsRequired(BindGuest); + +private static Outcome BindGuest(RequestBinder guest) { + RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + + return guest.New(s => new Guest(s.Get(firstName), s.Get(email))); +} +// A failure in the second guest's email reports Guests[1].Email. +``` + +For both list selectors, `AsRequired` records `REQUEST_ARGUMENT_REQUIRED` when the +list is absent, while `AsOptional` treats an absent list as an **empty** list +(never `null`) and records nothing. A `null` *element* of a present list is +recorded as a missing argument at its index. + +## Argument names and the wire format + +By default, the argument path uses the **C# property name** (`GuestEmail`). If +your serializer renames keys (snake_case, `JsonPropertyName`, a naming policy), +plug an `IArgumentNameProvider` so the paths reported in errors match the keys the +client actually sent: + +```csharp +public sealed class SnakeCaseNames : IArgumentNameProvider { + public string GetArgumentNameFrom(PropertyInfo property) => + ToSnakeCase(property.Name); // GuestEmail -> guest_email +} + +var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid) + .WithOptions(new RequestBinderOptions(new SnakeCaseNames())); +``` + +Call `WithOptions` **before** binding any property. Options are per-binder, never +global mutable state, and **nested binders inherit** the options in effect when +they are created — so `Stay.check_in` is renamed consistently, top to bottom. +The library deliberately ships only the default (C# property names): which +serializer names the wire keys is the host's knowledge, not the library's. + +## The bug channel: what throws vs what is collected + +The binder draws a hard line between a **client error** (recorded, surfaced once +as the envelope failure) and a **programming error** (thrown, so a genuine bug +reaches your exception boundary undisguised). Nothing on the invalid-input path +throws; these do: + +- a converter that throws instead of returning `Outcome.Failure` (a converter bug); +- a selector that is not a direct property access, e.g. `r => r.Email.Trim()`; +- an `AsOptional` fallback that does not convert (bad developer configuration); +- reading a token through the scope of a *different* binder; +- **a non-nullable value-type DTO property**, e.g. `int` instead of `int?`. + +The last one is worth dwelling on. A non-nullable value type can never be +`null`, so a *missing* argument (deserialized to `default(T)` — `0`, `false`) is +indistinguishable from a legitimately-sent default. The information does not +exist at runtime, so the binder rejects the mis-declaration loudly rather than +silently losing absence: + +```csharp +public sealed record BookingRequest(int MaxNights /* ... */); // ✗ int +// ^ throws ArgumentException: declare it int? + +public sealed record BookingRequest(int? MaxNights /* ... */); // ✓ int? +``` + +Declare every bound value-type property nullable, so an absent argument arrives +as `null` and the binder can tell it apart from a real value. + +## Complete example + +A full endpoint: nested `Stay`, a list of `Guests`, an optional `MaxNights`, and +a cross-field rule enforced through `Create`. + +```csharp +public Outcome BindBooking(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsRequired(BindGuest); + + // Create: PlaceBookingCommand.Create enforces "check-out after check-in" and may still reject. + return bind.Create(s => PlaceBookingCommand.Create( + s.Get(email), + s.Get(reference), + s.Get(currency), + s.Get(maxNights), + s.Get(stay), + s.Get(tags), + s.Get(guests))); +} +``` + +One structured error model runs from the wire to the command: a malformed field +surfaces as the binder's envelope with indexed paths; a rejected all-valid +combination surfaces as the command factory's own error. No exception is raised +unless the code itself is wrong. + +## Testing a binding + +The binder returns an `Outcome`, so the [testing helpers](Testing.en.md) apply +directly. Assert on codes and argument paths rather than on messages: + +```csharp +Outcome outcome = BindBooking(InvalidRequest()); + +Check.That(outcome.IsFailure).IsTrue(); +Check.That(outcome.Error!.Code.ToString()).IsEqualTo("PLACE_BOOKING_INVALID"); +Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) + .ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED"); +``` + +Assert the *whole* set of collected failures, in order — that is what proves the +collect-all behavior, not just that binding failed. + +## 📌 Review checklist + +Before approving a binding, verify that: + +- every DTO property is nullable, including value types (`int?`, not `int`); +- each property is bound with the variant matching its contract — required, + optional-with-fallback, or optional; +- converters fail by **returning** `Outcome.Failure`, never by throwing; +- the terminal matches the assembler: `New` for a total constructor, `Create` for + a validating factory returning `Outcome`; +- every complex property and list declares its own envelope with `FailWith`; +- an `IArgumentNameProvider` is configured when the wire keys differ from the C# + property names; +- tests assert the full, ordered set of collected errors and their argument paths. + +--- + + + +--- diff --git a/doc/RequestBinder.fr.md b/doc/RequestBinder.fr.md new file mode 100644 index 0000000..707f079 --- /dev/null +++ b/doc/RequestBinder.fr.md @@ -0,0 +1,489 @@ +# Lier les requêtes à la frontière + +🌍 **Langues :** +🇬🇧 [English](./RequestBinder.en.md) | 🇫🇷 Français (ce fichier) + +`FirstClassErrors.RequestBinder` convertit un DTO de requête entrant — la forme +lâche et nullable que reçoit un contrôleur, un consommateur de messages, une CLI +ou un handler gRPC — en une commande ou une requête typée, faite d’objets valeur, +à la frontière de l’adaptateur primaire. Il collecte **tous** les champs invalides +dans un seul `PrimaryPortError` documenté au lieu de s’arrêter au premier, et il ne +lève jamais d’exception sur le chemin des entrées invalides. + +Cette page est le guide dédié : déclarer un binder, convertir les propriétés, lire +les valeurs liées, assembler la commande, et traiter les erreurs produites. Si +`Outcome` et les fabriques d’erreurs sont nouveaux pour vous, lisez d’abord +[Bien démarrer](GettingStarted.fr.md) et le [Guide d’Outcome](OutcomeGuide.fr.md) — +le binder s’appuie directement sur les deux. + +## 🧭 Le modèle en une minute + +```mermaid +flowchart LR + A[DTO de requête] --> B[Lier chaque propriété en objet valeur] + B --> C{Tous les champs valides ?} + C -->|oui| D[New / Create assemble la commande] + C -->|non| E[Enveloppe PrimaryPortError groupant chaque échec] + D --> F[Outcome de TCommand succès] + E --> F2[Outcome de TCommand échec] +``` + +Un binder lit chaque propriété, la convertit via une fabrique d’objet valeur, et +**enregistre** chaque échec au lieu de le lever. Quand le terminal s’exécute, soit +tous les champs se sont liés — et la commande est assemblée —, soit au moins un a +échoué, et le résultat est l’échec d’une enveloppe unique qui les porte tous. + +## Installer le paquet + +```bash +dotnet add package FirstClassErrors.RequestBinder +``` + +Il cible **.NET Standard 2.0** et voyage sur le même train de release que +`FirstClassErrors`, à la même version — les deux se résolvent donc toujours +ensemble. + +## La forme d’une liaison + +Toute liaison a les trois mêmes parties : **démarrer** depuis la requête et +déclarer l’enveloppe, **sélectionner et convertir** chaque propriété, puis +**assembler** la commande. L’exemple fil rouge est un endpoint de réservation +d’hôtel dont le DTO est la forme lâche du fil, et dont la commande est un agrégat +d’objets valeur. + +```csharp +// Le DTO entrant : tout est nullable, tout est primitif. +public sealed record BookingRequest( + string? GuestEmail, + string? Reference, + string? Currency, + string? MaxNights, + StayDto? Stay, + IReadOnlyList? Tags, + IReadOnlyList? Guests); + +// La commande : des objets valeur, non-null là où la présence est requise. +public sealed record PlaceBookingCommand( + EmailAddress GuestEmail, + string Reference, + Currency Currency, + int? MaxNights, + Stay Stay, + IReadOnlyList Tags, + IReadOnlyList Guests); +``` + +```csharp +public Outcome Bind(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + + // Assembler la commande à partir des champs liés (la version complète est en fin de guide) : + return bind.New(s => new PlaceBookingCommand( + s.Get(email), + s.Get(reference), + s.Get(currency), + /* MaxNights, Stay, Tags, Guests — liés de la même façon, montrés plus bas */)); +} +``` + +- **`Bind.PropertiesOf(request)`** démarre un binder sur le DTO. +- **`.FailWith(PlaceBookingError.Invalid)`** est obligatoire : il déclare l’unique + erreur enveloppe — une fabrique de *votre* catalogue — sous laquelle chaque échec + est groupé. Un binder ne peut jamais rester sans enveloppe, exactement comme une + erreur ne peut jamais rester sans message public. +- Chaque `SimpleProperty(...)` sélectionne une propriété et la convertit, en + renvoyant un **jeton de champ**, pas une valeur. +- **`bind.New(...)`** assemble la commande, en lisant chaque jeton via la portée + `s`. Il renvoie `Outcome` — un succès quand tous les champs + se sont liés, l’échec de l’enveloppe sinon. + +La fabrique d’enveloppe est une erreur d’agrégat ordinaire de votre catalogue : + +```csharp +[ProvidesErrorsFor("PlaceBooking")] +public static class PlaceBookingError { + + [DocumentedBy(nameof(InvalidDocumentation))] + internal static PrimaryPortError Invalid(PrimaryPortInnerErrors violations) { + return PrimaryPortError.Create(Code.Invalid, "The booking request is invalid.", violations) + .WithPublicMessage("We could not accept your booking request."); + } + + // ... Code, documentation, enveloppes imbriquées (StayInvalid, GuestInvalid) omis pour la concision. +} +``` + +## Convertir une propriété scalaire + +`SimpleProperty(r => r.X)` sélectionne une propriété ; l’étape de conversion qui +suit offre quatre façons de la lier. Toutes prennent une fabrique d’objet valeur +(`Func>` — typiquement un groupe de méthodes comme +`EmailAddress.Parse`) et **échouent en renvoyant** `Outcome.Failure`, jamais en +levant une exception. + +| Méthode | Argument absent | Présent mais invalide | Valeur liée | +| --- | --- | --- | --- | +| `AsRequired(convert)` | enregistre `REQUEST_ARGUMENT_REQUIRED` | enregistre `REQUEST_ARGUMENT_INVALID` | `RequiredField` | +| `AsRequired()` | enregistre `REQUEST_ARGUMENT_REQUIRED` | — (pas de conversion) | `RequiredField` (brut) | +| `AsOptional(convert, fallback)` | convertit `fallback` à la place | enregistre `REQUEST_ARGUMENT_INVALID` | `RequiredField` (toujours présent) | +| `AsOptionalReference(convert)` | `null`, n’enregistre rien | enregistre `REQUEST_ARGUMENT_INVALID` | `OptionalReferenceField` | +| `AsOptionalValue(convert)` | `null`, n’enregistre rien | enregistre `REQUEST_ARGUMENT_INVALID` | `OptionalValueField` | + +```csharp +// Requis avec conversion : EmailAddress.Parse transforme la chaîne brute en objet valeur. +RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + +// Requis sans conversion : la présence est vérifiée, la valeur brute est liée telle quelle. +RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + +// Optionnel avec repli : l'absence utilise « EUR » ; une valeur présente mais invalide enregistre quand même une erreur. +RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + +// Type valeur optionnel : l'absence donne un vrai Nullable null — jamais default(int). +OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + +// AsOptionalReference est le pendant « type référence » d'AsOptionalValue (l'absence donne un objet +// valeur null, n'enregistre rien). Il est montré sur l'email optionnel de l'invité dans « Listes », plus bas. +``` + +**Optionnel signifie « peut être absent », jamais « peut être malformé ».** Un +argument présent qui échoue à se convertir est toujours une erreur — même sur une +liaison optionnelle. Seul un argument optionnel *manquant* est silencieux (repli, +`null`, ou liste vide). + +**Un repli est une configuration développeur, pas une entrée client.** Si le +`fallback` passé à `AsOptional` ne se convertit pas lui-même, c’est un bug de +votre code : il lève donc `InvalidOperationException` au lieu d’être rapporté comme +une erreur client. + +## Lire les valeurs liées : la portée de liaison + +Un jeton de champ n’expose **aucune** valeur publique. La seule façon d’en lire un +est `s.Get(token)`, à l’intérieur de l’assembleur passé à `New` ou `Create` : + +```csharp +return bind.New(s => new PlaceBookingCommand( + s.Get(email), // EmailAddress — requis + s.Get(reference), // string — requis, brut + s.Get(currency), // Currency — optionnel avec repli, toujours présent + s.Get(maxNights), // int? — valeur optionnelle, null si absent + ...)); +``` + +C’est une sûreté **par construction**, pas par convention. La portée `s` est un +`readonly ref struct` : elle ne peut être ni stockée, ni capturée, ni retournée, +donc elle ne vit que le temps de l’assembleur. Et le terminal ne la crée **que** +sur sa branche de succès — après avoir vérifié qu’aucun échec n’a été enregistré. +Une valeur liée ne peut donc être lue *que* là où chaque liaison est connue pour +avoir réussi : lire une valeur avant le terminal, ou hors de son assembleur, **ne +compile pas**. + +Le type du jeton porte la nullabilité : + +| Jeton | `s.Get(...)` renvoie | +| --- | --- | +| `RequiredField` | `T` | +| `OptionalReferenceField` | `T?` (`null` si l’argument était absent) | +| `OptionalValueField` | `T?` — un vrai `Nullable`, `null` si absent, jamais `default(T)` | + +## Assembler la commande : `New` et `Create` + +Il y a deux terminaux. Choisissez celui qui correspond à la forme de votre +assembleur — le nom recopie ce que vous écrivez à l’intérieur. + +| Terminal | Votre assembleur | À utiliser quand | +| --- | --- | --- | +| `New(s => new Command(...))` | renvoie la commande | le constructeur est total : chaque champ a déjà été validé un par un | +| `Create(s => Command.Create(...))` | renvoie `Outcome` | une fabrique validante applique une règle **inter-champs** qui peut encore rejeter une combinaison pourtant valide | + +`New` emballe la commande construite dans un outcome de succès : + +```csharp +return bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(reference), ...)); +``` + +`Create` exécute une fabrique qui peut encore échouer — une règle inter-champs +telle que « la sortie doit être après l’entrée » qu’aucun champ seul ne pourrait +vérifier — et **aplatit** son résultat, pour que vous n’obteniez jamais un +`Outcome>` : + +```csharp +return bind.Create(s => PlaceBookingCommand.Create( + s.Get(checkIn), s.Get(checkOut), s.Get(guests))); +// PlaceBookingCommand.Create(...) renvoie Outcome. +``` + +La fabrique ne s’exécute **que** sur la branche zéro-erreur — chaque champ est +déjà lié — donc une règle inter-champs peut supposer ses entrées présentes et +valides. Son échec est renvoyé **tel quel** : la fabrique possède cette erreur +(c’est une règle métier, pas un échec de liaison d’argument), elle n’est donc pas +ré-emballée dans l’enveloppe du binder. Un consommateur de `Create` voit donc soit +l’enveloppe du binder (un champ manquant ou malformé), soit l’erreur propre de la +fabrique (tous les champs allaient bien, mais la combinaison a été rejetée). La +décision derrière ces deux noms est consignée dans +[ADR-0007](../maintainers/adr/0007-name-the-binder-terminals-new-and-create.md). + +## Tout collecter, pas au premier échec + +Tout l’intérêt d’un binder est qu’un client qui corrige un champ ne découvre pas +le suivant seulement à la resoumission. Chaque propriété en échec est enregistrée +et rapportée d’un coup, dans l’ordre de déclaration : + +```csharp +var bind = Bind.PropertiesOf(new BookingRequest( + GuestEmail: "not-an-email", // invalide + Reference: null, // manquant + Currency: "EURO", // invalide (pas 3 lettres) + /* ... */)) + .FailWith(PlaceBookingError.Invalid); + +bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +bind.SimpleProperty(r => r.Reference).AsRequired(); +bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + +Outcome outcome = bind.New(s => /* jamais atteint */ null!); + +// outcome.Error est PlaceBookingError.Invalid, avec trois erreurs internes : +// REQUEST_ARGUMENT_INVALID (GuestEmail) +// REQUEST_ARGUMENT_REQUIRED (Reference) +// REQUEST_ARGUMENT_INVALID (Currency) +``` + +Une requête dont chaque champ est invalide ne lève **aucune** exception : le binder +ne lève jamais sur le chemin des entrées invalides. + +## Les deux codes d’erreur du binder + +Le binder fabrique exactement deux erreurs qui lui sont propres. Tout le reste +d’un arbre d’échec vient de *votre* code — les erreurs de conversion que renvoient +vos objets valeur, et les erreurs enveloppes que vous déclarez avec `FailWith`. + +| Code | Signification | Erreur interne | +| --- | --- | --- | +| `REQUEST_ARGUMENT_REQUIRED` | un argument requis était absent de la requête | — | +| `REQUEST_ARGUMENT_INVALID` | un argument présent a échoué à se convertir | l’erreur propre du convertisseur | + +Les deux portent le **chemin d’argument complet** dans leur contexte sous la clé +`RequestArgument` (par exemple `Guests[1].FirstName`), pour que le champ fautif +soit identifiable sans parser les messages : + +```csharp +Error required = outcome.Error!.InnerErrors.First(); +required.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); +// path == "Reference" +``` + +`REQUEST_ARGUMENT_REQUIRED` est **non transitoire** : resoumettre la même requête +ne peut pas réussir. `REQUEST_ARGUMENT_INVALID` emballe comme erreur interne le +`DomainError` ou le `PrimaryPortError` du convertisseur — lisez-le pour connaître +la règle précise que la valeur a violée. Les deux codes sont documentés dans le +catalogue généré comme toute autre erreur (voir le +[pipeline de documentation](ArchitectureOfTheDocumentationPipeline.fr.md)). + +Un convertisseur doit échouer avec un `DomainError` ou un `PrimaryPortError` — les +deux familles qu’un arbre d’échec accepte. Échouer avec une autre famille est un +bug du convertisseur, rapporté en levant, jamais enregistré comme erreur client. + +## Objets imbriqués + +Une propriété complexe est liée par un **binder imbriqué**, déclaré avec sa propre +enveloppe. La liaison imbriquée vit typiquement dans une méthode dédiée, passée en +groupe de méthodes : + +```csharp +RequiredField stay = bind.ComplexProperty(r => r.Stay) + .FailWith(PlaceBookingError.StayInvalid) + .AsRequired(BindStay); + +private static Outcome BindStay(RequestBinder stay) { + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); +} +``` + +Le binder imbriqué hérite des options du parent et **préfixe** ses chemins +d’argument : un échec dans `Stay` rapporte `Stay.CheckIn`, pas seulement +`CheckIn`. Une propriété complexe manquante enregistre `REQUEST_ARGUMENT_REQUIRED` ; +une liaison imbriquée qui échoue contribue sa propre enveloppe, dont les erreurs +internes portent déjà les chemins préfixés. Utilisez `AsOptional` plutôt +qu’`AsRequired` pour un objet imbriqué nullable : l’absence donne `null` et +n’enregistre rien. + +## Listes + +Deux sélecteurs lient les propriétés de type liste, chacun produisant des chemins +indexés pour qu’un mauvais élément n’en cache jamais un autre. + +**Une liste de scalaires** convertit chaque élément via une fabrique d’objet +valeur : + +```csharp +RequiredField> tags = + bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); +// Un élément en échec enregistre REQUEST_ARGUMENT_INVALID sous Tags[2]. +``` + +**Une liste d’éléments complexes** lie chaque élément avec un binder imbriqué, sous +une enveloppe par élément : + +```csharp +RequiredField> guests = + bind.ListOfComplexProperties(r => r.Guests) + .FailWith(PlaceBookingError.GuestInvalid) + .AsRequired(BindGuest); + +private static Outcome BindGuest(RequestBinder guest) { + RequiredField firstName = guest.SimpleProperty(g => g.FirstName).AsRequired(); + OptionalReferenceField email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse); + + return guest.New(s => new Guest(s.Get(firstName), s.Get(email))); +} +// Un échec sur l'email du deuxième invité rapporte Guests[1].Email. +``` + +Pour les deux sélecteurs de liste, `AsRequired` enregistre +`REQUEST_ARGUMENT_REQUIRED` quand la liste est absente, tandis qu’`AsOptional` +traite une liste absente comme une liste **vide** (jamais `null`) et n’enregistre +rien. Un *élément* `null` d’une liste présente est enregistré comme un argument +manquant à son index. + +## Noms d’arguments et format du fil + +Par défaut, le chemin d’argument utilise le **nom de propriété C#** +(`GuestEmail`). Si votre sérialiseur renomme les clés (snake_case, +`JsonPropertyName`, une politique de nommage), branchez un `IArgumentNameProvider` +pour que les chemins rapportés dans les erreurs correspondent aux clés réellement +envoyées par le client : + +```csharp +public sealed class SnakeCaseNames : IArgumentNameProvider { + public string GetArgumentNameFrom(PropertyInfo property) => + ToSnakeCase(property.Name); // GuestEmail -> guest_email +} + +var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid) + .WithOptions(new RequestBinderOptions(new SnakeCaseNames())); +``` + +Appelez `WithOptions` **avant** de lier la moindre propriété. Les options sont par +binder, jamais un état global mutable, et **les binders imbriqués héritent** des +options en vigueur à leur création — donc `Stay.check_in` est renommé de manière +cohérente, de haut en bas. La bibliothèque ne fournit délibérément que le défaut +(noms de propriété C#) : quel sérialiseur nomme les clés du fil relève de la +connaissance de l’hôte, pas de la bibliothèque. + +## Le canal des bugs : ce qui lève vs ce qui est collecté + +Le binder trace une frontière nette entre une **erreur client** (enregistrée, +surfacée une seule fois comme l’échec de l’enveloppe) et une **erreur de +programmation** (levée, pour qu’un vrai bug atteigne votre frontière d’exceptions +sans déguisement). Rien sur le chemin des entrées invalides ne lève ; ceci, si : + +- un convertisseur lève au lieu de renvoyer `Outcome.Failure` (bug de convertisseur) ; +- un sélecteur qui n’est pas un accès direct à une propriété, p. ex. `r => r.Email.Trim()` ; +- un repli `AsOptional` qui ne se convertit pas (mauvaise configuration développeur) ; +- la lecture d’un jeton via la portée d’un *autre* binder ; +- **une propriété DTO de type valeur non-nullable**, p. ex. `int` au lieu de `int?`. + +Le dernier cas mérite qu’on s’y arrête. Un type valeur non-nullable ne peut jamais +être `null`, donc un argument *manquant* (désérialisé en `default(T)` — `0`, +`false`) est indistinguable d’un défaut légitimement envoyé. L’information n’existe +pas à l’exécution : le binder rejette donc la mauvaise déclaration bruyamment +plutôt que de perdre silencieusement l’absence : + +```csharp +public sealed record BookingRequest(int MaxNights /* ... */); // ✗ int +// ^ lève ArgumentException : déclarez-le int? + +public sealed record BookingRequest(int? MaxNights /* ... */); // ✓ int? +``` + +Déclarez nullable chaque propriété de type valeur liée, pour qu’un argument absent +arrive comme `null` et que le binder puisse le distinguer d’une vraie valeur. + +## Exemple complet + +Un endpoint complet : un `Stay` imbriqué, une liste de `Guests`, un `MaxNights` +optionnel, et une règle inter-champs appliquée via `Create`. + +```csharp +public Outcome BindBooking(BookingRequest request) { + var bind = Bind.PropertiesOf(request) + .FailWith(PlaceBookingError.Invalid); + + RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = bind.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueField maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsRequired(BindGuest); + + // Create : PlaceBookingCommand.Create applique « sortie après entrée » et peut encore rejeter. + return bind.Create(s => PlaceBookingCommand.Create( + s.Get(email), + s.Get(reference), + s.Get(currency), + s.Get(maxNights), + s.Get(stay), + s.Get(tags), + s.Get(guests))); +} +``` + +Un seul modèle d’erreur structuré court du fil jusqu’à la commande : un champ +malformé surface comme l’enveloppe du binder avec ses chemins indexés ; une +combinaison pourtant valide mais rejetée surface comme l’erreur propre de la +fabrique de commande. Aucune exception n’est levée, sauf si le code lui-même est +faux. + +## Tester une liaison + +Le binder renvoie un `Outcome`, donc les [aides de test](Testing.fr.md) +s’appliquent directement. Assertez sur les codes et les chemins d’argument plutôt +que sur les messages : + +```csharp +Outcome outcome = BindBooking(InvalidRequest()); + +Check.That(outcome.IsFailure).IsTrue(); +Check.That(outcome.Error!.Code.ToString()).IsEqualTo("PLACE_BOOKING_INVALID"); +Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) + .ContainsExactly("REQUEST_ARGUMENT_INVALID", "REQUEST_ARGUMENT_REQUIRED"); +``` + +Assertez l’ensemble *complet* des échecs collectés, dans l’ordre — c’est ce qui +prouve le comportement de collecte exhaustive, pas seulement que la liaison a +échoué. + +## 📌 Checklist de revue + +Avant d’approuver une liaison, vérifiez que : + +- chaque propriété du DTO est nullable, y compris les types valeur (`int?`, pas `int`) ; +- chaque propriété est liée avec la variante correspondant à son contrat — requise, + optionnelle-avec-repli, ou optionnelle ; +- les convertisseurs échouent en **renvoyant** `Outcome.Failure`, jamais en levant ; +- le terminal correspond à l’assembleur : `New` pour un constructeur total, + `Create` pour une fabrique validante renvoyant `Outcome` ; +- chaque propriété complexe et chaque liste déclare sa propre enveloppe avec `FailWith` ; +- un `IArgumentNameProvider` est configuré quand les clés du fil diffèrent des noms + de propriété C# ; +- les tests assertent l’ensemble complet et ordonné des erreurs collectées et leurs + chemins d’argument. + +--- + + + +--- diff --git a/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md new file mode 100644 index 0000000..7541d69 --- /dev/null +++ b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md @@ -0,0 +1,142 @@ +# ADR-0007 | Name the binder terminals New and Create + +**Status:** Proposed +**Date:** 2026-07-15 +**Decision Makers:** Reefact + +## Context + +* `FirstClassErrors.RequestBinder` binds each property of a request DTO into a value + object, then a **terminal** assembles the bound values into the command (or query) + and returns `Outcome`. +* Consumers assemble that command in two shapes that differ in whether the assembly + step can itself fail: a **total constructor** (`new Command(...)`) cannot fail, + because every field was already validated one by one during binding; a + **validating factory** (`Command.Create(...)` returning `Outcome`) can + still fail, because it enforces a cross-field rule — such as check-out being after + check-in — that no single field could check on its own. +* C# forbids overloading on return type alone, and its overload resolution does not + traverse the implicit `TCommand` → `Outcome` conversion. Two same-named + terminal overloads — one taking a `TCommand`-returning assembler, one taking an + `Outcome`-returning assembler — are therefore ambiguous for a lambda that + returns `Outcome`: both are applicable with a different inferred + `TCommand`, and the compiler rejects the call. +* A single terminal taking only a `TCommand`-returning assembler forces a consumer + whose factory returns `Outcome` to nest the result as + `Outcome>`, and leaves a cross-field rule no place to run. +* The library already reserves the plain factory name for the `Outcome`-returning + variant (ADR-0005); across the codebase the `Parse` / `Create` factories of value + objects return `Outcome`, so "a plainly-named factory hands back an `Outcome`" + is established vocabulary. +* The assembler receives a `BindingScope`, a `readonly ref struct`; a ref struct + cannot be a generic type argument, so an assembler cannot be a `Func<>` and must be + a named delegate type. +* The library is pre-release, unpublished on NuGet with no external consumers, so a + naming choice on this new API carries no downstream migration cost. + +## Decision + +The request binder exposes two distinctly-named terminals — `New`, taking a total +constructor that returns the command, and `Create`, taking a validating factory that +returns `Outcome` whose result is flattened — instead of a single terminal +or two same-named overloads. + +## Rationale + +* Distinct names give each terminal exactly one signature, so the overload ambiguity + that two same-named terminals would raise for an `Outcome`-returning + lambda cannot arise: the problem is removed at the name rather than worked around at + the call site. +* Keeping two terminals — rather than one `Outcome`-only terminal — lets a total + constructor stay total: the common case constructs the command directly without + wrapping it in a success `Outcome`, while `Create` flattens the validating case so a + factory's `Outcome` is never nested. This addresses both failures the + Context describes (the nested outcome and the cross-field rule with nowhere to run). +* Naming each terminal after the assembler it takes makes the choice self-selecting: a + consumer writing a `new` reaches for `New`, one writing a `.Create` factory reaches + for `Create`. The name reuses the constructor-versus-factory distinction every C# + developer already holds, so it needs no separate lookup. +* The mnemonic is coherent with ADR-0005 rather than in tension with it: ADR-0005 + reserves the plain factory name for the `Outcome`-returning variant, and `Create` + here is precisely the `Outcome`-returning terminal. ADR-0005's axis + (`Outcome`-returning versus throwing) is orthogonal to this one (a bare-value + assembler versus an `Outcome`-returning assembler); neither terminal throws, so no + `OrThrow` marker applies. +* `Create` returns the factory's failure unchanged rather than re-wrapping it, because + a cross-field rule is a domain concern the factory owns, whereas the binder's + envelope groups argument-binding failures. Keeping the two channels separate lets a + caller tell a malformed argument from a rejected valid combination. +* The pre-release status means the naming is settled now, when there are no consumers + to migrate. + +## Alternatives Considered + +### One terminal taking an Outcome-returning assembler + +Considered because it is the smallest surface — a single name, with the flatten +covering both cases once the caller wraps a total constructor in a success `Outcome`. + +Rejected because it forces the common, cannot-fail case to wrap every construction in +a success `Outcome`, surfacing result-plumbing the total case does not otherwise need. + +### Two same-named `Build` overloads + +Considered because overloading is the idiomatic C# way to accept two argument shapes +under one verb. + +Rejected because a lambda returning `Outcome` is applicable to both +overloads with a different inferred `TCommand`, overload resolution does not prefer +one over the other, and the call therefore does not compile. + +### `Build` and `BuildValidated` (one bare name, one suffixed) + +Considered as distinct names that already avoid the ambiguity. + +Rejected as asymmetric: a bare verb beside a suffixed variant reads as "the real one +and a special case", when the two are peers over different assembler shapes. + +### Semantics-first symmetric pairs (`Assemble` / `Validate`, `BuildFrom` / `BuildThrough`) + +Considered because the words state the can-fail / cannot-fail semantics for a reader +discovering the API. + +Rejected in favour of `New` / `Create`, which optimise instead for the developer +writing the call: the name matches the constructor or factory already in hand, reusing +an existing convention rather than teaching new vocabulary. + +## Consequences + +### Positive + +* The overload ambiguity is impossible: one name, one signature each. +* A total constructor stays unwrapped, and a validating factory composes without a + nested `Outcome`. +* The terminal name is a usage mnemonic tied to the constructor or factory the + consumer already writes, and reuses the codebase's `Create`-returns-`Outcome` + convention (ADR-0005). +* Argument-binding failures and a factory's cross-field failure stay distinguishable + at the call site. + +### Negative + +* `New` and `Create` are near-synonyms in English, so "only `Create` can fail" is + carried by the constructor-versus-factory convention rather than by the words; a + reader unfamiliar with the convention must consult the API docs. +* `New` is an unusual method identifier — valid in C# (only the lowercase `new` is the + keyword) but requiring escaping (`[New]`) to call from VB.NET. +* Two public terminals and two public assembler delegate types to document rather than + one. + +### Risks + +* Without tooling that enforces it, a later terminal for a third assembler shape could + drift from the convention and dilute the mnemonic; mitigated for now by this ADR and + by review. + +## References + +* ADR-0005 — reserve the plain factory name for the Outcome-returning variant, the + convention `Create` here reuses. +* ADR-0003 — unify Outcome value mapping under `Then`, context on naming the Outcome + surface for intent rather than mechanics. +* Pull request #126 — the request binder feature this terminal belongs to. diff --git a/maintainers/adr/README.md b/maintainers/adr/README.md index 35e54a0..d1e4ab7 100644 --- a/maintainers/adr/README.md +++ b/maintainers/adr/README.md @@ -175,3 +175,4 @@ Optional supporting material: | [ADR-0004](0004-check-every-pull-request-against-the-adr-base.md) | Check every pull request against the ADR base | Accepted | | [ADR-0005](0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md) | Reserve the plain factory name for the Outcome-returning variant | Accepted | | [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.md) | Supply arbitrary test values from a single seedable source | Accepted | +| [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Proposed | diff --git a/tools/commit-lint/lint-commit-message.sh b/tools/commit-lint/lint-commit-message.sh index e77a2ae..f7e35e4 100755 --- a/tools/commit-lint/lint-commit-message.sh +++ b/tools/commit-lint/lint-commit-message.sh @@ -24,9 +24,9 @@ set -u TYPES='feat|fix|build|chore|ci|docs|perf|refactor|revert|style|test' -SCOPES='core|analyzers|cli|gendoc|testing' +SCOPES='core|analyzers|binder|cli|gendoc|testing' TYPES_HUMAN='feat, fix, build, chore, ci, docs, perf, refactor, revert, style, test' -SCOPES_HUMAN='core, analyzers, cli, gendoc, testing' +SCOPES_HUMAN='core, analyzers, binder, cli, gendoc, testing' MAX=72 # --- options ------------------------------------------------------------------ diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index ed5831d..ba7dc00 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -13,10 +13,10 @@ # Usage: tools/packaging/pack.sh # is any valid SemVer (a real release passes the tag version; the # dry run passes a throwaway like 0.0.0-dryrun). -# selects which release train to pack, since lib and the fce CLI are -# versioned and released independently: -# lib -> FirstClassErrors + FirstClassErrors.Testing (lockstep) -# cli -> FirstClassErrors.Cli (the `fce` .NET tool) +# selects which release train to pack, since the trains are versioned +# and released independently: +# lib -> FirstClassErrors + FirstClassErrors.Testing + FirstClassErrors.RequestBinder (lockstep) +# cli -> FirstClassErrors.Cli (the `fce` .NET tool) set -eu @@ -32,9 +32,13 @@ scope="$2" # csproj, so local and floor-check packs stay SBOM-free. case "$scope" in lib) - # FirstClassErrors + its testing helpers: intrinsically coupled (Testing has a ProjectReference on - # the library and sees its internals), so they always ship together at the same version. - projects='FirstClassErrors/FirstClassErrors.csproj FirstClassErrors.Testing/FirstClassErrors.Testing.csproj' + # FirstClassErrors, its testing helpers, and the request binder. All three carry a ProjectReference on the + # library, so all three are intrinsically coupled to it and ship together at the same version. Packing the + # binder HERE -- on the library's train rather than one of its own -- is what keeps its `FirstClassErrors` + # package dependency pointing at a version that is co-published in this very release (the lockstep guard + # below proves it): a separate train would stamp the binder's dependency at a version never published for + # FirstClassErrors, making the package unresolvable (NU1102) and, once pushed, irreversibly so. + projects='FirstClassErrors/FirstClassErrors.csproj FirstClassErrors.Testing/FirstClassErrors.Testing.csproj FirstClassErrors.RequestBinder/FirstClassErrors.RequestBinder.csproj' ;; cli) # The `fce` .NET tool (PackAsTool; the GenDoc worker it spawns travels bundled inside the tool @@ -64,6 +68,27 @@ for package in artifacts/*.nupkg; do fi done +# Lockstep guard for the lib train. FirstClassErrors, its testing helpers and the request binder are +# co-published here at the SAME $version, so every intra-train dependency on FirstClassErrors MUST reference +# exactly $version -- the version this release is about to publish. `dotnet pack` turns each ProjectReference +# into ; a package pinning any other version would +# demand a FirstClassErrors that was never published on this train -> NU1102 for the consumer, on an immutable +# artifact. This is the check whose absence made an independent binder train unshippable; assert it every pack. +if [ "$scope" = "lib" ]; then + for package in artifacts/*.nupkg; do + while IFS= read -r dependency; do + if [ -z "$dependency" ]; then continue; fi + case "$dependency" in + *"version=\"${version}\""*) : ;; # pinned to the co-published version -- good + *) echo "error: $package pins an off-train FirstClassErrors dependency (expected version=\"$version\"): $dependency" >&2; exit 1 ;; + esac + done <]*id="FirstClassErrors[^"]*"[^>]*>' || true) +EOF + done + echo "ok: every lib-train package pins its FirstClassErrors dependency to the co-published $version" +fi + # Positive proof that the fce tool ships its GenDoc worker. `fce generate` does not do the whole job # in-process: it spawns FirstClassErrors.GenDoc.Worker in a child process (dotnet exec) and resolves it # next to the installed executable (ResolveWorkerAssemblyPath -> AppContext.BaseDirectory). PackAsTool packs diff --git a/tools/trains.sh b/tools/trains.sh index 4af471a..298da11 100644 --- a/tools/trains.sh +++ b/tools/trains.sh @@ -22,7 +22,7 @@ # tools/commit-lint/lint-commit-message.sh. trains_rows() { cat <<'ROWS' -lib|lib-v|core,analyzers,testing|CHANGELOG.md|FirstClassErrors and FirstClassErrors.Testing +lib|lib-v|core,analyzers,testing,binder|CHANGELOG.md|FirstClassErrors, FirstClassErrors.Testing and FirstClassErrors.RequestBinder cli|cli-v|cli,gendoc|FirstClassErrors.Cli/CHANGELOG.md|FirstClassErrors.Cli (the fce .NET tool) ROWS }