From 2068a4fe49e5be21a1cc91d68b1b1d29ca51e1b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:05:54 +0000 Subject: [PATCH] feat(binder)!: build a command from attached input sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the request binder so it builds a command or query from independently attached sources rather than from a single DTO. The binder is source-agnostic and untyped: it is entered from its failure envelope — Bind.Request(envelope) — and a DTO's properties (PropertiesOf(dto)) and individually named, source-tagged out-of-DTO arguments (Argument(name).From(source, value) / ArgumentList, with FromRoute/FromQuery/... sugar) are attached as peers, sharing one collect-all envelope and one set of argument paths. The command type is named only at the New/Create terminal, inferred from the assembler; a single RequestBinder type serves top-level and nested binding, and a nested complex binding receives that RequestBinder plus the sub-DTO, so its result type is inferred with no explicit type argument. An argument's provenance is captured in the failure context under the new public RequestArgumentSource key (the argument-path key is public too), so an adapter can project a structured per-field response; a DTO property carries no source (its provenance is implicit). The request-binder guide (EN/FR) and the package README are rewritten to the new model, and ADR-0021 records the decision. BREAKING CHANGE: the entry point changes from Bind.PropertiesOf(dto).FailWith(env) to Bind.Request(env) + bind.PropertiesOf(dto); the property selectors move to PropertySource; the terminals New/Create carry the command type parameter; the converter types drop their TRequest type parameter; nested-binding functions take a non-generic RequestBinder and the sub-DTO. Pre-release, no external consumers. Refs: #148 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J12ghrzM1xiAKw4LETJDqr --- .../ListBindingInvariantTests.cs | 7 +- .../ArgumentBindingTests.cs | 280 +++++++++++++++ .../BindingContractTests.cs | 144 ++++---- .../BookingEndToEndTests.cs | 29 +- .../ComplexPropertyBindingTests.cs | 36 +- .../DefaultOptionsTests.cs | 22 +- .../ListBindingTests.cs | 72 ++-- .../RequestBinderTests.cs | 107 +++--- .../SimplePropertyBindingTests.cs | 75 ++-- .../StructuralCodeOverrideTests.cs | 50 +-- .../StructuralMessageOverrideTests.cs | 15 +- .../ValueTypePropertyBindingTests.cs | 80 +++-- .../Binding/BinderShowcase.cs | 33 +- .../Binding/BookingBinder.cs | 33 +- .../ArgumentListSource.cs | 59 ++++ .../ArgumentSource.cs | 65 ++++ .../ArgumentSourceExtensions.cs | 108 ++++++ FirstClassErrors.RequestBinder/Bind.cs | 51 +-- .../BindingAssembler.cs | 2 +- .../BindingScope.cs | 8 +- .../ComplexPropertyConverter.cs | 73 ++-- .../ComplexPropertyEnvelopeStage.cs | 29 +- .../ConfiguredBind.cs | 26 +- .../ListOfComplexPropertiesConverter.cs | 39 +-- .../ListOfComplexPropertiesEnvelopeStage.cs | 21 +- .../ListOfSimplePropertiesConverter.cs | 35 +- .../ListOfSimpleValuePropertiesConverter.cs | 37 +- .../NestedFailure.cs | 8 +- .../OptionalReferenceField.cs | 2 +- .../OptionalValueField.cs | 2 +- .../PropertySource.cs | 147 ++++++++ .../README.nuget.md | 13 +- .../RequestBinder.cs | 289 ++++----------- .../RequestBinderEnvelopeStage.cs | 41 --- .../RequestBinderOptions.cs | 4 +- .../RequestBinding.cs | 128 +++++++ .../RequestBindingError.cs | 32 +- .../RequiredField.cs | 2 +- .../SimplePropertyConverter.cs | 80 ++--- .../ValidatingAssembler.cs | 2 +- ...eers-through-a-source-agnostic-entry.fr.md | 195 +++++++++++ ...s-peers-through-a-source-agnostic-entry.md | 181 ++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + doc/handwritten/for-users/RequestBinder.en.md | 302 ++++++++++------ doc/handwritten/for-users/RequestBinder.fr.md | 329 ++++++++++++------ 45 files changed, 2289 insertions(+), 1005 deletions(-) create mode 100644 FirstClassErrors.RequestBinder.UnitTests/ArgumentBindingTests.cs create mode 100644 FirstClassErrors.RequestBinder/ArgumentListSource.cs create mode 100644 FirstClassErrors.RequestBinder/ArgumentSource.cs create mode 100644 FirstClassErrors.RequestBinder/ArgumentSourceExtensions.cs create mode 100644 FirstClassErrors.RequestBinder/PropertySource.cs delete mode 100644 FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs create mode 100644 FirstClassErrors.RequestBinder/RequestBinding.cs create mode 100644 doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md diff --git a/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs b/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs index e3190baf..14c32a7e 100644 --- a/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs +++ b/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs @@ -16,7 +16,7 @@ namespace FirstClassErrors.RequestBinder.PropertyTests; /// fewer, never more — and the positional stability of the indexed error paths, so that reordering the elements /// reorders their paths accordingly and an element's path never depends on its neighbours. /// -[TestSubject(typeof(ListOfSimplePropertiesConverter<,>))] +[TestSubject(typeof(ListOfSimplePropertiesConverter<>))] public sealed class ListBindingInvariantTests { [Fact(DisplayName = "Collect-all: a required list records exactly one error per failing element, and none for a valid one.")] @@ -57,9 +57,10 @@ public void ReorderingElementsReordersTheirErrorPathsAccordingly() { /// Binds the generated tokens as a required list and returns the whole outcome. private static Outcome> BindTokens((string? Raw, bool Fails)[] slots) { TokenListRequest request = new(slots.Select(slot => slot.Raw).ToArray()); - var binder = Bind.PropertiesOf(request).FailWith(CommandError.Invalid); + var binder = Bind.Request(CommandError.Invalid); + var body = binder.PropertiesOf(request); - RequiredField> tokens = binder.ListOfSimpleProperties(r => r.Tokens).AsRequired(Token.Parse); + RequiredField> tokens = body.ListOfSimpleProperties(r => r.Tokens).AsRequired(Token.Parse); return binder.New(scope => scope.Get(tokens)); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/ArgumentBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ArgumentBindingTests.cs new file mode 100644 index 00000000..64a311a8 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/ArgumentBindingTests.cs @@ -0,0 +1,280 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +/// +/// Binding of out-of-DTO arguments (route, query, header, …): the Bind.Request entry, the +/// Argument / ArgumentList sources, provenance capture, and the mixing of a DTO's properties with +/// arguments into one collect-all envelope. +/// +public sealed class ArgumentBindingTests { + + #region A present, valid argument binds + + [Fact] + public void Argument_present_and_valid_binds_its_value() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.Argument("email").From("query", "guest@example.com").AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(s => s.Get(email)); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow().Value).IsEqualTo("guest@example.com"); + } + + [Fact] + public void Argument_presence_only_binds_the_raw_value() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField raw = bind.Argument("reference").From("route", "BK-1").AsRequired(); + + Outcome outcome = bind.New(s => s.Get(raw)); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo("BK-1"); + } + + #endregion + + #region A missing / invalid argument records path and source + + [Fact] + public void Missing_required_argument_records_required_with_path_and_source() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.Argument("email").From("query", (string?)null).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(s => s.Get(email)); + + Error inner = Single(outcome); + Check.That(inner.Code).IsEqualTo(RequestBindingError.DefaultArgumentRequiredCode); + Check.That(PathOf(inner)).IsEqualTo("email"); + Check.That(SourceOf(inner)).IsEqualTo("query"); + } + + [Fact] + public void Invalid_argument_records_invalid_with_path_source_and_inner_cause() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.Argument("email").From("header", "not-an-email").AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(s => s.Get(email)); + + Error inner = Single(outcome); + Check.That(inner.Code).IsEqualTo(RequestBindingError.DefaultArgumentInvalidCode); + Check.That(PathOf(inner)).IsEqualTo("email"); + Check.That(SourceOf(inner)).IsEqualTo("header"); + Check.That(inner.InnerErrors.Single().Code).IsEqualTo(ErrorCode.Create("TEST_EMAIL_INVALID")); + } + + [Fact] + public void Optional_reference_argument_absent_yields_null_and_records_nothing() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + OptionalReferenceField email = bind.Argument("email").From("query", (string?)null).AsOptionalReference(EmailAddress.Parse); + + Outcome outcome = bind.New(s => new Container(s.Get(email))); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow().Email).IsNull(); + } + + #endregion + + #region Provenance: the FromXxx sugar labels the source + + [Theory] + [InlineData("route")] + [InlineData("query")] + [InlineData("header")] + [InlineData("body")] + [InlineData("form")] + public void From_labels_the_source_it_is_given(string source) { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.Argument("email").From(source, (string?)null).AsRequired(EmailAddress.Parse); + + Outcome outcome = bind.New(s => s.Get(email)); + + Check.That(SourceOf(Single(outcome))).IsEqualTo(source); + } + + [Fact] + public void FromRoute_sugar_is_from_route() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.Argument("email").FromRoute((string?)null).AsRequired(EmailAddress.Parse); + + Check.That(SourceOf(Single(bind.New(s => s.Get(email))))).IsEqualTo("route"); + } + + [Fact] + public void FromQuery_sugar_is_from_query() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = bind.Argument("email").FromQuery("not-an-email").AsRequired(EmailAddress.Parse); + + Check.That(SourceOf(Single(bind.New(s => s.Get(email))))).IsEqualTo("query"); + } + + [Fact] + public void A_dto_property_carries_no_source() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField email = + bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)) + .SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + Error inner = Single(bind.New(s => s.Get(email))); + Check.That(PathOf(inner)).IsEqualTo("GuestEmail"); + Check.That(SourceOf(inner)).IsNull(); + } + + #endregion + + #region Mixing a DTO's properties with arguments into one envelope + + [Fact] + public void A_dto_property_and_an_argument_collect_into_one_envelope() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(new BookingRequest("bad-email", null, null, null, null, null, null)); + + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); // body, invalid + RequiredField tag = bind.Argument("tag").FromQuery((string?)null).AsRequired(Tag.Parse); // query, missing + + Outcome outcome = bind.New(s => new Mix(s.Get(email), s.Get(tag))); + + Check.That(outcome.IsFailure).IsTrue(); + + IReadOnlyList inners = outcome.Error!.InnerErrors; + Check.That(inners).HasSize(2); + + Error emailError = inners.Single(e => PathOf(e) == "GuestEmail"); + Error tagError = inners.Single(e => PathOf(e) == "tag"); + Check.That(SourceOf(emailError)).IsNull(); // DTO property: implicit provenance + Check.That(SourceOf(tagError)).IsEqualTo("query"); // argument: explicit provenance + } + + [Fact] + public void A_command_binds_from_arguments_only_without_any_dto() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + + RequiredField email = bind.Argument("email").FromRoute("guest@example.com").AsRequired(EmailAddress.Parse); + RequiredField tag = bind.Argument("tag").FromQuery("vip").AsRequired(Tag.Parse); + + Outcome outcome = bind.New(s => new Mix(s.Get(email), s.Get(tag))); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow().Email.Value).IsEqualTo("guest@example.com"); + Check.That(outcome.GetResultOrThrow().Tag.Value).IsEqualTo("vip"); + } + + #endregion + + #region Value-type arguments (the struct overload) + + [Fact] + public void Value_type_argument_binds_over_its_underlying_type() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField count = bind.Argument("count").From("query", (int?)7).AsRequired(n => Outcome.Success(n)); + + Outcome outcome = bind.New(s => s.Get(count)); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo(7); + } + + [Fact] + public void Missing_value_type_argument_records_required() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField count = bind.Argument("count").From("query", (int?)null).AsRequired(n => Outcome.Success(n)); + + Error inner = Single(bind.New(s => s.Get(count))); + Check.That(inner.Code).IsEqualTo(RequestBindingError.DefaultArgumentRequiredCode); + Check.That(PathOf(inner)).IsEqualTo("count"); + Check.That(SourceOf(inner)).IsEqualTo("query"); + } + + #endregion + + #region List arguments + + [Fact] + public void List_argument_binds_each_element_and_records_a_bad_one_under_its_indexed_path() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField> tags = + bind.ArgumentList("tag").FromQuery(new[] { "ok", "bad tag" }).AsOptional(Tag.Parse); + + Outcome outcome = bind.New(s => new TagList(s.Get(tags))); + + Error inner = Single(outcome); + Check.That(inner.Code).IsEqualTo(RequestBindingError.DefaultArgumentInvalidCode); + Check.That(PathOf(inner)).IsEqualTo("tag[1]"); + Check.That(SourceOf(inner)).IsEqualTo("query"); + } + + [Fact] + public void List_argument_absent_optional_yields_empty_and_records_nothing() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + RequiredField> tags = bind.ArgumentList("tag").FromQuery((IEnumerable?)null).AsOptional(Tag.Parse); + + Outcome outcome = bind.New(s => new TagList(s.Get(tags))); + + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow().Tags).IsEmpty(); + } + + #endregion + + #region Guards + + [Fact] + public void Argument_with_a_null_name_throws() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + + Assert.Throws(() => bind.Argument(null!)); + } + + [Fact] + public void From_with_a_null_source_throws() { + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + + Assert.Throws(() => bind.Argument("email").From(null!, "x")); + } + + [Fact] + public void To_with_a_null_envelope_throws() { + Assert.Throws(() => Bind.Request(null!)); + } + + #endregion + + #region Helpers & fixtures + + private static Error Single(Outcome outcome) => Single(outcome.Error); + private static Error Single(Outcome outcome) => Single(outcome.Error); + private static Error Single(Outcome outcome) => Single(outcome.Error); + + private static Error Single(Error? envelope) { + Check.That(envelope).IsNotNull(); + + return envelope!.InnerErrors.Single(); + } + + private static string? PathOf(Error error) { + error.Context.TryGet(RequestBindingError.ArgumentPathKey, out string? path); + + return path; + } + + private static string? SourceOf(Error error) { + error.Context.TryGet(RequestBindingError.ArgumentSourceKey, out string? source); + + return source; + } + + private sealed record Container(EmailAddress? Email); + + private sealed record Mix(EmailAddress Email, Tag Tag); + + private sealed record TagList(IReadOnlyList Tags); + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index ab069754..663a401f 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -29,9 +29,10 @@ private static BookingRequest Request(string? email = "alice@example.org") { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure( + body.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure( PrimaryPortError.Create(ErrorCode.Create("TEST_PORT_LEVEL_REJECTION"), Any.DiagnosticMessage(), Any.Transience()) .WithPublicMessage(Any.ShortMessage()))); @@ -43,7 +44,8 @@ public void ConverterMayFailWithAPrimaryPortError() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); InfrastructureError foreignFamily = InfrastructureError.Create(Any.ErrorCode(), Any.DiagnosticMessage(), @@ -51,7 +53,7 @@ public void ConverterFailingWithAnotherFamilyIsABug() { .WithPublicMessage(Any.ShortMessage()); InvalidOperationException exception = Assert.Throws( - () => { bind.SimpleProperty(r => r.GuestEmail).AsRequired(_ => Outcome.Failure(foreignFamily)); }); + () => { body.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"); @@ -63,10 +65,11 @@ public void ConverterFailingWithAnotherFamilyIsABug() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) - .AsRequired(_ => Outcome.Failure(BookingDomainError.DateInvalid("raw"))); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) + .AsRequired((_, _) => Outcome.Failure(BookingDomainError.DateInvalid("raw"))); Outcome outcome = bind.New(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); @@ -77,10 +80,11 @@ public void BareNestedFailureIsWrapped() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) - .AsRequired(_ => Outcome.Failure(BookingDomainError.EmailInvalid("raw"))); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsRequired((_, _) => Outcome.Failure(BookingDomainError.EmailInvalid("raw"))); Outcome outcome = bind.New(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); @@ -96,10 +100,11 @@ private static PrimaryPortError LeafPortError() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) - .AsRequired(_ => Outcome.Failure(LeafPortError())); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) + .AsRequired((_, _) => Outcome.Failure(LeafPortError())); Outcome outcome = bind.New(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); @@ -112,10 +117,11 @@ public void BareNestedPrimaryPortErrorLeafIsWrapped() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) - .AsOptionalReference(_ => Outcome.Failure(LeafPortError())); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid) + .AsOptionalReference((_, _) => Outcome.Failure(LeafPortError())); Outcome outcome = bind.New(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); @@ -126,10 +132,11 @@ public void BareOptionalNestedPrimaryPortErrorLeafIsWrapped() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) - .AsRequired(_ => Outcome.Failure(LeafPortError())); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsRequired((_, _) => Outcome.Failure(LeafPortError())); Outcome outcome = bind.New(_ => "never"); Error wrapped = outcome.Error!.InnerErrors.Single(); @@ -144,11 +151,11 @@ public void BareNestedElementPrimaryPortErrorLeafIsWrapped() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, Guests: null)); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) - .AsRequired(g => g.New(_ => new Guest("never", null))); + body.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(); @@ -158,10 +165,10 @@ public void RequiredComplexListMissing() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, Tags: ["ok", null, "not ok"], null)); - bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) @@ -172,12 +179,13 @@ public void NullElementAsFirstFailureOfASimpleList() { [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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, Guests: [new GuestDto(null, null)])); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) - .AsOptional(g => { - RequiredField firstName = g.SimpleProperty(x => x.FirstName).AsRequired(); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid) + .AsOptional((g, dto) => { + PropertySource guest = g.PropertiesOf(dto); + RequiredField firstName = guest.SimpleProperty(x => x.FirstName).AsRequired(); return g.New(s => new Guest(s.Get(firstName), null)); }); @@ -239,15 +247,16 @@ 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); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(new ValueTypeRequest(0, null)); // 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); }); + ArgumentException exception = Assert.Throws(() => { body.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(); + Check.ThatCode(() => body.SimpleProperty(r => r.OptionalCount)).DoesNotThrow(); } #endregion @@ -256,28 +265,35 @@ public void NonNullableValueTypePropertyIsRejected() { [Fact(DisplayName = "Every entry point rejects a null collaborator with ArgumentNullException.")] public void GuardClauses() { - var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(Request()); - Check.ThatCode(() => Bind.PropertiesOf(null!)).Throws(); - Check.ThatCode(() => Bind.PropertiesOf(Request()).FailWith(null!)).Throws(); + Check.ThatCode(() => bind.PropertiesOf(null!)).Throws(); + Check.ThatCode(() => Bind.Request(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(() => body.SimpleProperty(r => r.GuestEmail).AsRequired(null!)).Throws(); + Check.ThatCode(() => body.SimpleProperty(r => r.GuestEmail).AsOptional(null!, "x")).Throws(); + Check.ThatCode(() => body.SimpleProperty(r => r.GuestEmail).AsOptionalReference(null!)).Throws(); + Check.ThatCode(() => body.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).AsOptionalReference(null!)).Throws(); + Check.ThatCode(() => body.ComplexProperty(r => r.Stay).FailWith(null!)).Throws(); + Check.ThatCode(() => body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(null!)).Throws(); + Check.ThatCode(() => body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptionalReference(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(() => body.ListOfSimpleProperties(r => r.Tags).AsRequired(null!)).Throws(); + Check.ThatCode(() => body.ListOfSimpleProperties(r => r.Tags).AsOptional(null!)).Throws(); + Check.ThatCode(() => body.ListOfComplexProperties(r => r.Guests).FailWith(null!)).Throws(); + Check.ThatCode(() => body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(null!)).Throws(); + Check.ThatCode(() => body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(null!)).Throws(); + + // Out-of-DTO argument sources: + Check.ThatCode(() => bind.Argument(null!)).Throws(); + Check.ThatCode(() => bind.ArgumentList(null!)).Throws(); + Check.ThatCode(() => bind.Argument("x").From(null!, "v")).Throws(); + Check.ThatCode(() => bind.ArgumentList("x").From(null!, ["v"])).Throws(); Check.ThatCode(() => new RequestBinderOptions(null!)).Throws(); Check.ThatCode(() => RequestBinderOptions.Default.ArgumentNameProvider.GetArgumentNameFrom(null!)).Throws(); @@ -289,30 +305,32 @@ public void GuardClauses() { [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); + RequestBinder binderA = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource bodyA = binderA.PropertiesOf(Request()); + RequiredField requiredOfA = bodyA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + OptionalReferenceField optRefOfA = bodyA.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + OptionalValueField optValOfA = bodyA.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); - var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequestBinder bind = Bind.Request(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(); + Check.ThatCode(() => bind.New(s => { s.Get((RequiredField)null!); return "x"; })).Throws(); + Check.ThatCode(() => bind.New(s => { s.Get((OptionalReferenceField)null!); return "x"; })).Throws(); + Check.ThatCode(() => bind.New(s => { s.Get((OptionalValueField)null!); return "x"; })).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(); + Check.ThatCode(() => bind.New(s => { s.Get(requiredOfA); return "x"; })).Throws(); + Check.ThatCode(() => bind.New(s => { s.Get(optRefOfA); return "x"; })).Throws(); + Check.ThatCode(() => bind.New(s => { s.Get(optValOfA); return "x"; })).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); + RequestBinder binderA = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource bodyA = binderA.PropertiesOf(Request()); + RequiredField requiredOfA = bodyA.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); InvalidOperationException exception = Assert.Throws( () => { bind.New(s => s.Get(requiredOfA).Value); }); diff --git a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs index 7e2d3719..63d3fe2b 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs @@ -12,30 +12,33 @@ namespace FirstClassErrors.RequestBinder.UnitTests; /// public sealed class BookingEndToEndTests { - private static Outcome BindStay(RequestBinder stay) { + private static Outcome BindStay(RequestBinder binder, StayDto dto) { + PropertySource stay = binder.PropertiesOf(dto); 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))); + return binder.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } - private static Outcome BindGuest(RequestBinder guest) { + private static Outcome BindGuest(RequestBinder binder, GuestDto dto) { + PropertySource guest = binder.PropertiesOf(dto); 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))); + return binder.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).AsOptionalReference(BindStay); - RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); - RequiredField> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + RequestBinder bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + PropertySource body = bind.PropertiesOf(request); + + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = body.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueField maxNights = body.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + OptionalReferenceField stay = body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptionalReference(BindStay); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = body.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), diff --git a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs index b255e098..e79c61a1 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ComplexPropertyBindingTests.cs @@ -8,11 +8,13 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class ComplexPropertyBindingTests { - private static Outcome BindStay(RequestBinder stay) { + private static Outcome BindStay(RequestBinder binder, StayDto dto) { + PropertySource stay = binder.PropertiesOf(dto); + 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))); + return binder.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } private static BookingRequest RequestWith(StayDto? stay) { @@ -21,9 +23,10 @@ private static BookingRequest RequestWith(StayDto? stay) { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))); - RequiredField stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + RequiredField stay = body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); Outcome outcome = bind.New(s => s.Get(stay)); Check.That(outcome.IsSuccess).IsTrue(); @@ -32,9 +35,10 @@ public void RequiredComplexBinds() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(new StayDto("not-a-date", null))); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); @@ -52,9 +56,10 @@ public void NestedFailureSurfacesAsItsEnvelopeWithPrefixedPaths() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(stay: null)); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); Outcome outcome = bind.New(_ => "never"); Error required = outcome.Error!.InnerErrors.Single(); @@ -64,20 +69,23 @@ public void RequiredComplexMissing() { [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).AsOptionalReference(BindStay); + var absent = Bind.Request(BookingEnvelopeError.CommandInvalid); + var absentBody = absent.PropertiesOf(RequestWith(stay: null)); + OptionalReferenceField none = absentBody.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptionalReference(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).AsOptionalReference(BindStay); + var present = Bind.Request(BookingEnvelopeError.CommandInvalid); + var presentBody = present.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))); + OptionalReferenceField some = presentBody.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptionalReference(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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(new StayDto(null, null))); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptionalReference(BindStay); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptionalReference(BindStay); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); diff --git a/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs index 1163c6ad..10f7c66e 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs @@ -7,7 +7,7 @@ namespace FirstClassErrors.RequestBinder.UnitTests; /// -/// The application-wide : it is what +/// The application-wide : it is what /// binds with, configurable once at startup and frozen on first use. These tests inject it through the scoped, /// parallel-safe test seam (OverrideDefaultForTests) so they never mutate the process default — the binder /// suite keeps seeing the built-in default. @@ -18,34 +18,34 @@ private static BookingRequest MissingEmail() { return new BookingRequest(null, "R", null, null, null, null, null); } - private static Error BindMissingEmail(RequestBinderEnvelopeStage start) { - var bind = start.FailWith(BookingEnvelopeError.CommandInvalid); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + private static Error BindMissingEmail(RequestBinder bind) { + var body = bind.PropertiesOf(MissingEmail()); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); return bind.New(_ => "x").Error!.InnerErrors.Single(); } - // ── Bind.PropertiesOf binds with the configured default ─────────────────────────────────────────────── + // ── Bind.Request binds with the configured default ─────────────────────────────────────────────── - [Fact(DisplayName = "Bind.PropertiesOf binds with the configured default options — naming and structural codes — without WithOptions.")] - public void BindPropertiesOfUsesTheConfiguredDefault() { + [Fact(DisplayName = "Bind.Request binds with the configured default options — naming and structural codes — without WithOptions.")] + public void BindRequestUsesTheConfiguredDefault() { var configured = new RequestBinderOptions(new SnakeCaseNameProvider(), RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")), RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))); using (RequestBinderOptions.OverrideDefaultForTests(configured)) { - Error error = BindMissingEmail(Bind.PropertiesOf(MissingEmail())); + Error error = BindMissingEmail(Bind.Request(BookingEnvelopeError.CommandInvalid)); Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_REQUIRED"); Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("guest_email"); } } - [Fact(DisplayName = "Outside the configured scope, Bind.PropertiesOf falls back to the built-in default.")] + [Fact(DisplayName = "Outside the configured scope, Bind.Request falls back to the built-in default.")] public void OutsideScopeFallsBackToBuiltIn() { using (RequestBinderOptions.OverrideDefaultForTests(new RequestBinderOptions(new SnakeCaseNameProvider()))) { } - Error error = BindMissingEmail(Bind.PropertiesOf(MissingEmail())); + Error error = BindMissingEmail(Bind.Request(BookingEnvelopeError.CommandInvalid)); Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("GuestEmail"); @@ -61,7 +61,7 @@ public void WithOptionsWinsOverTheConfiguredDefault() { RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("PERCALL_INVALID"))); using (RequestBinderOptions.OverrideDefaultForTests(appDefault)) { - Error error = BindMissingEmail(Bind.WithOptions(perCall).PropertiesOf(MissingEmail())); + Error error = BindMissingEmail(Bind.WithOptions(perCall).Request(BookingEnvelopeError.CommandInvalid)); Check.That(error.Code.ToString()).IsEqualTo("PERCALL_REQUIRED"); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs index b3de7376..7aaba0ae 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs @@ -8,11 +8,13 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class ListBindingTests { - private static Outcome BindGuest(RequestBinder guest) { + private static Outcome BindGuest(RequestBinder binder, GuestDto dto) { + PropertySource guest = binder.PropertiesOf(dto); + 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))); + return binder.New(s => new Guest(s.Get(firstName), s.Get(email))); } private static BookingRequest RequestWith(IReadOnlyList? tags = null, IReadOnlyList? guests = null) { @@ -21,9 +23,10 @@ private static BookingRequest RequestWith(IReadOnlyList? tags = null, I [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(tags: ["vip", "late-checkout"])); - RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + RequiredField> tags = body.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"); @@ -31,9 +34,10 @@ public void RequiredSimpleListBinds() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(tags: null)); - bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); Outcome outcome = bind.New(_ => "never"); Error required = outcome.Error!.InnerErrors.Single(); @@ -43,9 +47,10 @@ public void RequiredSimpleListMissing() { [Fact(DisplayName = "A required list that is present but empty is valid: it binds an empty list and records nothing — required constrains presence, not element count.")] public void RequiredSimpleListPresentButEmptyBindsEmpty() { - var bind = Bind.PropertiesOf(RequestWith(tags: [])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(tags: [])); - RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); Outcome> outcome = bind.New(s => s.Get(tags)); Check.That(outcome.IsSuccess).IsTrue(); @@ -54,9 +59,10 @@ public void RequiredSimpleListPresentButEmptyBindsEmpty() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(tags: ["ok", "not ok", null, "fine"])); - bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.Error!.InnerErrors).HasSize(2); @@ -68,9 +74,10 @@ public void EveryFailingElementIsCollected() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(tags: null)); - RequiredField> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); Outcome> outcome = bind.New(s => s.Get(tags)); Check.That(outcome.IsSuccess).IsTrue(); @@ -79,11 +86,11 @@ public void OptionalListAbsentBindsEmpty() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)])); RequiredField> guests = - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + body.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"); @@ -92,10 +99,11 @@ public void RequiredComplexListBinds() { [Fact(DisplayName = "A required complex list that is present but empty is valid: it binds an empty list and records nothing.")] public void RequiredComplexListPresentButEmptyBindsEmpty() { - var bind = Bind.PropertiesOf(RequestWith(guests: [])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(guests: [])); RequiredField> guests = - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); Outcome> outcome = bind.New(s => s.Get(guests)); Check.That(outcome.IsSuccess).IsTrue(); @@ -104,10 +112,10 @@ public void RequiredComplexListPresentButEmptyBindsEmpty() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), new GuestDto(null, "nope"), new GuestDto(null, null)])); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.Error!.InnerErrors).HasSize(2); @@ -123,10 +131,10 @@ public void FailingComplexElementsRecordTheirEnvelopes() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), null])); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); Outcome outcome = bind.New(_ => "never"); Error required = outcome.Error!.InnerErrors.Single(); @@ -136,10 +144,10 @@ public void NullComplexElementIsRequired() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(guests: [null, new GuestDto(null, "nope")])); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); Outcome outcome = bind.New(_ => "never"); @@ -158,10 +166,11 @@ public void NullComplexElementDoesNotHideLaterFailures() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(RequestWith(guests: null)); RequiredField> guests = - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsOptional(BindGuest); Outcome> outcome = bind.New(s => s.Get(guests)); Check.That(outcome.IsSuccess).IsTrue(); @@ -170,11 +179,10 @@ public void OptionalComplexListAbsentBindsEmpty() { [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.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())) - .PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, null, null, Guests: [new GuestDto(null, "nope")])) - .FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, null, null, Guests: [new GuestDto(null, "nope")])); - bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest); + body.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"); diff --git a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs index 014106a9..cfd40384 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/RequestBinderTests.cs @@ -12,11 +12,12 @@ namespace FirstClassErrors.RequestBinder.UnitTests; public sealed class RequestBinderTests { - private static Outcome BindStay(RequestBinder stay) { + private static Outcome BindStay(RequestBinder binder, StayDto dto) { + PropertySource stay = binder.PropertiesOf(dto); 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))); + return binder.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } private static readonly ErrorCode CheckOutNotAfterCheckIn = ErrorCode.Create("TEST_CHECKOUT_NOT_AFTER_CHECKIN"); @@ -32,9 +33,9 @@ private static Outcome AssembleStay(BookingDate checkIn, BookingDate check [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", "EUR", null, null, null, null)); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); int assembled = 0; Outcome outcome = bind.New(s => { @@ -49,9 +50,9 @@ public void NewAssemblesOnceOnSuccess() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); bool assembled = false; Outcome outcome = bind.New(_ => { @@ -66,10 +67,10 @@ public void NewNeverAssemblesOnFailure() { [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); + var bind = Bind.Request(BookingEnvelopeError.StayInvalid); + var body = bind.PropertiesOf(new StayDto("2026-08-10", "2026-08-14")); + RequiredField checkIn = body.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = body.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); Outcome outcome = bind.Create(s => AssembleStay(s.Get(checkIn), s.Get(checkOut))); @@ -79,10 +80,10 @@ public void CreateFlattensFactorySuccess() { [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); + var bind = Bind.Request(BookingEnvelopeError.StayInvalid); + var body = bind.PropertiesOf(new StayDto("2026-08-14", "2026-08-10")); + RequiredField checkIn = body.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = body.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); Outcome outcome = bind.Create(s => AssembleStay(s.Get(checkIn), s.Get(checkOut))); @@ -94,10 +95,10 @@ public void CreateReturnsFactoryFailureAsIs() { [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); + var bind = Bind.Request(BookingEnvelopeError.StayInvalid); + var body = bind.PropertiesOf(new StayDto(null, "2026-08-14")); + RequiredField checkIn = body.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = body.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); bool factoryCalled = false; Outcome outcome = bind.Create(s => { @@ -114,12 +115,12 @@ public void CreateNeverCallsFactoryOnBindingFailure() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("nope", null, "EURO", null, null, null, null)); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - bind.SimpleProperty(r => r.Reference).AsRequired(); - bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.Reference).AsRequired(); + body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.Error!.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID"); @@ -132,17 +133,18 @@ public void CollectsEveryFailure() { [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(); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("nope", null, "EURO", "-1", new StayDto(null, "bad"), ["a b", null], [null, new GuestDto(null, "x")])); + + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.Reference).AsRequired(); + body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + body.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + body.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired((g, dto) => { + PropertySource guestBody = g.PropertiesOf(dto); + RequiredField firstName = guestBody.SimpleProperty(x => x.FirstName).AsRequired(); return g.New(s => new Guest(s.Get(firstName), null)); }); @@ -154,36 +156,36 @@ public void FullyInvalidRequestThrowsNothing() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, null)); - Check.ThatCode(() => bind.SimpleProperty(r => r.GuestEmail) + Check.ThatCode(() => body.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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("a@b.c", null, null, null, null, null, null)); // 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()); }); + () => { body.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)) + Check.ThatCode(() => body.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.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())) - .PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, new StayDto(null, null), null, null)) - .FailWith(BookingEnvelopeError.CommandInvalid); + .Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest("a@b.c", "REF-1", null, null, new StayDto(null, null), null, null)); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.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"); @@ -198,8 +200,9 @@ public void ConfiguredEntryPointIsReusableAcrossRequests() { ConfiguredBind bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNameProvider())); string? PathOfMissingEmail(BookingRequest request) { - var b = bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); - b.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + var b = bind.Request(BookingEnvelopeError.CommandInvalid); + var body = b.PropertiesOf(request); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); return BindingAssertions.ArgumentPathOf(b.New(_ => "never").Error!.InnerErrors.Single()); } @@ -210,10 +213,10 @@ public void ConfiguredEntryPointIsReusableAcrossRequests() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Outcome outcome = bind.New(_ => "never"); var required = (InfrastructureError)outcome.Error!.InnerErrors.Single(); diff --git a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs index 0acb03a7..50cff4ac 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/SimplePropertyBindingTests.cs @@ -16,9 +16,10 @@ private static BookingRequest Request(string? email = "alice@example.org", [Fact(DisplayName = "A required property that is present and valid binds its value.")] public void RequiredPresentAndValidBinds() { - var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request()); - RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Outcome outcome = bind.New(s => s.Get(email).Value); Check.That(outcome.IsSuccess).IsTrue(); @@ -27,9 +28,10 @@ public void RequiredPresentAndValidBinds() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(email: null)); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); @@ -42,9 +44,10 @@ public void RequiredMissingFails() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(email: "not-an-email")); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); @@ -57,12 +60,14 @@ public void RequiredInvalidWrapsTheConverterError() { [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(); + var present = Bind.Request(BookingEnvelopeError.CommandInvalid); + var presentBody = present.PropertiesOf(Request()); + RequiredField reference = presentBody.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(); + var missing = Bind.Request(BookingEnvelopeError.CommandInvalid); + var missingBody = missing.PropertiesOf(new BookingRequest(null, null, null, null, null, null, null)); + missingBody.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"); @@ -70,20 +75,23 @@ public void RequiredWithoutConversion() { [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"); + var provided = Bind.Request(BookingEnvelopeError.CommandInvalid); + var providedBody = provided.PropertiesOf(Request(currency: "USD")); + RequiredField providedCurrency = providedBody.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"); + var absent = Bind.Request(BookingEnvelopeError.CommandInvalid); + var absentBody = absent.PropertiesOf(Request(currency: null)); + RequiredField defaulted = absentBody.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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(currency: "EURO")); - bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); @@ -92,10 +100,11 @@ public void OptionalPresentButInvalidRecords() { [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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(currency: null)); InvalidOperationException exception = Assert.Throws( - () => { bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "NOT-A-CURRENCY"); }); + () => { body.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. @@ -104,20 +113,23 @@ public void InvalidFallbackIsABug() { [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); + var absent = Bind.Request(BookingEnvelopeError.CommandInvalid); + var absentBody = absent.PropertiesOf(Request(email: null)); + OptionalReferenceField none = absentBody.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); + var present = Bind.Request(BookingEnvelopeError.CommandInvalid); + var presentBody = present.PropertiesOf(Request()); + OptionalReferenceField some = presentBody.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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(email: "nope")); - bind.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsOptionalReference(EmailAddress.Parse); Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); @@ -127,24 +139,27 @@ public void OptionalReferencePresentButInvalidRecords() { [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); + var absent = Bind.Request(BookingEnvelopeError.CommandInvalid); + var absentBody = absent.PropertiesOf(Request(nights: null)); + OptionalValueField none = absentBody.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); + var present = Bind.Request(BookingEnvelopeError.CommandInvalid); + var presentBody = present.PropertiesOf(Request(nights: "5")); + OptionalValueField some = presentBody.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); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(nights: "-2")); - bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + body.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs index 45a8443d..fe0f5954 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs @@ -23,11 +23,13 @@ private static BookingRequest Request(string? guestEmail = "a@b.c", StayDto? sta return new BookingRequest(guestEmail, "REF-1", null, null, stay, tags, null); } - private static Outcome BindStay(RequestBinder stay) { + private static Outcome BindStay(RequestBinder binder, StayDto dto) { + var stay = binder.PropertiesOf(dto); + 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))); + return binder.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); } // ── Defaults are unchanged ──────────────────────────────────────────────────────────────────────────── @@ -50,9 +52,10 @@ public void OptionsDefaultToTheStructuralCodes() { [Fact(DisplayName = "By default, a missing required argument still records REQUEST_ARGUMENT_REQUIRED.")] public void DefaultCodeStillRaisedWhenNotOverridden() { - var bind = Bind.PropertiesOf(Request(guestEmail: null)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(guestEmail: null)); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Check.That(bind.New(_ => "x").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); } @@ -61,9 +64,10 @@ public void DefaultCodeStillRaisedWhenNotOverridden() { [Fact(DisplayName = "A configured code replaces REQUEST_ARGUMENT_REQUIRED on a missing scalar, keeping the path.")] public void CustomRequiredCodeOnMissingScalar() { - var bind = Bind.WithOptions(CustomCodes()).PropertiesOf(Request(guestEmail: null)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.WithOptions(CustomCodes()).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(guestEmail: null)); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Error error = bind.New(_ => "x").Error!.InnerErrors.Single(); Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_REQUIRED"); @@ -72,9 +76,10 @@ public void CustomRequiredCodeOnMissingScalar() { [Fact(DisplayName = "A configured code replaces REQUEST_ARGUMENT_INVALID on an invalid scalar, still wrapping the converter's cause.")] public void CustomInvalidCodeOnInvalidScalar() { - var bind = Bind.WithOptions(CustomCodes()).PropertiesOf(Request(guestEmail: "not-an-email")).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.WithOptions(CustomCodes()).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(guestEmail: "not-an-email")); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Error error = bind.New(_ => "x").Error!.InnerErrors.Single(); Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_INVALID"); @@ -83,11 +88,10 @@ public void CustomInvalidCodeOnInvalidScalar() { [Fact(DisplayName = "Configured codes flow through list elements: a null element uses the required code, an invalid one the invalid code.")] public void CustomCodesOnListElements() { - var bind = Bind.WithOptions(CustomCodes()) - .PropertiesOf(Request(tags: ["ok", null, "bad tag"])) - .FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.WithOptions(CustomCodes()).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(tags: ["ok", null, "bad tag"])); - bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); IReadOnlyList errors = bind.New(_ => "x").Error!.InnerErrors; Check.That(errors.Select(e => e.Code.ToString())).ContainsExactly("ACME_ARGUMENT_REQUIRED", "ACME_ARGUMENT_INVALID"); @@ -96,9 +100,10 @@ public void CustomCodesOnListElements() { [Fact(DisplayName = "A missing complex property records the configured required code under its path.")] public void CustomRequiredCodeOnMissingComplex() { - var bind = Bind.WithOptions(CustomCodes()).PropertiesOf(Request(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.WithOptions(CustomCodes()).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(stay: null)); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); Error error = bind.New(_ => "x").Error!.InnerErrors.Single(); Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_REQUIRED"); @@ -107,11 +112,10 @@ public void CustomRequiredCodeOnMissingComplex() { [Fact(DisplayName = "A nested binder inherits the configured codes: the nested envelope's inner failures use them under prefixed paths.")] public void NestedBinderInheritsCustomCodes() { - var bind = Bind.WithOptions(CustomCodes()) - .PropertiesOf(Request(stay: new StayDto("not-a-date", null))) - .FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.WithOptions(CustomCodes()).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(stay: new StayDto("not-a-date", null))); - bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); + body.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay); Error stayEnvelope = bind.New(_ => "x").Error!.InnerErrors.Single(e => e.Code.ToString() == "TEST_STAY_INVALID"); // CheckIn "not-a-date" is invalid; CheckOut is missing — both under the inherited codes, both prefixed with "Stay.". @@ -123,13 +127,15 @@ public void NestedBinderInheritsCustomCodes() { [Fact(DisplayName = "A consumer branches on a binder failure via ErrorCode equality — the exposed default, or its own overridden code.")] public void ConsumerBranchesSymbolically() { - var byDefault = Bind.PropertiesOf(Request(guestEmail: null)).FailWith(BookingEnvelopeError.CommandInvalid); - byDefault.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + var byDefault = Bind.Request(BookingEnvelopeError.CommandInvalid); + var byDefaultBody = byDefault.PropertiesOf(Request(guestEmail: null)); + byDefaultBody.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Error defaulted = byDefault.New(_ => "x").Error!.InnerErrors.Single(); Check.That(defaulted.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); - var overridden = Bind.WithOptions(CustomCodes()).PropertiesOf(Request(guestEmail: null)).FailWith(BookingEnvelopeError.CommandInvalid); - overridden.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + var overridden = Bind.WithOptions(CustomCodes()).Request(BookingEnvelopeError.CommandInvalid); + var overriddenBody = overridden.PropertiesOf(Request(guestEmail: null)); + overriddenBody.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); Error owned = overridden.New(_ => "x").Error!.InnerErrors.Single(); Check.That(owned.Code == AcmeRequired).IsTrue(); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs index 53db91c0..1c8348ab 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs @@ -21,20 +21,21 @@ private static BookingRequest MissingEmail() { } private static Error BindMissingEmail(RequestBinderOptions? options = null) { - RequestBinderEnvelopeStage start = options is null - ? Bind.PropertiesOf(MissingEmail()) - : Bind.WithOptions(options).PropertiesOf(MissingEmail()); + RequestBinder bind = options is null + ? Bind.Request(BookingEnvelopeError.CommandInvalid) + : Bind.WithOptions(options).Request(BookingEnvelopeError.CommandInvalid); - var bind = start.FailWith(BookingEnvelopeError.CommandInvalid); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + var body = bind.PropertiesOf(MissingEmail()); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); return bind.New(_ => "x").Error!.InnerErrors.Single(); } private static Error BindInvalidEmail(RequestBinderOptions options) { var request = new BookingRequest("not-an-email", "REF-1", null, null, null, null, null); - var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); - bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + var bind = Bind.WithOptions(options).Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(request); + body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); return bind.New(_ => "x").Error!.InnerErrors.Single(); } diff --git a/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs b/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs index 2cd000c9..db7c6afc 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/ValueTypePropertyBindingTests.cs @@ -53,9 +53,10 @@ private static ValueTypeRequest Request(int? quantity = 5, bool? flag = true, st [Fact(DisplayName = "A nullable value-type property binds via a method-group converter over its underlying type.")] public void ValueTypeRequiredBindsViaMethodGroup() { - var bind = Bind.PropertiesOf(Request(quantity: 5)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantity: 5)); - RequiredField qty = bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); + RequiredField qty = body.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); Outcome outcome = bind.New(s => s.Get(qty).Value); Check.That(outcome.IsSuccess).IsTrue(); @@ -64,9 +65,10 @@ public void ValueTypeRequiredBindsViaMethodGroup() { [Fact(DisplayName = "A required value-type property that is absent records REQUEST_ARGUMENT_REQUIRED with its path.")] public void ValueTypeRequiredMissingRecords() { - var bind = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantity: null)); - bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); + body.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); Outcome outcome = bind.New(_ => "never"); Check.That(outcome.IsFailure).IsTrue(); @@ -77,9 +79,10 @@ public void ValueTypeRequiredMissingRecords() { [Fact(DisplayName = "A present-but-invalid value-type property records REQUEST_ARGUMENT_INVALID wrapping the converter error.")] public void ValueTypeRequiredInvalidRecords() { - var bind = Bind.PropertiesOf(Request(quantity: -1)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantity: -1)); - bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); + body.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); Outcome outcome = bind.New(_ => "never"); Error invalid = outcome.Error!.InnerErrors.Single(); @@ -89,29 +92,33 @@ public void ValueTypeRequiredInvalidRecords() { [Fact(DisplayName = "AsOptionalValue on a value-type property yields the value when present and a real null when absent.")] public void ValueTypeOptionalValue() { - var present = Bind.PropertiesOf(Request(quantity: 7)).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalValueField some = present.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); + var present = Bind.Request(BookingEnvelopeError.CommandInvalid); + var presentBody = present.PropertiesOf(Request(quantity: 7)); + OptionalValueField some = presentBody.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); Check.That(present.New(s => s.Get(some)!.Value.Value).GetResultOrThrow()).IsEqualTo(7); - var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); - OptionalValueField none = absent.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); + var absent = Bind.Request(BookingEnvelopeError.CommandInvalid); + var absentBody = absent.PropertiesOf(Request(quantity: null)); + OptionalValueField none = absentBody.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); } [Fact(DisplayName = "AsOptional with a fallback converts the underlying-typed fallback when the value-type property is absent.")] public void ValueTypeOptionalWithFallback() { - var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); + var absent = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = absent.PropertiesOf(Request(quantity: null)); - RequiredField defaulted = absent.SimpleProperty(r => r.Quantity).AsOptional(PositiveQty.From, 1); + RequiredField defaulted = body.SimpleProperty(r => r.Quantity).AsOptional(PositiveQty.From, 1); Check.That(absent.New(s => s.Get(defaulted).Value).GetResultOrThrow()).IsEqualTo(1); } [Fact(DisplayName = "The underlying-type inference is not int-specific: a bool? property binds the same way.")] public void ValueTypeWorksForBool() { - var bind = Bind.PropertiesOf(Request(flag: true)).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(flag: true)); - RequiredField consent = bind.SimpleProperty(r => r.Flag).AsRequired(Consent.From); + RequiredField consent = body.SimpleProperty(r => r.Flag).AsRequired(Consent.From); Check.That(bind.New(s => s.Get(consent).Given).GetResultOrThrow()).IsTrue(); } @@ -120,9 +127,10 @@ public void ValueTypeWorksForBool() { [Fact(DisplayName = "A list of nullable value types binds each element via a method-group converter over the underlying type.")] public void ValueTypeListRequiredBinds() { - var bind = Bind.PropertiesOf(Request(quantities: [1, 2, 3])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantities: [1, 2, 3])); - RequiredField> qtys = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + RequiredField> qtys = body.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); Outcome outcome = bind.New(s => s.Get(qtys).Sum(q => q.Value)); Check.That(outcome.IsSuccess).IsTrue(); @@ -131,9 +139,10 @@ public void ValueTypeListRequiredBinds() { [Fact(DisplayName = "A null element of a value-type list records REQUEST_ARGUMENT_REQUIRED under its indexed path.")] public void ValueTypeListNullElementRecords() { - var bind = Bind.PropertiesOf(Request(quantities: [1, null, 3])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantities: [1, null, 3])); - bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + body.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); Error required = bind.New(_ => "never").Error!.InnerErrors.Single(); Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); @@ -142,9 +151,10 @@ public void ValueTypeListNullElementRecords() { [Fact(DisplayName = "An invalid element of a value-type list records REQUEST_ARGUMENT_INVALID under its indexed path.")] public void ValueTypeListInvalidElementRecords() { - var bind = Bind.PropertiesOf(Request(quantities: [1, -2, 3])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantities: [1, -2, 3])); - bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + body.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); @@ -153,20 +163,23 @@ public void ValueTypeListInvalidElementRecords() { [Fact(DisplayName = "An optional value-type list that is absent yields an empty list and records nothing; a required one records REQUEST_ARGUMENT_REQUIRED.")] public void ValueTypeListOptionalVsRequiredWhenAbsent() { - var optional = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid); - RequiredField> empty = optional.ListOfSimpleProperties(r => r.Quantities).AsOptional(PositiveQty.From); + var optional = Bind.Request(BookingEnvelopeError.CommandInvalid); + var optionalBody = optional.PropertiesOf(Request(quantities: null)); + RequiredField> empty = optionalBody.ListOfSimpleProperties(r => r.Quantities).AsOptional(PositiveQty.From); Check.That(optional.New(s => s.Get(empty).Count).GetResultOrThrow()).IsEqualTo(0); - var required = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid); - required.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + var required = Bind.Request(BookingEnvelopeError.CommandInvalid); + var requiredBody = required.PropertiesOf(Request(quantities: null)); + requiredBody.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); Check.That(required.New(_ => "never").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); } [Fact(DisplayName = "A required value-type list that is present but empty is valid: it binds an empty list and records nothing.")] public void RequiredValueTypeListPresentButEmptyBindsEmpty() { - var bind = Bind.PropertiesOf(Request(quantities: [])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(quantities: [])); - RequiredField> quantities = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); + RequiredField> quantities = body.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); Outcome> outcome = bind.New(s => s.Get(quantities)); Check.That(outcome.IsSuccess).IsTrue(); @@ -177,27 +190,30 @@ public void RequiredValueTypeListPresentButEmptyBindsEmpty() { [Fact(DisplayName = "A string property still resolves to the reference overload — no ambiguity introduced by the value-type overload.")] public void StringPropertyStillBindsViaReferenceOverload() { - var bind = Bind.PropertiesOf(Request(note: "a@b.c")).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(note: "a@b.c")); - RequiredField email = bind.SimpleProperty(r => r.Note).AsRequired(EmailAddress.Parse); + RequiredField email = body.SimpleProperty(r => r.Note).AsRequired(EmailAddress.Parse); Check.That(bind.New(s => s.Get(email).Value).GetResultOrThrow()).IsEqualTo("a@b.c"); } [Fact(DisplayName = "A list of strings still resolves to the reference list overload.")] public void StringListStillBindsViaReferenceOverload() { - var bind = Bind.PropertiesOf(Request(notes: ["a", "b"])).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request(notes: ["a", "b"])); - RequiredField> tags = bind.ListOfSimpleProperties(r => r.Notes).AsOptional(Tag.Parse); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Notes).AsOptional(Tag.Parse); Check.That(bind.New(s => s.Get(tags).Count).GetResultOrThrow()).IsEqualTo(2); } [Fact(DisplayName = "A non-nullable value-type property still throws ArgumentException — the value-type overload does not bypass the guard.")] public void NonNullableValueTypeStillThrows() { - var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); + var bind = Bind.Request(BookingEnvelopeError.CommandInvalid); + var body = bind.PropertiesOf(Request()); - Check.ThatCode(() => bind.SimpleProperty(r => r.Count)).Throws(); + Check.ThatCode(() => body.SimpleProperty(r => r.Count)).Throws(); } } diff --git a/FirstClassErrors.RequestBinder.Usage/Binding/BinderShowcase.cs b/FirstClassErrors.RequestBinder.Usage/Binding/BinderShowcase.cs index 4e5c193b..ee0d0891 100644 --- a/FirstClassErrors.RequestBinder.Usage/Binding/BinderShowcase.cs +++ b/FirstClassErrors.RequestBinder.Usage/Binding/BinderShowcase.cs @@ -40,9 +40,10 @@ public static class BinderShowcase { /// and binds an empty list — a required list constrains presence, not element count. /// public static Outcome> BindTagsAsRequired(BookingRequest request) { - RequestBinder binder = Bind.PropertiesOf(request).FailWith(PlaceBookingError.CommandInvalid); + RequestBinder binder = Bind.Request(PlaceBookingError.CommandInvalid); + PropertySource body = binder.PropertiesOf(request); - RequiredField> tags = binder.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse); return binder.New(s => s.Get(tags)); } @@ -53,10 +54,11 @@ public static Outcome> BindTagsAsRequired(BookingRequest requ /// still records under its (prefixed / indexed) path. /// public static Outcome BindOptionalSections(BookingRequest request) { - RequestBinder binder = Bind.PropertiesOf(request).FailWith(PlaceBookingError.CommandInvalid); + RequestBinder binder = Bind.Request(PlaceBookingError.CommandInvalid); + PropertySource body = binder.PropertiesOf(request); - OptionalReferenceField stay = binder.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsOptionalReference(BookingBinder.BindStay); - RequiredField> guests = binder.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsOptional(BookingBinder.BindGuest); + OptionalReferenceField stay = body.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsOptionalReference(BookingBinder.BindStay); + RequiredField> guests = body.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsOptional(BookingBinder.BindGuest); return binder.New(s => new OptionalSections(s.Get(stay), s.Get(guests))); } @@ -67,12 +69,12 @@ public static Outcome BindOptionalSections(BookingRequest requ /// guest_email — the key a snake_case client actually sent — rather than the C# property name. /// public static Outcome BindGuestEmailWithSnakeCaseNames(BookingRequest request) { - RequestBinder binder = + RequestBinder binder = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseArgumentNames())) - .PropertiesOf(request) - .FailWith(PlaceBookingError.CommandInvalid); + .Request(PlaceBookingError.CommandInvalid); + PropertySource body = binder.PropertiesOf(request); - RequiredField email = binder.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); return binder.New(s => s.Get(email)); } @@ -89,12 +91,10 @@ public static Outcome BindGuestEmailWithCustomStructuralCodes(Book HotelApiArgumentRequired, HotelApiArgumentInvalid); - RequestBinder binder = - Bind.WithOptions(options) - .PropertiesOf(request) - .FailWith(PlaceBookingError.CommandInvalid); + RequestBinder binder = Bind.WithOptions(options).Request(PlaceBookingError.CommandInvalid); + PropertySource body = binder.PropertiesOf(request); - RequiredField email = binder.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); return binder.New(s => s.Get(email)); } @@ -115,10 +115,11 @@ public static bool IsMissingArgument(Error error, ErrorCode argumentRequiredCode /// the fix. Returns true to confirm the guard tripped. /// public static bool NonNullableValueTypeGuardTrips() { - RequestBinder binder = Bind.PropertiesOf(new NonNullableProbe(0)).FailWith(PlaceBookingError.CommandInvalid); + RequestBinder binder = Bind.Request(PlaceBookingError.CommandInvalid); + PropertySource probe = binder.PropertiesOf(new NonNullableProbe(0)); try { - binder.SimpleProperty(p => p.Nights); + probe.SimpleProperty(p => p.Nights); return false; } catch (ArgumentException) { diff --git a/FirstClassErrors.RequestBinder.Usage/Binding/BookingBinder.cs b/FirstClassErrors.RequestBinder.Usage/Binding/BookingBinder.cs index bb42b9cc..5b542365 100644 --- a/FirstClassErrors.RequestBinder.Usage/Binding/BookingBinder.cs +++ b/FirstClassErrors.RequestBinder.Usage/Binding/BookingBinder.cs @@ -23,25 +23,26 @@ public static class BookingBinder { /// with each argument's full (indexed, prefixed) path. Raises no exception on the invalid-input path. /// public static Outcome BindBooking(BookingRequest request) { - RequestBinder binder = Bind.PropertiesOf(request).FailWith(PlaceBookingError.CommandInvalid); + RequestBinder binder = Bind.Request(PlaceBookingError.CommandInvalid); + PropertySource body = binder.PropertiesOf(request); // Scalars: required + converter, required + raw (presence only), optional + fallback. - RequiredField email = binder.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); - RequiredField reference = binder.SimpleProperty(r => r.Reference).AsRequired(); - RequiredField currency = binder.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = body.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); // Value-type scalars: a nullable value-type property bound over its underlying int; required, and optional // (a real Nullable when absent — never default(NightCount)). - RequiredField nights = binder.SimpleProperty(r => r.Nights).AsRequired(NightCount.From); - OptionalValueField maxNights = binder.SimpleProperty(r => r.MaxNights).AsOptionalValue(NightCount.From); + RequiredField nights = body.SimpleProperty(r => r.Nights).AsRequired(NightCount.From); + OptionalValueField maxNights = body.SimpleProperty(r => r.MaxNights).AsOptionalValue(NightCount.From); // Complex (nested) property: a required sub-object bound by a nested binder under its own envelope. - RequiredField stay = binder.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField stay = body.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); // Lists: of simple reference elements (optional), of value-type elements (required), of complex elements (required). - RequiredField> tags = binder.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); - RequiredField> rooms = binder.ListOfSimpleProperties(r => r.RoomNumbers).AsRequired(RoomNumber.From); - RequiredField> guests = binder.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsRequired(BindGuest); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> rooms = body.ListOfSimpleProperties(r => r.RoomNumbers).AsRequired(RoomNumber.From); + RequiredField> guests = body.ListOfComplexProperties(r => r.Guests).FailWith(PlaceBookingError.GuestInvalid).AsRequired(BindGuest); // Total assembler: runs once, only when no failure was recorded. return binder.New(s => new PlaceBookingCommand( @@ -61,11 +62,13 @@ public static Outcome BindBooking(BookingRequest request) { /// cross-field rule (check-out strictly after check-in) — which no single field can check — is enforced and /// flattened. Each date is reported under a Stay.-prefixed path. Shared with . /// - internal static Outcome BindStay(RequestBinder stay) { + internal static Outcome BindStay(RequestBinder binder, StayDto dto) { + PropertySource stay = binder.PropertiesOf(dto); + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); - return stay.Create(s => Stay.Create(s.Get(checkIn), s.Get(checkOut))); + return binder.Create(s => Stay.Create(s.Get(checkIn), s.Get(checkOut))); } /// @@ -73,11 +76,13 @@ internal static Outcome BindStay(RequestBinder stay) { /// New terminal. Each field is reported under an indexed path such as Guests[1].FirstName. Shared /// with . /// - internal static Outcome BindGuest(RequestBinder guest) { + internal static Outcome BindGuest(RequestBinder binder, GuestDto dto) { + PropertySource guest = binder.PropertiesOf(dto); + 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))); + return binder.New(s => new Guest(s.Get(firstName), s.Get(email))); } #endregion diff --git a/FirstClassErrors.RequestBinder/ArgumentListSource.cs b/FirstClassErrors.RequestBinder/ArgumentListSource.cs new file mode 100644 index 00000000..3d5bd423 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ArgumentListSource.cs @@ -0,0 +1,59 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A named out-of-DTO list argument awaiting its source and values: the stage between +/// and the per-element value-object conversion. State where the +/// values come from and supply them with — or a host +/// helper such as FromQuery — then bind them with AsRequired / AsOptional. Each failing element +/// is recorded under its indexed path (tag[2]). +/// +public sealed class ArgumentListSource { + + #region Fields declarations + + private readonly RequestBinding _binding; + private readonly string _argumentPath; + + #endregion + + #region Constructors declarations + + internal ArgumentListSource(RequestBinding binding, string argumentPath) { + _binding = binding; + _argumentPath = argumentPath; + } + + #endregion + + /// + /// Supplies the list argument's provenance and values: a null collection is treated as absent + /// (present-but-empty is a valid empty list), and a null element records the required-argument failure + /// under its indexed path. + /// + /// The raw element type of the supplied values. + /// The provenance label recorded in each failing element's context (for example "query"). + /// The raw values; null means the argument was absent. + /// The converter stage offering AsRequired / AsOptional. + /// Thrown when is null. + public ListOfSimplePropertiesConverter From(string source, IEnumerable? values) { + if (source is null) { throw new ArgumentNullException(nameof(source)); } + + return new ListOfSimplePropertiesConverter(_binding, _argumentPath, values, values is null, source); + } + + /// + /// Supplies the provenance and values of a value-type list argument (e.g. a repeated int? query + /// parameter), surfacing the underlying non-nullable element type to the converter. + /// + /// The underlying (non-nullable) value type of the supplied elements. + /// The provenance label recorded in each failing element's context (for example "query"). + /// The raw values; null means the argument was absent. + /// The converter stage offering AsRequired / AsOptional. + /// Thrown when is null. + public ListOfSimpleValuePropertiesConverter From(string source, IEnumerable? values) where TArgument : struct { + if (source is null) { throw new ArgumentNullException(nameof(source)); } + + return new ListOfSimpleValuePropertiesConverter(_binding, _argumentPath, values, values is null, source); + } + +} diff --git a/FirstClassErrors.RequestBinder/ArgumentSource.cs b/FirstClassErrors.RequestBinder/ArgumentSource.cs new file mode 100644 index 00000000..17f7228b --- /dev/null +++ b/FirstClassErrors.RequestBinder/ArgumentSource.cs @@ -0,0 +1,65 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// A named out-of-DTO argument awaiting its source and value: the stage between +/// and the value-object conversion. State where the value comes from +/// and supply it with — or a host helper such as +/// FromRoute / FromQuery — then bind it with AsRequired / AsOptional on the returned +/// converter, exactly as a DTO property is bound. +/// +/// +/// The source is a provenance label ("route", "query", "header", …) captured in the failure's +/// context for diagnostics; it does not appear in the argument's error path, which is the name given to +/// . +/// +public sealed class ArgumentSource { + + #region Fields declarations + + private readonly RequestBinding _binding; + private readonly string _argumentPath; + + #endregion + + #region Constructors declarations + + internal ArgumentSource(RequestBinding binding, string argumentPath) { + _binding = binding; + _argumentPath = argumentPath; + } + + #endregion + + /// + /// Supplies the argument's provenance and value: a value that is null is treated as absent (the + /// AsRequired / AsOptional variant chosen next decides how absence is handled). + /// + /// The raw type of the supplied value. + /// The provenance label recorded in the failure's context (for example "route"). + /// The raw value; null means the argument was absent. + /// The converter stage offering AsRequired / AsOptional and their variants. + /// Thrown when is null. + public SimplePropertyConverter From(string source, TArgument? value) { + if (source is null) { throw new ArgumentNullException(nameof(source)); } + + return new SimplePropertyConverter(_binding, _argumentPath, value, value is null, source); + } + + /// + /// Supplies the provenance and value of a value-type argument (e.g. a Guid? route identifier), + /// surfacing the underlying non-nullable type to the converter — the value-type counterpart of + /// , existing for the same reason as the value-type + /// SimpleProperty overload. + /// + /// The underlying (non-nullable) value type of the supplied value. + /// The provenance label recorded in the failure's context (for example "route"). + /// The raw value; null means the argument was absent. + /// The converter stage offering AsRequired / AsOptional and their variants. + /// Thrown when is null. + public SimplePropertyConverter From(string source, TArgument? value) where TArgument : struct { + if (source is null) { throw new ArgumentNullException(nameof(source)); } + + return new SimplePropertyConverter(_binding, _argumentPath, value is null ? default : value.Value, value is null, source); + } + +} diff --git a/FirstClassErrors.RequestBinder/ArgumentSourceExtensions.cs b/FirstClassErrors.RequestBinder/ArgumentSourceExtensions.cs new file mode 100644 index 00000000..e1e137c1 --- /dev/null +++ b/FirstClassErrors.RequestBinder/ArgumentSourceExtensions.cs @@ -0,0 +1,108 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// Host-agnostic provenance shortcuts over and +/// : FromRoute(v) is exactly +/// From("route", v). They only tag a provenance label on an already-extracted value — they carry no +/// dependency on any web framework, so they live in the core. A host integration package may add richer helpers that +/// extract the value from the incoming request itself. +/// +public static class ArgumentSourceExtensions { + + #region Statics members declarations + + /// Binds an argument sourced from the route (From("route", value)). + public static SimplePropertyConverter FromRoute(this ArgumentSource argument, TArgument? value) { + return Guarded(argument).From("route", value); + } + + /// Binds a value-type argument sourced from the route (From("route", value)). + public static SimplePropertyConverter FromRoute(this ArgumentSource argument, TArgument? value) where TArgument : struct { + return Guarded(argument).From("route", value); + } + + /// Binds an argument sourced from the query string (From("query", value)). + public static SimplePropertyConverter FromQuery(this ArgumentSource argument, TArgument? value) { + return Guarded(argument).From("query", value); + } + + /// Binds a value-type argument sourced from the query string (From("query", value)). + public static SimplePropertyConverter FromQuery(this ArgumentSource argument, TArgument? value) where TArgument : struct { + return Guarded(argument).From("query", value); + } + + /// Binds an argument sourced from a request header (From("header", value)). + public static SimplePropertyConverter FromHeader(this ArgumentSource argument, TArgument? value) { + return Guarded(argument).From("header", value); + } + + /// Binds a value-type argument sourced from a request header (From("header", value)). + public static SimplePropertyConverter FromHeader(this ArgumentSource argument, TArgument? value) where TArgument : struct { + return Guarded(argument).From("header", value); + } + + /// Binds an argument sourced from the request body (From("body", value)). + public static SimplePropertyConverter FromBody(this ArgumentSource argument, TArgument? value) { + return Guarded(argument).From("body", value); + } + + /// Binds a value-type argument sourced from the request body (From("body", value)). + public static SimplePropertyConverter FromBody(this ArgumentSource argument, TArgument? value) where TArgument : struct { + return Guarded(argument).From("body", value); + } + + /// Binds an argument sourced from a form field (From("form", value)). + public static SimplePropertyConverter FromForm(this ArgumentSource argument, TArgument? value) { + return Guarded(argument).From("form", value); + } + + /// Binds a value-type argument sourced from a form field (From("form", value)). + public static SimplePropertyConverter FromForm(this ArgumentSource argument, TArgument? value) where TArgument : struct { + return Guarded(argument).From("form", value); + } + + /// Binds a list argument sourced from the query string (From("query", values)). + public static ListOfSimplePropertiesConverter FromQuery(this ArgumentListSource argument, IEnumerable? values) { + return Guarded(argument).From("query", values); + } + + /// Binds a value-type list argument sourced from the query string (From("query", values)). + public static ListOfSimpleValuePropertiesConverter FromQuery(this ArgumentListSource argument, IEnumerable? values) where TArgument : struct { + return Guarded(argument).From("query", values); + } + + /// Binds a list argument sourced from repeated request headers (From("header", values)). + public static ListOfSimplePropertiesConverter FromHeader(this ArgumentListSource argument, IEnumerable? values) { + return Guarded(argument).From("header", values); + } + + /// Binds a value-type list argument sourced from repeated request headers (From("header", values)). + public static ListOfSimpleValuePropertiesConverter FromHeader(this ArgumentListSource argument, IEnumerable? values) where TArgument : struct { + return Guarded(argument).From("header", values); + } + + /// Binds a list argument sourced from repeated form fields (From("form", values)). + public static ListOfSimplePropertiesConverter FromForm(this ArgumentListSource argument, IEnumerable? values) { + return Guarded(argument).From("form", values); + } + + /// Binds a value-type list argument sourced from repeated form fields (From("form", values)). + public static ListOfSimpleValuePropertiesConverter FromForm(this ArgumentListSource argument, IEnumerable? values) where TArgument : struct { + return Guarded(argument).From("form", values); + } + + private static ArgumentSource Guarded(ArgumentSource argument) { + if (argument is null) { throw new ArgumentNullException(nameof(argument)); } + + return argument; + } + + private static ArgumentListSource Guarded(ArgumentListSource argument) { + if (argument is null) { throw new ArgumentNullException(nameof(argument)); } + + return argument; + } + + #endregion + +} diff --git a/FirstClassErrors.RequestBinder/Bind.cs b/FirstClassErrors.RequestBinder/Bind.cs index 6d70e676..4a1d4457 100644 --- a/FirstClassErrors.RequestBinder/Bind.cs +++ b/FirstClassErrors.RequestBinder/Bind.cs @@ -1,19 +1,21 @@ 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. +/// The entry point of request binding: builds a typed command or query of value objects from an incoming request 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 bind = Bind.Request(PlaceBookingError.Invalid); /// -/// var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); -/// var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); +/// var body = bind.PropertiesOf(request); +/// var email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +/// var stay = body.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); +/// var id = bind.Argument("bookingId").FromRoute(routeBookingId).AsRequired(BookingId.From); /// /// Outcome<PlaceBookingCommand> command = -/// bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); +/// bind.New(s => new PlaceBookingCommand(s.Get(id), s.Get(email), s.Get(stay))); /// /// public static class Bind { @@ -21,29 +23,30 @@ public static class Bind { #region Statics members declarations /// - /// Starts binding the properties of a request DTO with the default options (argument names are the C# - /// property names). Declare the failure envelope next, with - /// . + /// Starts binding a request with the default options (argument names are the C# property names), declaring the + /// failure envelope up front — 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. Attach the inputs next, through + /// and , and assemble the + /// command or query 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, RequestBinderOptions.Default); + /// The envelope factory, receiving the collected failures. + /// The request binder. + /// Thrown when is null. + public static RequestBinder Request(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new RequestBinder(new RequestBinding(envelope, RequestBinderOptions.Default, argumentPrefix: null)); } /// - /// Fixes the binding options — for example a serializer-aware — before - /// any property is bound, then starts binding with . The - /// options are set once here, so a binder's naming policy can never change mid-binding. The returned entry - /// point holds no per-request state: create it once (for example at application setup) and reuse it for every - /// request. + /// Fixes the binding options — for example a serializer-aware — before any + /// input is bound, then starts binding with . The options are set once here, + /// so a binder's naming policy can never change mid-binding. The returned entry point holds no per-request state: + /// create it once (for example at application setup) and reuse it for every request. /// /// The options every binding started from the returned entry point (and its nested binders) binds with. - /// An options-configured entry point offering . + /// An options-configured entry point offering . /// Thrown when is null. public static ConfiguredBind WithOptions(RequestBinderOptions options) { if (options is null) { throw new ArgumentNullException(nameof(options)); } diff --git a/FirstClassErrors.RequestBinder/BindingAssembler.cs b/FirstClassErrors.RequestBinder/BindingAssembler.cs index a8cead15..3098ce4d 100644 --- a/FirstClassErrors.RequestBinder/BindingAssembler.cs +++ b/FirstClassErrors.RequestBinder/BindingAssembler.cs @@ -1,7 +1,7 @@ namespace FirstClassErrors.RequestBinder; /// -/// Assembles the bound command inside , reading each bound value +/// 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. diff --git a/FirstClassErrors.RequestBinder/BindingScope.cs b/FirstClassErrors.RequestBinder/BindingScope.cs index 18a3ad6d..e4d75ea2 100644 --- a/FirstClassErrors.RequestBinder/BindingScope.cs +++ b/FirstClassErrors.RequestBinder/BindingScope.cs @@ -1,16 +1,16 @@ namespace FirstClassErrors.RequestBinder; /// -/// The reader handed to a build terminal's assembler ( / -/// ): the only channel through which a bound value can +/// 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 — +/// 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 diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs index d697d9f5..ad442e45 100644 --- a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -1,18 +1,22 @@ 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. +/// Binds a complex property through a nested binder: the binding function receives a child +/// — building the nested value object, prefixed with this property's path, inheriting the +/// parent options, failing with the envelope declared on the previous stage — together with the nested DTO to attach +/// to it, and typically lives in a dedicated method passed as a method group. /// -/// The type of the request DTO. +/// +/// The nested binder is a full , so the nested value object is built exactly like a +/// top-level command: attach the supplied DTO with , bind its +/// properties (and, if needed, out-of-DTO arguments), then assemble with New / Create. +/// /// The type of the nested DTO. -public sealed class ComplexPropertyConverter { +public sealed class ComplexPropertyConverter { #region Fields declarations - private readonly RequestBinder _binder; + private readonly RequestBinding _binding; private readonly string _argumentPath; private readonly TArgument? _value; private readonly bool _isMissing; @@ -22,12 +26,12 @@ public sealed class ComplexPropertyConverter { #region Constructors declarations - internal ComplexPropertyConverter(RequestBinder binder, + internal ComplexPropertyConverter(RequestBinding binding, string argumentPath, TArgument? value, bool isMissing, Func envelope) { - _binder = binder; + _binding = binding; _argumentPath = argumentPath; _value = value; _isMissing = isMissing; @@ -37,62 +41,61 @@ internal ComplexPropertyConverter(RequestBinder #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. + /// Binds a required complex property: 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 nested binding function, receiving the child binder and the nested DTO (typically a method group such as BindStay). /// The bound field token. /// Thrown when is null. - public RequiredField AsRequired(Func, Outcome> bindNested) where TProperty : notnull { + public RequiredField AsRequired(Func> bindNested) where TProperty : notnull { if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } if (_isMissing) { - _binder.RecordArgumentRequired(_argumentPath); + _binding.RecordArgumentRequired(_argumentPath); - return new RequiredField(_binder, default!); + return new RequiredField(_binding, default!); } - RequestBinder nested = NestedBinder(); - Outcome outcome = bindNested(nested); + RequestBinding nested = NestedBinding(); + Outcome outcome = bindNested(new RequestBinder(nested), _value!); if (outcome.IsFailure) { - _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalid)); + _binding.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binding.Options.ArgumentInvalid)); - return new RequiredField(_binder, default!); + return new RequiredField(_binding, default!); } - return new RequiredField(_binder, outcome.GetResultOrThrow()); + return new RequiredField(_binding, outcome.GetResultOrThrow()); } /// - /// Binds an optional complex argument whose bound type is a reference type: absent yields a - /// null value and records nothing; a - /// present-but-invalid nested binding records its envelope. The complex counterpart of the scalar - /// AsOptionalReference; the split name keeps AsOptionalValue free for a future value-type - /// nested object. + /// Binds an optional complex property whose bound type is a reference type: absent yields a null + /// value and records nothing; a present-but-invalid nested + /// binding records its envelope. The complex counterpart of the scalar AsOptionalReference; the split name + /// keeps AsOptionalValue free for a future value-type nested object. /// /// The reference type the nested binding produces. - /// The nested binding function (typically a method group such as BindAddress). + /// The nested binding function, receiving the child binder and the nested DTO (typically a method group such as BindAddress). /// The bound field token. /// Thrown when is null. - public OptionalReferenceField AsOptionalReference(Func, Outcome> bindNested) where TProperty : class { + public OptionalReferenceField AsOptionalReference(Func> bindNested) where TProperty : class { if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); } - if (_isMissing) { return new OptionalReferenceField(_binder, value: null); } + if (_isMissing) { return new OptionalReferenceField(_binding, value: null); } - RequestBinder nested = NestedBinder(); - Outcome outcome = bindNested(nested); + RequestBinding nested = NestedBinding(); + Outcome outcome = bindNested(new RequestBinder(nested), _value!); if (outcome.IsFailure) { - _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalid)); + _binding.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binding.Options.ArgumentInvalid)); - return new OptionalReferenceField(_binder, value: null); + return new OptionalReferenceField(_binding, value: null); } - return new OptionalReferenceField(_binder, outcome.GetResultOrThrow()); + return new OptionalReferenceField(_binding, outcome.GetResultOrThrow()); } - private RequestBinder NestedBinder() { - return new RequestBinder(_value!, _envelope, _binder.Options, _argumentPath); + private RequestBinding NestedBinding() { + return new RequestBinding(_envelope, _binding.Options, _argumentPath); } } diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs b/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs index 04ccb80f..d2b8e364 100644 --- a/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs +++ b/FirstClassErrors.RequestBinder/ComplexPropertyEnvelopeStage.cs @@ -1,26 +1,25 @@ 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 mandatory envelope stage of a complex property: declares the envelope error the nested binding's failures are +/// grouped into, before AsRequired / AsOptionalReference become available. /// -/// The type of the request DTO. /// The type of the nested DTO. -public sealed class ComplexPropertyEnvelopeStage { +public sealed class ComplexPropertyEnvelopeStage { #region Fields declarations - private readonly RequestBinder _binder; - private readonly string _argumentPath; - private readonly TArgument? _value; - private readonly bool _isMissing; + private readonly RequestBinding _binding; + 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; + internal ComplexPropertyEnvelopeStage(RequestBinding binding, string argumentPath, TArgument? value, bool isMissing) { + _binding = binding; _argumentPath = argumentPath; _value = value; _isMissing = isMissing; @@ -29,16 +28,16 @@ internal ComplexPropertyEnvelopeStage(RequestBinder binder, string arg #endregion /// - /// Declares the nested envelope: the factory producing the under which the - /// nested binding's failures are grouped. + /// 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. + /// The converter stage offering AsRequired / AsOptionalReference. /// Thrown when is null. - public ComplexPropertyConverter FailWith(Func envelope) { + public ComplexPropertyConverter FailWith(Func envelope) { if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } - return new ComplexPropertyConverter(_binder, _argumentPath, _value, _isMissing, envelope); + return new ComplexPropertyConverter(_binding, _argumentPath, _value, _isMissing, envelope); } } diff --git a/FirstClassErrors.RequestBinder/ConfiguredBind.cs b/FirstClassErrors.RequestBinder/ConfiguredBind.cs index 720d27bf..54c63457 100644 --- a/FirstClassErrors.RequestBinder/ConfiguredBind.cs +++ b/FirstClassErrors.RequestBinder/ConfiguredBind.cs @@ -2,9 +2,9 @@ namespace FirstClassErrors.RequestBinder; /// /// An options-configured request-binding entry point, produced by . It fixes the -/// once, before any binding begins, so a binder's naming policy can never -/// change mid-binding. It carries no per-request state, so a single instance can be created once (for example at -/// application setup) and reused for every request. +/// once, before any binding begins, so a binder's naming policy can never change +/// mid-binding. It carries no per-request state, so a single instance can be created once (for example at application +/// setup) and reused for every request. /// public sealed class ConfiguredBind { @@ -23,17 +23,17 @@ internal ConfiguredBind(RequestBinderOptions options) { #endregion /// - /// Starts binding the properties of a request DTO with the configured options. Declare the failure envelope - /// next, with . + /// Starts binding a request with the configured options, declaring the failure envelope up front. Attach the + /// inputs next, through and , + /// and assemble the command or query 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 RequestBinderEnvelopeStage PropertiesOf(TRequest request) { - if (request is null) { throw new ArgumentNullException(nameof(request)); } - - return new RequestBinderEnvelopeStage(request, _options); + /// The envelope factory, receiving the collected failures. + /// The request binder. + /// Thrown when is null. + public RequestBinder Request(Func envelope) { + if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } + + return new RequestBinder(new RequestBinding(envelope, _options, argumentPrefix: null)); } } diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs index 710fe25b..95176b33 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -1,17 +1,16 @@ 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. +/// Binds a list 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 { +public sealed class ListOfComplexPropertiesConverter { #region Fields declarations - private readonly RequestBinder _binder; + private readonly RequestBinding _binding; private readonly string _argumentPath; private readonly IEnumerable? _values; private readonly bool _isMissing; @@ -21,12 +20,12 @@ public sealed class ListOfComplexPropertiesConverter { #region Constructors declarations - internal ListOfComplexPropertiesConverter(RequestBinder binder, + internal ListOfComplexPropertiesConverter(RequestBinding binding, string argumentPath, IEnumerable? values, bool isMissing, Func envelope) { - _binder = binder; + _binding = binding; _argumentPath = argumentPath; _values = values; _isMissing = isMissing; @@ -42,16 +41,16 @@ internal ListOfComplexPropertiesConverter(RequestBinder /// 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 nested binding function, receiving the child binder and the element DTO (typically a method group). /// The bound field token. /// Thrown when is null. - public RequiredField> AsRequired(Func, Outcome> bindElement) where TProperty : notnull { + public RequiredField> AsRequired(Func> bindElement) where TProperty : notnull { if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); } if (_isMissing) { - _binder.RecordArgumentRequired(_argumentPath); + _binding.RecordArgumentRequired(_argumentPath); - return new RequiredField>(_binder, default!); + return new RequiredField>(_binding, default!); } return BindElements(bindElement); @@ -62,27 +61,27 @@ public RequiredField> AsRequired(Func /// The type each element's nested binding produces. - /// The nested binding function applied to each element (typically a method group). + /// The nested binding function, receiving the child binder and the element DTO (typically a method group). /// The bound field token. /// Thrown when is null. - public RequiredField> AsOptional(Func, Outcome> bindElement) where TProperty : notnull { + public RequiredField> AsOptional(Func> 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 new RequiredField>(_binding, empty); } return BindElements(bindElement); } - private RequiredField> BindElements(Func, Outcome> bindElement) where TProperty : notnull { - return _binder.ConvertEachElement(_argumentPath, _values!, (element, elementPath) => { - RequestBinder nested = new(element!, _envelope, _binder.Options, elementPath); - Outcome outcome = bindElement(nested); + private RequiredField> BindElements(Func> bindElement) where TProperty : notnull { + return _binding.ConvertEachElement(_argumentPath, _values!, source: null, (element, elementPath) => { + RequestBinding nested = new(_envelope, _binding.Options, elementPath); + Outcome outcome = bindElement(new RequestBinder(nested), element!); if (outcome.IsFailure) { - _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binder.Options.ArgumentInvalid)); + _binding.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binding.Options.ArgumentInvalid)); } return outcome; diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs index 72aeafd3..0a6beac7 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesEnvelopeStage.cs @@ -1,16 +1,15 @@ 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 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 { +public sealed class ListOfComplexPropertiesEnvelopeStage { #region Fields declarations - private readonly RequestBinder _binder; + private readonly RequestBinding _binding; private readonly string _argumentPath; private readonly IEnumerable? _values; private readonly bool _isMissing; @@ -19,8 +18,8 @@ public sealed class ListOfComplexPropertiesEnvelopeStage { #region Constructors declarations - internal ListOfComplexPropertiesEnvelopeStage(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { - _binder = binder; + internal ListOfComplexPropertiesEnvelopeStage(RequestBinding binding, string argumentPath, IEnumerable? values, bool isMissing) { + _binding = binding; _argumentPath = argumentPath; _values = values; _isMissing = isMissing; @@ -29,16 +28,16 @@ internal ListOfComplexPropertiesEnvelopeStage(RequestBinder binder, st #endregion /// - /// Declares the per-element envelope: the factory producing the under which - /// each failing element's nested-binding failures are grouped. + /// 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) { + public ListOfComplexPropertiesConverter FailWith(Func envelope) { if (envelope is null) { throw new ArgumentNullException(nameof(envelope)); } - return new ListOfComplexPropertiesConverter(_binder, _argumentPath, _values, _isMissing, envelope); + return new ListOfComplexPropertiesConverter(_binding, _argumentPath, _values, _isMissing, envelope); } } diff --git a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs index 9fa9bf5c..556a4120 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -1,30 +1,31 @@ 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. +/// Binds a list input — a DTO list property or an out-of-DTO list argument — 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 { +/// The raw element type of the list. +public sealed class ListOfSimplePropertiesConverter { #region Fields declarations - private readonly RequestBinder _binder; - private readonly string _argumentPath; - private readonly IEnumerable? _values; - private readonly bool _isMissing; + private readonly RequestBinding _binding; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + private readonly string? _source; #endregion #region Constructors declarations - internal ListOfSimplePropertiesConverter(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { - _binder = binder; + internal ListOfSimplePropertiesConverter(RequestBinding binding, string argumentPath, IEnumerable? values, bool isMissing, string? source) { + _binding = binding; _argumentPath = argumentPath; _values = values; _isMissing = isMissing; + _source = source; } #endregion @@ -43,9 +44,9 @@ public RequiredField> AsRequired(Func>(_binder, default!); + return new RequiredField>(_binding, default!); } return ConvertElements(convertElement); @@ -65,16 +66,16 @@ public RequiredField> AsOptional(Func empty = new List(); - return new RequiredField>(_binder, empty); + return new RequiredField>(_binding, empty); } return ConvertElements(convertElement); } private RequiredField> ConvertElements(Func> convertElement) where TProperty : notnull { - return _binder.ConvertEachElement(_argumentPath, _values!, (element, elementPath) => { + return _binding.ConvertEachElement(_argumentPath, _values!, _source, (element, elementPath) => { Outcome outcome = convertElement(element!); - if (outcome.IsFailure) { _binder.RecordArgumentInvalid(elementPath, outcome.Error!); } + if (outcome.IsFailure) { _binding.RecordArgumentInvalid(elementPath, outcome.Error!, _source); } return outcome; }); diff --git a/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs index 41bb2fcd..1e7f1c6f 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs @@ -1,36 +1,37 @@ namespace FirstClassErrors.RequestBinder; /// -/// Binds a list request property whose elements are a nullable value type (e.g. int?), each converted -/// by a value-object converter over the underlying non-nullable type. Each failing element is recorded -/// individually, under its indexed path (Quantities[2]), so one bad element never hides the others. +/// Binds a list input whose elements are a nullable value type (e.g. int?), each converted by a +/// value-object converter over the underlying non-nullable type. Each failing element is recorded individually, under +/// its indexed path (Quantities[2]), so one bad element never hides the others. /// /// -/// The value-type counterpart of : because a +/// The value-type counterpart of : because a /// Nullable<TArgument> element and a reference-type element need different null handling, the value-type /// path is a separate converter rather than a reuse of the reference one. It differs only in that a present element /// is unwrapped (element.Value) before conversion. /// -/// The type of the request DTO. -/// The underlying (non-nullable) value type of the DTO list elements. -public sealed class ListOfSimpleValuePropertiesConverter where TArgument : struct { +/// The underlying (non-nullable) value type of the list elements. +public sealed class ListOfSimpleValuePropertiesConverter where TArgument : struct { #region Fields declarations - private readonly RequestBinder _binder; - private readonly string _argumentPath; - private readonly IEnumerable? _values; - private readonly bool _isMissing; + private readonly RequestBinding _binding; + private readonly string _argumentPath; + private readonly IEnumerable? _values; + private readonly bool _isMissing; + private readonly string? _source; #endregion #region Constructors declarations - internal ListOfSimpleValuePropertiesConverter(RequestBinder binder, string argumentPath, IEnumerable? values, bool isMissing) { - _binder = binder; + internal ListOfSimpleValuePropertiesConverter(RequestBinding binding, string argumentPath, IEnumerable? values, bool isMissing, string? source) { + _binding = binding; _argumentPath = argumentPath; _values = values; _isMissing = isMissing; + _source = source; } #endregion @@ -50,9 +51,9 @@ public RequiredField> AsRequired(Func>(_binder, default!); + return new RequiredField>(_binding, default!); } return ConvertElements(convertElement); @@ -72,16 +73,16 @@ public RequiredField> AsOptional(Func empty = new List(); - return new RequiredField>(_binder, empty); + return new RequiredField>(_binding, empty); } return ConvertElements(convertElement); } private RequiredField> ConvertElements(Func> convertElement) where TProperty : notnull { - return _binder.ConvertEachElement(_argumentPath, _values!, (element, elementPath) => { + return _binding.ConvertEachElement(_argumentPath, _values!, _source, (element, elementPath) => { Outcome outcome = convertElement(element!.Value); - if (outcome.IsFailure) { _binder.RecordArgumentInvalid(elementPath, outcome.Error!); } + if (outcome.IsFailure) { _binding.RecordArgumentInvalid(elementPath, outcome.Error!, _source); } return outcome; }); diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs index 40528391..3dba099c 100644 --- a/FirstClassErrors.RequestBinder/NestedFailure.cs +++ b/FirstClassErrors.RequestBinder/NestedFailure.cs @@ -1,9 +1,9 @@ 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 -/// . +/// 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 { @@ -26,7 +26,7 @@ internal static class NestedFailure { internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath, BinderErrorDefinition argumentInvalid) { return ReferenceEquals(error, nestedEnvelope) ? (PrimaryPortError)error - : RequestBindingError.ArgumentInvalid(argumentInvalid, argumentPath, error); + : RequestBindingError.ArgumentInvalid(argumentInvalid, argumentPath, error, source: null); } #endregion diff --git a/FirstClassErrors.RequestBinder/OptionalReferenceField.cs b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs index 645cdc60..4f192fc6 100644 --- a/FirstClassErrors.RequestBinder/OptionalReferenceField.cs +++ b/FirstClassErrors.RequestBinder/OptionalReferenceField.cs @@ -7,7 +7,7 @@ namespace FirstClassErrors.RequestBinder; /// /// /// 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. /// diff --git a/FirstClassErrors.RequestBinder/OptionalValueField.cs b/FirstClassErrors.RequestBinder/OptionalValueField.cs index 14677905..ccbc10b0 100644 --- a/FirstClassErrors.RequestBinder/OptionalValueField.cs +++ b/FirstClassErrors.RequestBinder/OptionalValueField.cs @@ -8,7 +8,7 @@ namespace FirstClassErrors.RequestBinder; /// /// /// 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. /// diff --git a/FirstClassErrors.RequestBinder/PropertySource.cs b/FirstClassErrors.RequestBinder/PropertySource.cs new file mode 100644 index 00000000..a15e75c4 --- /dev/null +++ b/FirstClassErrors.RequestBinder/PropertySource.cs @@ -0,0 +1,147 @@ +#region Usings declarations + +using System.Linq.Expressions; +using System.Reflection; + +#endregion + +namespace FirstClassErrors.RequestBinder; + +/// +/// A request DTO attached to a as a source of inputs: its properties are +/// selected and bound into value objects, recording every failure into the binder's single collect-all envelope. +/// Obtained from ; a DTO is one source among peers, sitting +/// beside the out-of-DTO arguments bound directly on the binder. +/// +/// The type of the request DTO. +public sealed class PropertySource { + + #region Fields declarations + + private readonly RequestBinding _binding; + private readonly TDto _dto; + + #endregion + + #region Constructors declarations + + internal PropertySource(RequestBinding binding, TDto dto) { + _binding = binding; + _dto = dto; + } + + #endregion + + /// + /// 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 DTO (e.g. d => d.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(_binding, path, (TArgument?)value, value is null, source: null); + } + + /// + /// Selects a scalar value-type property declared nullable (e.g. int?), converted by a value-object + /// converter over the underlying, non-nullable type (Func<int, Outcome<T>>). + /// + /// + /// A nullable value-type property surfaces its underlying type here, not its : a + /// converter written against the underlying type binds as a method group, exactly as a reference-type converter + /// does on the reference overload. This overload exists because a value type and a reference type cannot share one + /// selector method (the two carry genuinely different parameter types, TArgument versus + /// Nullable<TArgument>), so they coexist. + /// + /// The underlying (non-nullable) value type of the DTO property. + /// A direct property access on a nullable value-type property (e.g. d => d.MaxNights). + /// The converter stage offering AsRequired / AsOptional and their variants. + public SimplePropertyConverter SimpleProperty(Expression> selector) where TArgument : struct { + (string path, object? value) = ResolveArgument(selector); + + return new SimplePropertyConverter(_binding, path, value is null ? default : (TArgument)value, value is null, source: 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 DTO (e.g. d => d.Stay). + /// The stage on which the nested envelope is declared. + public ComplexPropertyEnvelopeStage ComplexProperty(Expression> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ComplexPropertyEnvelopeStage(_binding, 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 DTO (e.g. d => d.Tags). + /// The converter stage offering AsRequired / AsOptional. + public ListOfSimplePropertiesConverter ListOfSimpleProperties(Expression?>> selector) { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfSimplePropertiesConverter(_binding, path, (IEnumerable?)value, value is null, source: null); + } + + /// + /// Selects a list property whose elements are a nullable value type (e.g. int?), each converted by + /// a value-object converter over the underlying, non-nullable type. + /// + /// + /// The value-type counterpart of the reference/string list overload: it exists for the same reason as the + /// value-type SimpleProperty overload — an IEnumerable<TArgument> selector and an + /// IEnumerable<Nullable<TArgument>> selector are distinct parameter types, so the two coexist. + /// + /// The underlying (non-nullable) value type of the DTO list elements. + /// A direct property access on a list of nullable value types (e.g. d => d.Quantities). + /// The converter stage offering AsRequired / AsOptional. + public ListOfSimpleValuePropertiesConverter ListOfSimpleProperties(Expression?>> selector) where TArgument : struct { + (string path, object? value) = ResolveArgument(selector); + + return new ListOfSimpleValuePropertiesConverter(_binding, path, (IEnumerable?)value, value is null, source: 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 DTO (e.g. d => d.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(_binding, path, (IEnumerable?)value, value is null); + } + + 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 = _binding.PathOf(_binding.Options.ArgumentNameProvider.GetArgumentNameFrom(property)); + + return (path, property.GetValue(_dto)); + } + +} diff --git a/FirstClassErrors.RequestBinder/README.nuget.md b/FirstClassErrors.RequestBinder/README.nuget.md index 6fc61780..1d35ec23 100644 --- a/FirstClassErrors.RequestBinder/README.nuget.md +++ b/FirstClassErrors.RequestBinder/README.nuget.md @@ -1,20 +1,23 @@ # 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. +Fluent, framework-agnostic request binding for [FirstClassErrors](https://github.com/Reefact/first-class-errors): build a typed command or query of value objects from an incoming request — a DTO body, route/query/header arguments, or both — 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. +* **Source-agnostic**: the binder builds the command; a DTO's properties and out-of-DTO arguments are attached as peers, into one envelope with one set of paths. * **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 bind = Bind.Request(InvalidBookingCommandError.Invalid); -var email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); -var stay = bind.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); +var body = bind.PropertiesOf(request); +var email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +var stay = body.ComplexProperty(r => r.Stay).FailWith(InvalidStayError.Invalid).AsRequired(BindStay); +var id = bind.Argument("bookingId").FromRoute(routeBookingId).AsRequired(BookingId.From); Outcome command = - bind.New(s => new PlaceBookingCommand(s.Get(email), s.Get(stay))); + bind.New(s => new PlaceBookingCommand(s.Get(id), s.Get(email), s.Get(stay))); ``` See the [request-binder guide](https://github.com/Reefact/first-class-errors/blob/main/doc/handwritten/for-users/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 index e589761d..cb7bdbeb 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -1,171 +1,95 @@ -#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 -/// . +/// Binds an incoming request 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. The binder is source-agnostic: its inputs are attached as peers — a +/// DTO's properties through , and individually named out-of-DTO values through +/// / — so a route/query/header value binds into the same envelope +/// as a body property, with the same paths, and the command type is named only at the build terminal +/// ( / ). /// /// /// /// 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. +/// 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. +/// 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 { +public sealed class RequestBinder { #region Fields declarations - private readonly TRequest _request; - private readonly Func _envelope; - private readonly string? _argumentPrefix; - private readonly List _errors = new(); + private readonly RequestBinding _binding; #endregion #region Constructors declarations - internal RequestBinder(TRequest request, Func envelope, RequestBinderOptions options, string? argumentPrefix) { - _request = request; - _envelope = envelope; - Options = options; - _argumentPrefix = argumentPrefix; + internal RequestBinder(RequestBinding binding) { + _binding = binding; } #endregion - /// The options this binder (and every binder nested under it) binds with; fixed before binding begins. - internal RequestBinderOptions Options { get; } - - /// - /// 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; } - - /// - /// 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 scalar value-type property declared nullable (e.g. int?), converted by a value-object - /// converter over the underlying, non-nullable type (Func<int, Outcome<T>>). - /// - /// - /// A nullable value-type property surfaces its underlying type here, not its : a - /// converter written against the underlying type — a value-object factory such as PositiveInt.From — binds - /// as a method group, exactly as a reference-type converter does on the reference overload. A missing value (a - /// null property) is still distinguished from a legitimate default and is handled by the AsRequired - /// / AsOptional variant chosen next. This overload exists because a value type and a reference type cannot - /// share one selector method (the two would differ only by a class / struct constraint, which C# - /// does not treat as an overload); the two selectors carry genuinely different parameter types - /// (TArgument versus Nullable<TArgument>), so they coexist. - /// - /// The underlying (non-nullable) value type of the DTO property. - /// A direct property access on a nullable value-type property (e.g. r => r.MaxNights). - /// The converter stage offering AsRequired / AsOptional and their variants. - public SimplePropertyConverter SimpleProperty(Expression> selector) where TArgument : struct { - (string path, object? value) = ResolveArgument(selector); - - return new SimplePropertyConverter(this, path, value is null ? default : (TArgument)value, value is null); - } - /// - /// Selects a complex property, bound by a nested binder. Declare the nested envelope next, with - /// . + /// Attaches a request DTO as a source of inputs: its properties are bound through the returned + /// (SimpleProperty, ComplexProperty, ListOf…). The DTO is + /// one source among peers; out-of-DTO values are attached separately through . /// - /// 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); + /// The type of the request DTO. + /// The request DTO whose properties are bound. + /// The property source offering the DTO-property selectors. + /// Thrown when is null. + public PropertySource PropertiesOf(TDto dto) { + if (dto is null) { throw new ArgumentNullException(nameof(dto)); } - return new ComplexPropertyEnvelopeStage(this, path, (TArgument?)value, value is null); + return new PropertySource(_binding, dto); } /// - /// Selects a list property whose elements are converted by a plain value-object converter. + /// Names an out-of-DTO argument — a value that does not come from a request DTO (a route, query, header or claim + /// value). State where it comes from and supply its value next, with + /// (or a host helper such as FromRoute). The failure is recorded under , into the + /// same envelope as every other input. /// - /// 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 a nullable value type (e.g. int?), each converted by - /// a value-object converter over the underlying, non-nullable type (Func<int, Outcome<T>>). - /// - /// - /// The value-type counterpart of the reference/string list overload: each element surfaces its underlying type, - /// so a converter written against it binds as a method group, while a null element is still recorded as a - /// missing argument under its indexed path. It exists for the same reason as the value-type - /// SimpleProperty overload — an IEnumerable<TArgument> selector and an - /// IEnumerable<Nullable<TArgument>> selector are distinct parameter types, so the two coexist. - /// - /// The underlying (non-nullable) value type of the DTO list elements. - /// A direct property access on a list of nullable value types (e.g. r => r.Quantities). - /// The converter stage offering AsRequired / AsOptional. - public ListOfSimpleValuePropertiesConverter ListOfSimpleProperties(Expression?>> selector) where TArgument : struct { - (string path, object? value) = ResolveArgument(selector); + /// The argument's logical name, used verbatim as its error path. + /// The stage on which the source and value are supplied. + /// Thrown when is null. + public ArgumentSource Argument(string name) { + if (name is null) { throw new ArgumentNullException(nameof(name)); } - return new ListOfSimpleValuePropertiesConverter(this, path, (IEnumerable?)value, value is null); + return new ArgumentSource(_binding, _binding.PathOf(name)); } /// - /// Selects a list property whose elements are bound by a nested binder (one per element). Declare the - /// per-element envelope next, with - /// . + /// Names an out-of-DTO list argument — repeated out-of-DTO values under one name (a repeated query + /// parameter, for example). Supply its source and values next, with + /// . Each failing element is + /// recorded under its indexed path (tag[2]). /// - /// 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); + /// The argument's logical name, the stem of each element's indexed error path. + /// The stage on which the source and values are supplied. + /// Thrown when is null. + public ArgumentListSource ArgumentList(string name) { + if (name is null) { throw new ArgumentNullException(nameof(name)); } - return new ListOfComplexPropertiesEnvelopeStage(this, path, (IEnumerable?)value, value is null); + return new ArgumentListSource(_binding, _binding.PathOf(name)); } /// - /// 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. + /// 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. The command type is inferred from the assembler, so it need not be named. /// /// /// Mirror the shape of the assembler at the call site: takes a new — a total @@ -173,16 +97,16 @@ public ListOfComplexPropertiesEnvelopeStage ListOfComplexPr /// 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 type of the command or query to build, inferred from . /// 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))); } + if (!_binding.HasErrors) { return Outcome.Success(assemble(new BindingScope(_binding))); } - return Outcome.Failure(BuildFailureEnvelope()); + return Outcome.Failure(_binding.BuildFailureEnvelope()); } /// @@ -196,10 +120,10 @@ public Outcome New(BindingAssembler assemble) wher /// 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 . + /// under the envelope declared with . For a total constructor that cannot fail, call + /// . /// - /// The type of the bound command or query. + /// The type of the command or query to build, inferred from . /// /// The validating assembler, reading the bound values from the supplied and returning /// an . @@ -209,100 +133,9 @@ public Outcome New(BindingAssembler assemble) wher 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); - } - - /// Records a missing-required-argument failure at under this binder's configured . - internal void RecordArgumentRequired(string argumentPath) { - Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequired, argumentPath)); - } - - /// Records a present-but-invalid-argument failure at under this binder's configured , wrapping . - internal void RecordArgumentInvalid(string argumentPath, Error cause) { - Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalid, argumentPath, cause)); - } - - /// - /// Binds every element of a list property at its indexed path (Tags[2]), collecting every - /// failure so one bad element never hides the others: a null element records - /// REQUEST_ARGUMENT_REQUIRED, and binds each non-null element — - /// recording its own failure (a converter error, or a nested envelope wrapped under the path) and yielding - /// its value on success. The list converters share this single iteration and null-element rule, so a change - /// to either is made in one place. - /// - /// The element type as stored in the DTO list (a reference or a ). - /// The type each element binds to. - /// The list's argument path; each element is reported under argumentPath[index]. - /// The list elements. - /// Binds a non-null element at its indexed path, recording its own failure. - /// The bound field token carrying the successfully bound elements. - internal RequiredField> ConvertEachElement( - string argumentPath, IEnumerable values, Func> convertElementAt) where TProperty : notnull { - List converted = new(); - int index = 0; - - foreach (TStored element in values) { - string elementPath = $"{argumentPath}[{index}]"; - index++; - - if (element is null) { - RecordArgumentRequired(elementPath); - - continue; - } - - Outcome outcome = convertElementAt(element, elementPath); - if (outcome.IsSuccess) { converted.Add(outcome.GetResultOrThrow()); } - // A failing element was already recorded by convertElementAt (REQUEST_ARGUMENT_INVALID, or the nested - // envelope wrapped under the indexed path), so it is simply skipped here. - } - - // 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 `converted` is the complete list when it is observed. - return new RequiredField>(this, converted); - } - - /// 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)); + if (!_binding.HasErrors) { return assemble(new BindingScope(_binding)); } - return (path, property.GetValue(_request)); + return Outcome.Failure(_binding.BuildFailureEnvelope()); } } diff --git a/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs b/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs deleted file mode 100644 index c8d96a1b..00000000 --- a/FirstClassErrors.RequestBinder/RequestBinderEnvelopeStage.cs +++ /dev/null @@ -1,41 +0,0 @@ -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; - private readonly RequestBinderOptions _options; - - #endregion - - #region Constructors declarations - - internal RequestBinderEnvelopeStage(TRequest request, RequestBinderOptions options) { - _request = request; - _options = options; - } - - #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, _options, argumentPrefix: null); - } - -} diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs index 3c1ac736..55e97c91 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -1,7 +1,7 @@ namespace FirstClassErrors.RequestBinder; /// -/// The binding options of a . A binder's options are fixed once, before +/// The binding options of a . A binder's options are fixed once, before /// binding begins — through or the application-wide — and /// inherited by nested binders; they never change while a binder is binding. The process-wide /// may be configured once at application startup and is frozen on first use. @@ -17,7 +17,7 @@ public sealed class RequestBinderOptions { private static bool _frozen; /// - /// The application-wide default options that binds with. Assign it + /// The application-wide default options that binds with. Assign it /// once at application startup — before any binding — to configure the binder host-wide without threading /// options through every call; a per-call still overrides it. The first bind /// reads it and thereby freezes it, so the default cannot drift once binding has begun. Defaults to the diff --git a/FirstClassErrors.RequestBinder/RequestBinding.cs b/FirstClassErrors.RequestBinder/RequestBinding.cs new file mode 100644 index 00000000..c6d60bef --- /dev/null +++ b/FirstClassErrors.RequestBinder/RequestBinding.cs @@ -0,0 +1,128 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The request-independent core of a binding: it owns the collected failures, the failure envelope, the binding +/// options and the argument-path prefix, and it is the identity every bound field token is checked against inside a +/// build terminal. +/// +/// +/// A builds a command or query on top of one instance; a +/// and the argument sources record their failures into the same instance — so a +/// DTO's properties and out-of-DTO arguments bound on one binder share a single collect-all envelope and one set of +/// argument paths. Instances are not thread-safe: one binding, in one scope. +/// +internal sealed class RequestBinding { + + #region Fields declarations + + private readonly Func _envelope; + private readonly string? _argumentPrefix; + private readonly List _errors = new(); + + #endregion + + #region Constructors declarations + + internal RequestBinding(Func envelope, RequestBinderOptions options, string? argumentPrefix) { + _envelope = envelope; + Options = options; + _argumentPrefix = argumentPrefix; + } + + #endregion + + /// The options this binding (and every binding nested under it) binds with; fixed before binding begins. + internal RequestBinderOptions Options { get; } + + /// + /// The envelope instance the most recent failing build terminal produced, or null when no build has + /// failed. A parent binding compares a nested failure against this by reference to tell a nested binding'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; } + + /// Whether any binding failure has been recorded; a build terminal assembles only when this is false. + internal bool HasErrors => _errors.Count > 0; + + /// Records a binding failure; it will surface in the envelope built by a failing build terminal. + internal void Record(PrimaryPortError error) { + _errors.Add(error); + } + + /// + /// Records a missing-required-argument failure at under the configured + /// , tagging it with when the value + /// was supplied as an out-of-DTO argument (null for a DTO property, whose provenance is implicit). + /// + internal void RecordArgumentRequired(string argumentPath, string? source = null) { + Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequired, argumentPath, source)); + } + + /// + /// Records a present-but-invalid-argument failure at under the configured + /// , wrapping and tagging it with + /// when the value was supplied as an out-of-DTO argument. + /// + internal void RecordArgumentInvalid(string argumentPath, Error cause, string? source = null) { + Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalid, argumentPath, cause, source)); + } + + /// + /// Binds every element of a list at its indexed path (Tags[2]), collecting every failure so one bad + /// element never hides the others: a null element records the required-argument failure, and + /// binds each non-null element — recording its own failure (a converter + /// error, or a nested envelope wrapped under the path) and yielding its value on success. The list converters + /// share this single iteration and null-element rule, so a change to either is made in one place. + /// + /// The element type as stored (a reference or a ). + /// The type each element binds to. + /// The list's argument path; each element is reported under argumentPath[index]. + /// The list elements. + /// The provenance of an out-of-DTO argument list (null for a DTO property). + /// Binds a non-null element at its indexed path, recording its own failure. + /// The bound field token carrying the successfully bound elements. + internal RequiredField> ConvertEachElement( + string argumentPath, IEnumerable values, string? source, Func> convertElementAt) where TProperty : notnull { + List converted = new(); + int index = 0; + + foreach (TStored element in values) { + string elementPath = $"{argumentPath}[{index}]"; + index++; + + if (element is null) { + RecordArgumentRequired(elementPath, source); + + continue; + } + + Outcome outcome = convertElementAt(element, elementPath); + if (outcome.IsSuccess) { converted.Add(outcome.GetResultOrThrow()); } + // A failing element was already recorded by convertElementAt (REQUEST_ARGUMENT_INVALID, or the nested + // envelope wrapped under the indexed path), so it is simply skipped here. + } + + // 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 `converted` is the complete list when it is observed. + return new RequiredField>(this, converted); + } + + /// Prepends this binding's argument prefix to a path segment ("CheckIn" -> "Stay.CheckIn"). + internal string PathOf(string argumentName) { + return _argumentPrefix is null ? argumentName : $"{_argumentPrefix}.{argumentName}"; + } + + /// Builds and caches the envelope grouping every recorded failure; the cached instance disambiguates a nested failure by reference. + internal PrimaryPortError BuildFailureEnvelope() { + PrimaryPortInnerErrors innerErrors = new(); + foreach (PrimaryPortError error in _errors) { + innerErrors.Add(error); + } + + BuiltEnvelope = _envelope(innerErrors); + + return BuiltEnvelope; + } + +} diff --git a/FirstClassErrors.RequestBinder/RequestBindingError.cs b/FirstClassErrors.RequestBinder/RequestBindingError.cs index 70e4030a..c6b54aef 100644 --- a/FirstClassErrors.RequestBinder/RequestBindingError.cs +++ b/FirstClassErrors.RequestBinder/RequestBindingError.cs @@ -62,25 +62,40 @@ public static class RequestBindingError { /// public static ErrorCode DefaultArgumentInvalidCode => DefaultArgumentInvalid.Code; + /// + /// The context key under which every structural binding failure carries the failing argument's full path (for + /// example Guests[1].Email). Exposed so an adapter can project the failing field into a structured error + /// response (a per-field problem entry) rather than parsing it out of the message. + /// + public static ErrorContextKey ArgumentPathKey => ErrCtxKey.RequestArgument; + + /// + /// The context key under which an out-of-DTO argument's failure carries its provenance (for example + /// route, query, header). Absent for a DTO property, whose provenance is implicit. Exposed for + /// the same projection reason as . + /// + public static ErrorContextKey ArgumentSourceKey => ErrCtxKey.RequestArgumentSource; + /// /// The argument at is required but was absent from the request. /// /// The structural-error definition to raise — the binder's configured , defaulting to . /// The full path of the missing argument. + /// The provenance of an out-of-DTO argument (for example route), recorded in the context when non-null; null for a DTO property. /// /// Non-transient by nature: resubmitting the same request cannot succeed. The diagnostic message stays in the /// library's internal language (English) by convention; only the public messages are localizable, through /// . /// [DocumentedBy(nameof(ArgumentRequiredDocumentation))] - internal static PrimaryPortError ArgumentRequired(BinderErrorDefinition definition, string argumentPath) { + internal static PrimaryPortError ArgumentRequired(BinderErrorDefinition definition, string argumentPath, string? source = null) { BindingMessage message = definition.GetMessage(argumentPath); return PrimaryPortError.Create( definition.Code, $"Argument '{argumentPath}' is required but was missing from the request.", Transience.NonTransient, - ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) + ctx => AddArgumentContext(ctx, argumentPath, source)) .WithPublicMessage(message.ShortMessage, message.DetailedMessage); } @@ -95,9 +110,10 @@ internal static PrimaryPortError ArgumentRequired(BinderErrorDefinition definiti /// — the two families a accepts. Any /// other family is a converter bug, reported by throwing (the binder's bug channel), never recorded. /// + /// The provenance of an out-of-DTO argument (for example route), recorded in the context when non-null; null for a DTO property. /// Thrown when belongs to another error family. [DocumentedBy(nameof(ArgumentInvalidDocumentation))] - internal static PrimaryPortError ArgumentInvalid(BinderErrorDefinition definition, string argumentPath, Error cause) { + internal static PrimaryPortError ArgumentInvalid(BinderErrorDefinition definition, string argumentPath, Error cause, string? source = null) { PrimaryPortInnerErrors innerErrors = new(); switch (cause) { case DomainError domainError: @@ -119,7 +135,7 @@ internal static PrimaryPortError ArgumentInvalid(BinderErrorDefinition definitio definition.Code, $"Argument '{argumentPath}' is invalid.", innerErrors, - ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) + ctx => AddArgumentContext(ctx, argumentPath, source)) .WithPublicMessage(message.ShortMessage, message.DetailedMessage); } @@ -199,6 +215,11 @@ private static ErrorDocumentation ArgumentInvalidDocumentation() { return DescribeArgumentInvalid(DefaultArgumentInvalid); } + private static void AddArgumentContext(ErrorContextBuilder ctx, string argumentPath, string? source) { + ctx.Add(ErrCtxKey.RequestArgument, argumentPath); + if (source is not null) { ctx.Add(ErrCtxKey.RequestArgumentSource, source); } + } + /// A representative converter failure used only by the documentation example above. private static DomainError SampleCause() { return DomainError.Create( @@ -218,6 +239,9 @@ private static class ErrCtxKey { public static readonly ErrorContextKey RequestArgument = ErrorContextKey.Create("RequestArgument", "Full path of the request argument that failed to bind (e.g. 'Guests[1].FirstName')."); + public static readonly ErrorContextKey RequestArgumentSource = + ErrorContextKey.Create("RequestArgumentSource", "Provenance of an out-of-DTO request argument that failed to bind (e.g. 'route', 'query', 'header'); absent for a DTO property."); + #endregion } diff --git a/FirstClassErrors.RequestBinder/RequiredField.cs b/FirstClassErrors.RequestBinder/RequiredField.cs index ba7577a4..218c7867 100644 --- a/FirstClassErrors.RequestBinder/RequiredField.cs +++ b/FirstClassErrors.RequestBinder/RequiredField.cs @@ -4,7 +4,7 @@ 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. /// /// diff --git a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs index d01b55c6..e9ba5c2d 100644 --- a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs @@ -1,40 +1,41 @@ namespace FirstClassErrors.RequestBinder; /// -/// Converts a scalar request property into a value object, through a plain converter -/// (Func<TArgument, Outcome<TProperty>>). +/// Converts a scalar input — a DTO property or an out-of-DTO argument — 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. +/// — 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 { +/// The raw type of the input. +public sealed class SimplePropertyConverter { #region Fields declarations - private readonly RequestBinder _binder; - private readonly string _argumentPath; - private readonly TArgument? _value; - private readonly bool _isMissing; + private readonly RequestBinding _binding; + private readonly string _argumentPath; + private readonly TArgument? _value; + private readonly bool _isMissing; + private readonly string? _source; #endregion #region Constructors declarations - internal SimplePropertyConverter(RequestBinder binder, string argumentPath, TArgument? value, bool isMissing) { - _binder = binder; + internal SimplePropertyConverter(RequestBinding binding, string argumentPath, TArgument? value, bool isMissing, string? source) { + _binding = binding; _argumentPath = argumentPath; _value = value; _isMissing = isMissing; + _source = source; } #endregion /// - /// Binds a required argument: missing records REQUEST_ARGUMENT_REQUIRED, a failed conversion records + /// Binds a required input: missing records REQUEST_ARGUMENT_REQUIRED, a failed conversion records /// REQUEST_ARGUMENT_INVALID wrapping the converter's error. /// /// The type of the value object. @@ -50,28 +51,27 @@ public RequiredField AsRequired(Func - /// Binds a required argument without conversion: only the presence is checked, and the raw value is the bound - /// value. + /// Binds a required input 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.RecordArgumentRequired(_argumentPath); + _binding.RecordArgumentRequired(_argumentPath, _source); - return new RequiredField(_binder, default!); + return new RequiredField(_binding, default!); } - return new RequiredField(_binder, _value!); + return new RequiredField(_binding, _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". + /// Binds an optional input with a fallback: when the input is absent, is + /// converted instead, so the bound property always has a value. A present-but-invalid input 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 raw value converted when the input is absent. /// The bound field token. /// Thrown when is null. /// @@ -89,13 +89,13 @@ public RequiredField AsOptional(Func(_binder, fallback.GetResultOrThrow()); + return new RequiredField(_binding, 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. + /// Binds an optional reference-type input without a fallback: absent yields a null + /// value and records nothing; a present-but-invalid input records + /// REQUEST_ARGUMENT_INVALID. /// /// The reference type of the value object. /// The value-object converter. @@ -104,22 +104,22 @@ public RequiredField AsOptional(Func AsOptionalReference(Func> convert) where TProperty : class { if (convert is null) { throw new ArgumentNullException(nameof(convert)); } - if (_isMissing) { return new OptionalReferenceField(_binder, value: null); } + if (_isMissing) { return new OptionalReferenceField(_binding, value: null); } Outcome outcome = convert(_value!); if (outcome.IsFailure) { RecordInvalid(outcome.Error!); - return new OptionalReferenceField(_binder, value: null); + return new OptionalReferenceField(_binding, value: null); } - return new OptionalReferenceField(_binder, outcome.GetResultOrThrow()); + return new OptionalReferenceField(_binding, outcome.GetResultOrThrow()); } /// - /// Binds an optional value-type argument without a fallback: absent yields a null + /// Binds an optional value-type input without a fallback: absent yields a null /// value — a real null, never - /// default(TProperty) — and records nothing; a present-but-invalid argument records + /// default(TProperty) — and records nothing; a present-but-invalid input records /// REQUEST_ARGUMENT_INVALID. /// /// The value type of the value object. @@ -129,36 +129,36 @@ public OptionalReferenceField AsOptionalReference(Func AsOptionalValue(Func> convert) where TProperty : struct { if (convert is null) { throw new ArgumentNullException(nameof(convert)); } - if (_isMissing) { return new OptionalValueField(_binder, value: null); } + if (_isMissing) { return new OptionalValueField(_binding, value: null); } Outcome outcome = convert(_value!); if (outcome.IsFailure) { RecordInvalid(outcome.Error!); - return new OptionalValueField(_binder, value: null); + return new OptionalValueField(_binding, value: null); } - return new OptionalValueField(_binder, outcome.GetResultOrThrow()); + return new OptionalValueField(_binding, outcome.GetResultOrThrow()); } private RequiredField RequiredMissing() { - _binder.RecordArgumentRequired(_argumentPath); + _binding.RecordArgumentRequired(_argumentPath, _source); - return new RequiredField(_binder, default!); + return new RequiredField(_binding, default!); } private RequiredField RecordIfInvalid(Outcome outcome) where TProperty : notnull { if (outcome.IsFailure) { RecordInvalid(outcome.Error!); - return new RequiredField(_binder, default!); + return new RequiredField(_binding, default!); } - return new RequiredField(_binder, outcome.GetResultOrThrow()); + return new RequiredField(_binding, outcome.GetResultOrThrow()); } private void RecordInvalid(Error cause) { - _binder.RecordArgumentInvalid(_argumentPath, cause); + _binding.RecordArgumentInvalid(_argumentPath, cause, _source); } } diff --git a/FirstClassErrors.RequestBinder/ValidatingAssembler.cs b/FirstClassErrors.RequestBinder/ValidatingAssembler.cs index 96109a57..a445221d 100644 --- a/FirstClassErrors.RequestBinder/ValidatingAssembler.cs +++ b/FirstClassErrors.RequestBinder/ValidatingAssembler.cs @@ -1,7 +1,7 @@ namespace FirstClassErrors.RequestBinder; /// -/// Assembles the bound command inside , reading each bound +/// 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. diff --git a/doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.fr.md b/doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.fr.md new file mode 100644 index 00000000..15c1e1b3 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.fr.md @@ -0,0 +1,195 @@ +# ADR-0021 | Lier les arguments hors-DTO comme des pairs via un point d’entrée agnostique de la source et non typé + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md) + +**Statut :** Proposé +**Date :** 2026-07-19 +**Décideurs :** Reefact + +## Contexte + +* Le binder construisait une commande à partir d’un unique DTO de requête. Son + point d’entrée était centré-DTO — un binder était *démarré sur* un DTO, et + l’enveloppe d’échec était déclarée sur le DTO en deuxième temps. Il n’existait + aucune couture pour une entrée qui ne vit pas dans le DTO. +* De vrais adaptateurs primaires assemblent régulièrement une commande à partir de + plus que le corps : un identifiant de route, un paramètre de query, un en-tête de + requête, un claim. L’hôte a déjà extrait ces valeurs individuelles ; elles ont + besoin de la même liaison — collecte exhaustive, codée, porteuse de chemin — que + les propriétés du DTO, dans la **même** enveloppe — pour qu’un mauvais segment de + route et un mauvais champ de corps soient rapportés ensemble. +* Le binder est agnostique du framework (contrôleurs HTTP, consommateurs de + messages, CLI, handlers gRPC). Extraire une valeur d’une requête HTTP entrante + relève de la connaissance de l’hôte ; la bibliothèque voit des valeurs déjà + extraites. +* Le chemin d’erreur d’une propriété de DTO est dérivé par réflexion depuis le nom + de propriété C# (via l’`IArgumentNameProvider` configuré) ; une valeur hors-DTO + n’a pas de propriété sur laquelle réfléchir, son chemin doit donc être indiqué par + l’appelant. +* La provenance d’une propriété de DTO est uniforme et implicite — chaque propriété + vient de l’unique corps de requête. La provenance d’une valeur hors-DTO ne l’est + pas : « route », « query » et « header » sont des origines distinctes que + l’appelant a dû indiquer, et qui sont utiles au diagnostic pour distinguer une + entrée en échec d’une autre. +* Nommer le type de commande au point d’entrée, l’inférer partout ailleurs, et + garder un seul type de binder ne peuvent pas tenir tous les trois à la fois : + avec le type de commande fixé sur une entrée générique, une liaison complexe + imbriquée place le type imbriqué en position de *paramètre* de délégué, où + l’inférence de groupe de méthodes de C# ne peut pas le récupérer — forçant un + argument de type explicite à chaque site d’appel imbriqué. +* Une propriété complexe de DTO est, par construction, un chemin dans un DTO ; un + argument hors-DTO n’a pas de DTO où cheminer. +* Les erreurs portent un contexte typé ; une clé d’accès publique permet à un + consommateur de lire une entrée de contexte sans dépendre de son nom interne. +* La bibliothèque est en pré-release, non publiée sur NuGet et sans consommateur + externe : la surface du point d’entrée peut donc encore changer sans migration. +* L’ADR-0007 a nommé les terminaux de construction `New` / `Create` ; l’ADR-0012 a + fixé les options d’un binder à son point d’entrée ; l’ADR-0017 a rendu réglable le + défaut d’options applicatif. Les trois décrivent le point d’entrée dans sa forme + précédente, centrée-DTO. + +## Décision + +Le binder est agnostique de la source : son point d’entrée non typé déclare +l’enveloppe d’échec d’emblée et attache les entrées comme des pairs — un DTO via une +source de propriétés, et des arguments hors-DTO nommés individuellement (chacun +indiquant sa provenance) via une source d’arguments — le type de commande n’étant +nommé qu’au terminal `New` / `Create`, et sans argument hors-DTO complexe. + +## Justification + +* Faire déclarer l’enveloppe par l’entrée et attacher le DTO et les arguments comme + des pairs est ce qui permet à une valeur de route/query/en-tête de se lier dans la + *même* enveloppe, avec les *mêmes* chemins et les *mêmes* deux codes structurels, + qu’une propriété de corps — c’est l’exigence. Une entrée centrée-DTO n’a nulle + part où placer une entrée absente du DTO ; un modèle de pairs, si. +* L’entrée est non typée parce que le modèle agnostique de la source retire la + raison de la typer. La commande n’est plus « construite sur un DTO » ; elle est + assemblée au terminal à partir de pairs. Nommer le type de commande au terminal + résout la tension à trois du Contexte : le terminal l’infère depuis l’assembleur, + il n’est donc nommé nulle part ailleurs, un seul type de binder sert la liaison de + premier niveau et la liaison imbriquée, et — parce que le type imbriqué n’apparaît + désormais qu’en position de *retour* de la liaison imbriquée — l’inférence de + groupe de méthodes le récupère sans argument de type explicite. L’intention reste + exprimée, par le nom de la fabrique d’enveloppe et par le type propre de la + commande, pas par un paramètre de type redondant sur l’entrée. +* Un argument hors-DTO indique son propre nom parce qu’il n’y a pas de propriété + d’où le dériver ; le nom est utilisé tel quel comme chemin, l’appelant contrôle + donc directement la clé du fil, là précisément où une propriété de DTO s’en remet + au fournisseur de noms. +* Capturer la provenance d’un argument, et *seulement* celle d’un argument, colle à + l’endroit où l’information existe et vaut la peine d’être conservée : l’origine + d’un argument a été indiquée par l’appelant et distingue des échecs par ailleurs + semblables, tandis que l’origine d’une propriété de DTO est l’unique corps + implicite et serait du bruit sur chaque propriété. L’asymétrie est délibérée, pas + un oubli. +* Réutiliser la surface de convertisseurs existante (`AsRequired`, `AsOptional`, les + optionnels valeur et référence, et la forme liste) pour les arguments garde un + seul modèle mental pour chaque entrée : un argument ne diffère d’une propriété que + par la façon dont il est nommé et sourcé, jamais par la façon dont il est lié. +* Omettre un argument hors-DTO complexe est une conséquence de ce que sont les deux + concepts, pas une coupe de fonctionnalité : une propriété complexe déréférence un + chemin de DTO, et un argument n’a pas de DTO à déréférencer. Une valeur complexe + assemblée à partir de plusieurs arguments s’exprime en liant chacun comme un pair + et en les combinant dans le terminal — aucun nouveau concept requis. +* Le statut de pré-release signifie que la forme du point d’entrée est arrêtée + maintenant, quand il n’y a aucun consommateur à migrer. + +## Alternatives considérées + +### Garder l’entrée centrée-DTO et traiter une valeur hors-DTO comme un DTO synthétique à une propriété + +Considérée parce qu’elle réutilise le chemin de propriété existant sans changement. + +Rejetée parce qu’elle force l’appelant à emballer chaque valeur libre dans un DTO +jetable dans le seul but de satisfaire la forme d’entrée, et le chemin dérivé par +réflexion rapporte alors le nom de propriété de cet emballage plutôt que la clé du +fil voulue par l’appelant — l’inverse de ce dont un argument hors-DTO a besoin. + +### Typer le point d’entrée sur la commande (un `Bind.To` générique) + +Considérée parce qu’elle énonce le type cible en tête, se lit comme intention-d’abord, +et garde un seul type de binder. + +Rejetée parce que, le type de commande étant fixé sur l’entrée, une liaison complexe +imbriquée place le type imbriqué en position de paramètre de délégué où l’inférence +de groupe de méthodes ne peut pas le récupérer, donc chaque site d’appel imbriqué +doit épeler un argument de type explicite — une écharde persistante sur la forme de +binder la plus courante. L’entrée non typée retire l’écharde tout en exprimant +l’intention via l’enveloppe et le type de commande. + +### Ajouter un argument hors-DTO complexe reflétant la propriété complexe + +Considérée par symétrie avec le côté DTO, pour que les arguments et les propriétés +offrent les mêmes formes. + +Rejetée parce qu’elle n’a pas de référent : une propriété complexe lie un DTO +imbriqué atteint par un chemin, et un argument hors-DTO n’a ni DTO ni chemin. L’API +d’apparence symétrique nommerait une chose qui n’existe pas ; la composition +existante pairs-plus-terminal couvre déjà « une valeur complexe bâtie à partir +d’arguments ». + +### Encoder la provenance dans le chemin d’erreur de l’argument (par exemple `route:bookingId`) + +Considérée parce qu’elle ne nécessite aucune deuxième clé de contexte. + +Rejetée parce qu’elle amalgame deux faits distincts — *où* est l’entrée (le chemin, +servant à localiser le champ) et *quel type d’origine* elle a (la source, servant à +classer l’échec) — en une seule chaîne que les consommateurs devraient alors parser, +le parsing de message même que le binder existe pour éviter. Une clé de contexte +séparée et typée garde les deux faits de première classe. + +## Conséquences + +### Positives + +* Une commande assemblée à partir d’un corps et de tout mélange de valeurs de + route/query/en-tête se lie en une passe, dans une enveloppe, avec un seul jeu de + chemins et de codes. +* Le site d’appel de la liaison imbriquée n’a besoin d’aucun argument de type + explicite ; un seul type de binder sert la liaison de premier niveau et imbriquée. +* L’échec d’un argument porte sa provenance, un consommateur peut donc classer les + échecs (une mauvaise route vs un mauvais en-tête) sans parser les messages. +* La surface de convertisseurs est inchangée, un argument est donc lié avec + exactement les verbes d’une propriété. + +### Négatives + +* La forme du point d’entrée change de la forme centrée-DTO que l’ADR-0007, + l’ADR-0012 et l’ADR-0017 décrivent en prose ; les décisions de ces ADR sont + inchangées, mais leur surface illustrative est désormais historique. +* Deux façons d’attacher une entrée (source de propriétés, source d’arguments) sont + une surface un peu plus large qu’une seule — acceptée parce qu’elles modélisent + deux provenances réellement différentes, pas deux saveurs de la même chose. + +### Risques + +* Un appelant pourrait chercher un « argument complexe » inexistant et être + brièvement surpris par son absence ; atténué en documentant la composition + pairs-plus-terminal comme la voie prévue pour bâtir une valeur complexe à partir + d’arguments. +* Des étiquettes de provenance libres pourraient dériver dans une base de code + (« route » vs « path ») ; atténué par les raccourcis de provenance (`FromRoute`, + `FromQuery`, …) qui fixent les étiquettes courantes, laissant le `From(source, …)` + brut pour le reste. + +## Actions de suivi + +* Mettre à jour le guide du request-binder (EN + FR) et le README du paquet vers + l’entrée agnostique de la source et la section des arguments hors-DTO. +* Envisager un paquet d’intégration hôte qui extrait les valeurs d’une requête HTTP + entrante (plutôt que d’étiqueter celles déjà extraites), si une demande de + consommateur apparaît. + +## Références + +* ADR-0007 — nommer les terminaux du binder New et Create ; le terminal qui porte + désormais aussi le paramètre de type de commande. Décision inchangée. +* ADR-0012 — fixer les options du binder avant le début de la liaison ; les options + sont toujours fixées au point d’entrée (désormais agnostique de la source). + Décision inchangée. +* ADR-0017 — fournir un défaut applicatif configurable pour les options du binder ; + le défaut soutient toujours le point d’entrée nu. Décision inchangée. +* Issue #148 — la demande que cette décision résout. +* [`fluent-request-binder`](https://github.com/Reefact/fluent-request-binder) — le + binder antérieur dont le modèle source/argument a informé cette décision. diff --git a/doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md b/doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md new file mode 100644 index 00000000..df437b56 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md @@ -0,0 +1,181 @@ +# ADR-0021 | Bind out-of-DTO arguments as peers through a source-agnostic untyped entry + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.fr.md) + +**Status:** Proposed +**Date:** 2026-07-19 +**Decision Makers:** Reefact + +## Context + +* The binder built a command from a single request DTO. Its entry point was + DTO-first — a binder was *started over* a DTO, and the failure envelope was + declared on the DTO as a second step. There was no seam for an input that does + not live in the DTO. +* Real primary adapters routinely assemble a command from more than the body: a + route identifier, a query parameter, a request header, a claim. A host has + already extracted these individual values; they need the same collect-all, + coded, path-carrying binding the DTO's properties get, into the **same** + envelope — so a bad route segment and a bad body field are reported together. +* The binder is framework-agnostic (HTTP controllers, message consumers, CLIs, + gRPC handlers). Extracting a value from an incoming HTTP request is host + knowledge; the library sees already-extracted values. +* A DTO property's error path is derived by reflection from the C# property name + (through the configured `IArgumentNameProvider`); an out-of-DTO value has no + property to reflect over, so its path must be stated by the caller. +* A DTO property's provenance is uniform and implicit — every property comes from + the one request body. An out-of-DTO value's provenance is not: "route", "query" + and "header" are distinct origins the caller had to state, and which are useful + in diagnostics to tell one failing input from another. +* Naming the command type at the entry point and inferring it everywhere else and + keeping a single binder type cannot all three hold at once: with the command + type fixed on a generic entry, a nested complex binding puts the nested type in + a delegate *parameter* position, where C# method-group inference cannot recover + it — forcing an explicit type argument at every nested call site. +* A complex DTO property is, by construction, a path into a DTO; an out-of-DTO + argument has no DTO to path into. +* Errors carry a typed context; a public accessor key lets a consumer read a + context entry without depending on its internal name. +* The library is pre-release, unpublished on NuGet with no external consumers, so + the entry-point surface can still change without a migration. +* ADR-0007 named the build terminals `New` / `Create`; ADR-0012 fixed a binder's + options at its entry point; ADR-0017 made the application-wide options default + settable. All three describe the entry point in its previous DTO-first shape. + +## Decision + +The binder is source-agnostic: its untyped entry point declares the failure +envelope up front and attaches inputs as peers — a DTO through a property source, +and individually named out-of-DTO arguments (each stating its provenance) through +an argument source — with the command type named only at the `New` / `Create` +terminal, and with no complex out-of-DTO argument. + +## Rationale + +* Making the entry declare the envelope and attach the DTO and the arguments as + peers is what lets a route/query/header value bind into the *same* envelope, + with the *same* paths and the *same* two structural codes, as a body property — + which is the requirement. A DTO-first entry has no place to put an input that is + not in the DTO; a peer model does. +* The entry is untyped because the source-agnostic model removes the reason to + type it. The command is no longer "built over a DTO"; it is assembled at the + terminal from peers. Naming the command type at the terminal resolves the + three-way tension in Context: the terminal infers it from the assembler, so it + is named nowhere else, one binder type serves top-level and nested binding + alike, and — because the nested type now appears only in the nested binding's + *return* position — method-group inference recovers it with no explicit type + argument. Intention is still expressed, by the envelope factory's name and the + command's own type, not by a redundant type parameter on the entry. +* An out-of-DTO argument states its own name because there is no property to + derive it from; the name is used verbatim as the path, so the caller controls + the wire key directly, exactly where a DTO property defers to the name provider. +* Capturing an argument's provenance, and *only* an argument's, matches where the + information exists and is worth keeping: an argument's origin was stated by the + caller and distinguishes otherwise-similar failures, whereas a DTO property's + origin is the single implicit body and would be noise on every property. The + asymmetry is deliberate, not an omission. +* Reusing the existing converter surface (`AsRequired`, `AsOptional`, the value + and reference optionals, and the list form) for arguments keeps one mental model + for every input: an argument differs from a property only in how it is named and + sourced, never in how it is bound. +* Omitting a complex out-of-DTO argument is a consequence of what the two concepts + are, not a feature cut: a complex property dereferences a DTO path, and an + argument has no DTO to dereference. A complex value assembled from several + arguments is expressed by binding each as a peer and combining them in the + terminal — no new concept required. +* The pre-release status means the entry-point shape is settled now, when there + are no consumers to migrate. + +## Alternatives Considered + +### Keep the DTO-first entry and treat an out-of-DTO value as a synthetic one-property DTO + +Considered because it reuses the existing property path unchanged. + +Rejected because it forces the caller to wrap each loose value in a throwaway DTO +purely to satisfy the entry shape, and the reflection-derived path then reports +that wrapper's property name rather than the caller's intended wire key — the +inverse of what an out-of-DTO argument needs. + +### Type the entry point on the command (a generic `Bind.To`) + +Considered because it states the target type at the top, reads as intention-first, +and keeps a single binder type. + +Rejected because, with the command type fixed on the entry, a nested complex +binding puts the nested type in a delegate parameter position where method-group +inference cannot recover it, so every nested call site must spell out an explicit +type argument — a persistent papercut across the most common binder shape. The +untyped entry removes the papercut while still expressing intention through the +envelope and the command type. + +### Add a complex out-of-DTO argument mirroring the complex property + +Considered for symmetry with the DTO side, so arguments and properties would offer +the same shapes. + +Rejected because it has no referent: a complex property binds a nested DTO reached +by a path, and an out-of-DTO argument has no DTO and no path. The symmetric-looking +API would name a thing that does not exist; the existing peers-plus-terminal +composition already covers "a complex value built from arguments". + +### Encode provenance in the argument's error path (for example `route:bookingId`) + +Considered because it needs no second context key. + +Rejected because it conflates two distinct facts — *where* the input is (the path, +used to locate the field) and *what kind of origin* it has (the source, used to +classify the failure) — into one string that consumers would then have to parse, +the very message-parsing the binder exists to avoid. A separate, typed context key +keeps both facts first-class. + +## Consequences + +### Positive + +* A command assembled from a body and any mix of route/query/header values binds + in one pass, into one envelope, with one set of paths and codes. +* The nested-binding call site needs no explicit type argument; one binder type + serves top-level and nested binding. +* An argument failure carries its provenance, so a consumer can classify failures + (a bad route vs a bad header) without parsing messages. +* The converter surface is unchanged, so an argument is bound with exactly the + verbs a property is. + +### Negative + +* The entry-point shape changes from the DTO-first form that ADR-0007, ADR-0012 + and ADR-0017 describe in prose; those ADRs' decisions are unaffected, but their + illustrative surface is now historical. +* Two ways to attach an input (property source, argument source) are a slightly + larger surface than one — accepted because they model two genuinely different + provenances, not two flavours of the same thing. + +### Risks + +* A caller could reach for a non-existent "complex argument" and be briefly + surprised by its absence; mitigated by documenting the peers-plus-terminal + composition as the intended way to build a complex value from arguments. +* Free-form provenance labels could drift across a codebase ("route" vs "path"); + mitigated by the provenance-shortcut helpers (`FromRoute`, `FromQuery`, …) that + fix the common labels, leaving the raw `From(source, …)` for the rest. + +## Follow-up Actions + +* Update the request-binder guide (EN + FR) and the package README to the + source-agnostic entry and the out-of-DTO argument section. +* Consider a host-integration package that extracts values from an incoming HTTP + request (rather than tagging already-extracted ones), if consumer demand + appears. + +## References + +* ADR-0007 — name the binder terminals New and Create; the terminal that now also + carries the command type parameter. Unchanged decision. +* ADR-0012 — fix the binder options before binding begins; the options are still + fixed at the (now source-agnostic) entry point. Unchanged decision. +* ADR-0017 — provide a configurable application-wide default for the binder + options; the default still backs the bare entry point. Unchanged decision. +* Issue #148 — the request this decision resolves. +* [`fluent-request-binder`](https://github.com/Reefact/fluent-request-binder) — + the prior-art binder whose source/argument model informed this decision. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 88341100..34798908 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -194,3 +194,4 @@ Optional supporting material: | [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.md) | Bundle the binder's structural error code and messages in one definition | Accepted | | [ADR-0019](0019-document-overridden-binder-errors-in-the-consumers-catalog.md) | Document overridden binder errors in the consumer's own catalog | Accepted | | [ADR-0020](0020-materialize-dummies-only-through-generate.md) | Materialize dummies only through Generate() | Accepted | +| [ADR-0021](0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md) | Bind out-of-DTO arguments as peers through a source-agnostic untyped entry | Proposed | diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md index ee544ce8..ca954173 100644 --- a/doc/handwritten/for-users/RequestBinder.en.md +++ b/doc/handwritten/for-users/RequestBinder.en.md @@ -3,15 +3,15 @@ 🌍 **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 +`FirstClassErrors.RequestBinder` converts an incoming request — a DTO body, +out-of-DTO route/query/header arguments, or both — into a typed command or query +of value objects, at the primary-adapter boundary. It collects **every** invalid +input 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 and +arguments, 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. @@ -19,18 +19,22 @@ first — the binder builds directly on both. ```mermaid flowchart LR - A[Request DTO] --> B[Bind each property into a value object] - B --> C{Every field valid?} + A[Request DTO properties] --> B[Bind each input into a value object] + A2[Out-of-DTO arguments
route / query / header] --> B + B --> C{Every input 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. +A binder is **source-agnostic**: it builds a command, and its inputs are attached +as **peers** — a DTO's properties on one side, individually named out-of-DTO +arguments on the other — into one envelope, with one set of paths. Each input is +read, converted through a value-object factory, and every failure is **recorded** +rather than raised. When the terminal runs, either every input 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 @@ -43,10 +47,11 @@ It targets **.NET Standard 2.0** and ships on the same release train as ## 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. +Every binding has the same three parts: **start** by declaring the envelope, +**attach and convert** each input — a DTO's properties, out-of-DTO arguments, or +both — 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. @@ -72,12 +77,13 @@ public sealed record PlaceBookingCommand( ```csharp public Outcome Bind(BookingRequest request) { - var bind = Bind.PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid); + RequestBinder bind = Bind.Request(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"); + PropertySource body = bind.PropertiesOf(request); + + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = body.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = body.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( @@ -88,16 +94,19 @@ public Outcome Bind(BookingRequest request) { } ``` -- **`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.Request(PlaceBookingError.Invalid)`** starts a binder and declares the + envelope up front — one factory from *your* catalog, under which every failure is + grouped. The envelope comes **first**, not last: a binder can never exist without + one, exactly as an error can never be left without a public message. +- **`bind.PropertiesOf(request)`** attaches the DTO as a source of inputs, returning + a `PropertySource` on which each `SimpleProperty(...)` selects one + property and converts it. The DTO is **one source among peers**; out-of-DTO values + are attached separately with `bind.Argument(...)` (see *Out-of-DTO arguments*). +- Each converter returns 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. + `s`. It returns `Outcome` — success when every input bound, + the envelope failure otherwise. The command type is inferred from the assembler, + so you never name it. The envelope factory is an ordinary aggregate error from your catalog: @@ -117,8 +126,8 @@ public static class PlaceBookingError { ## 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 +`body.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. @@ -133,16 +142,16 @@ throwing. ```csharp // Required with conversion: EmailAddress.Parse turns the raw string into a value object. -RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +RequiredField email = body.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(); +RequiredField reference = body.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"); +RequiredField currency = body.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); +OptionalValueField maxNights = body.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. @@ -159,11 +168,11 @@ factory such as `Quantity.From(int)` therefore binds as a method group, exactly // DTO: public sealed record CartRequest(int? Quantity, IReadOnlyList? Lines); // Quantity.From is `int -> Outcome`; the binder feeds it the unwrapped int. -RequiredField qty = bind.SimpleProperty(r => r.Quantity).AsRequired(Quantity.From); +RequiredField qty = body.SimpleProperty(r => r.Quantity).AsRequired(Quantity.From); // A list of value types works the same way: each element is converted over its underlying // int, and a null element records REQUEST_ARGUMENT_REQUIRED under its indexed path (Lines[2]). -RequiredField> lines = bind.ListOfSimpleProperties(r => r.Lines).AsRequired(Quantity.From); +RequiredField> lines = body.ListOfSimpleProperties(r => r.Lines).AsRequired(Quantity.From); ``` The property must still be declared nullable (`int?`, not `int`) so the binder can @@ -212,7 +221,8 @@ The token's type carries the nullability: ## 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. +the name mirrors what you write inside it. Both infer the command type from the +assembler, so you never name it. | Terminal | Your assembler | Use when | | --- | --- | --- | @@ -239,28 +249,28 @@ 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 +A consumer of `Create` therefore sees either the binder's envelope (an input was +missing or malformed) or the factory's own error (all inputs were fine, but the combination was rejected). The decision behind these two names is recorded in [ADR-0007](../for-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: +the next only on resubmit. Every failing input 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); +RequestBinder bind = Bind.Request(PlaceBookingError.Invalid); +PropertySource body = bind.PropertiesOf(new BookingRequest( + GuestEmail: "not-an-email", // invalid + Reference: null, // missing + Currency: "EURO", // invalid (not 3 letters) + /* ... */)); -bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); -bind.SimpleProperty(r => r.Reference).AsRequired(); -bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); +body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +body.SimpleProperty(r => r.Reference).AsRequired(); +body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); Outcome outcome = bind.New(s => /* never reached */ null!); @@ -270,30 +280,35 @@ Outcome outcome = bind.New(s => /* never reached */ null!); // REQUEST_ARGUMENT_INVALID (Currency) ``` -A request whose every field is invalid raises **zero** exceptions: the binder +A request whose every input 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`. +return, and the envelope errors you declare. | 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 | +| `REQUEST_ARGUMENT_REQUIRED` | a required input was absent from the request | — | +| `REQUEST_ARGUMENT_INVALID` | a present input 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: +Both carry the **full argument path** in their context (for example +`Guests[1].FirstName`), so the failing input is identifiable without parsing +messages. Read it through the public typed key `RequestBindingError.ArgumentPathKey`: ```csharp Error required = outcome.Error!.InnerErrors.First(); -required.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); +required.Context.TryGet(RequestBindingError.ArgumentPathKey, out string? path); // path == "Reference" ``` +An out-of-DTO argument additionally carries its **provenance** (`"route"`, +`"query"`, …) under `RequestBindingError.ArgumentSourceKey`; a DTO property carries +no source, because its provenance — the request body — is implicit. See +*Out-of-DTO arguments*, below. + `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 @@ -308,27 +323,30 @@ bug, reported by throwing, never recorded as a client error. 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: +method group; it receives the child binder **and** the nested DTO to attach to it: ```csharp -RequiredField stay = bind.ComplexProperty(r => r.Stay) +RequiredField stay = body.ComplexProperty(r => r.Stay) .FailWith(PlaceBookingError.StayInvalid) .AsRequired(BindStay); -private static Outcome BindStay(RequestBinder stay) { +private static Outcome BindStay(RequestBinder binder, StayDto dto) { + PropertySource stay = binder.PropertiesOf(dto); + 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))); + return binder.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 `AsOptionalReference` instead of `AsRequired` for a nullable -nested object: absent yields `null` and records nothing — the same +The nested binder is a full `RequestBinder` — the nested value object is built +exactly like a top-level command. It 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 `AsOptionalReference` instead of `AsRequired` +for a nullable nested object: absent yields `null` and records nothing — the same `AsOptionalReference` name the scalar side uses for a nullable reference value. ## Lists @@ -340,7 +358,7 @@ element never hides the others. ```csharp RequiredField> tags = - bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); // A failing element records REQUEST_ARGUMENT_INVALID under Tags[2]. ``` @@ -349,15 +367,17 @@ per-element envelope: ```csharp RequiredField> guests = - bind.ListOfComplexProperties(r => r.Guests) + body.ListOfComplexProperties(r => r.Guests) .FailWith(PlaceBookingError.GuestInvalid) .AsRequired(BindGuest); -private static Outcome BindGuest(RequestBinder guest) { +private static Outcome BindGuest(RequestBinder binder, GuestDto dto) { + PropertySource guest = binder.PropertiesOf(dto); + 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))); + return binder.New(s => new Guest(s.Get(firstName), s.Get(email))); } // A failure in the second guest's email reports Guests[1].Email. ``` @@ -371,9 +391,81 @@ present list is recorded as a missing argument at its index. When the domain nee at least one element, enforce that cardinality in the value object or command the bound list feeds. +## Out-of-DTO arguments + +Not every input is a DTO property. A route identifier, a query parameter, a header +value, a claim — these are **out-of-DTO arguments**: individual values the host has +already extracted from the request. The binder attaches them as **peers** of the +DTO, into the same envelope and the same set of paths, so a route value and a body +property fail together, uniformly. + +Name the argument, state where it comes from, then bind it with the **same** +converters a DTO property uses: + +```csharp +RequestBinder bind = Bind.Request(ConfirmBookingError.Invalid); + +// A body DTO and out-of-DTO arguments, attached as peers into one binder: +PropertySource body = bind.PropertiesOf(request); +RequiredField payment = body.SimpleProperty(r => r.PaymentRef).AsRequired(PaymentRef.Parse); + +RequiredField id = bind.Argument("bookingId").FromRoute(routeBookingId).AsRequired(BookingId.From); +RequiredField tenant = bind.Argument("tenant").FromHeader(tenantHeader).AsRequired(TenantId.From); + +Outcome command = + bind.New(s => new ConfirmBookingCommand(s.Get(id), s.Get(tenant), s.Get(payment))); +``` + +- **`bind.Argument(name)`** names an out-of-DTO argument. The `name` is used + verbatim as the argument's error path (`bookingId`) — there is no DTO property to + derive it from, so you state it. +- **`.From(source, value)`** supplies its provenance and value. `source` is a free + label (`"route"`, `"query"`, `"header"`, …) recorded in the failure's context; a + `null` value means the argument was **absent**. The provenance-shortcut helpers + `FromRoute`, `FromQuery`, `FromHeader`, `FromBody`, `FromForm` are exactly + `From("route", …)` and so on. +- The returned converter is the **same** one a DTO property yields: `AsRequired`, + `AsRequired()`, `AsOptional`, `AsOptionalReference`, `AsOptionalValue`, with the + same absent/invalid semantics and the same two structural codes. + +Because the helpers only **tag** an already-extracted value, they carry no +dependency on any web framework and work the same for an HTTP controller, a +message consumer, or a CLI. Extracting the value from the incoming request itself +is the host's job, not the library's. + +**Provenance is captured, for diagnostics.** An argument's failure carries both its +path *and* its source, so you can tell a bad route segment from a bad header without +parsing anything: + +```csharp +Error inner = command.Error!.InnerErrors.First(); +inner.Context.TryGet(RequestBindingError.ArgumentPathKey, out string? path); // "bookingId" +inner.Context.TryGet(RequestBindingError.ArgumentSourceKey, out string? source); // "route" +``` + +The source appears **only** on out-of-DTO arguments. A DTO property records its +path but no source, because its provenance — the request body — is implicit and the +same for every property; adding it would be noise. This asymmetry is deliberate: an +argument is a value whose origin you had to state, so the origin is worth keeping. + +**A list argument** is the out-of-DTO counterpart of `ListOfSimpleProperties` — a +repeated query parameter or header, under one name and an indexed path: + +```csharp +RequiredField> tags = + bind.ArgumentList("tag").FromQuery(queryTags).AsOptional(Tag.Parse); +// A failing element records REQUEST_ARGUMENT_INVALID under tag[2], source "query". +``` + +There is **no** out-of-DTO counterpart of a *complex* property: a complex property +is a path into a DTO, and an out-of-DTO argument, by definition, has no DTO to path +into. Build a complex value from arguments by binding each argument as a peer and +assembling them in the terminal, exactly as the example above builds +`ConfirmBookingCommand`. + ## Argument names and the wire format -By default, the argument path uses the **C# property name** (`GuestEmail`). If +By default, a DTO property's 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: @@ -384,11 +476,14 @@ public sealed class SnakeCaseNames : IArgumentNameProvider { ToSnakeCase(property.Name); // GuestEmail -> guest_email } -var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) - .PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid); +RequestBinder bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) + .Request(PlaceBookingError.Invalid); ``` +The name provider applies to **DTO properties**, whose name is derived by +reflection; an out-of-DTO argument's path is the name you passed to +`bind.Argument(...)`, used verbatim, since you already gave it the wire name. + Options are chosen **once**, on `Bind.WithOptions`, before the binder even exists — so a binder's naming policy can never change mid-binding. They are fixed for the life of a binding, and **nested binders inherit** the options in effect when they are @@ -401,7 +496,7 @@ wire keys is the host's knowledge, not the library's. ## Structural errors: codes and messages The binder raises two coded errors of its own — `REQUEST_ARGUMENT_REQUIRED` when a -required argument is missing, and `REQUEST_ARGUMENT_INVALID` when one is present but +required input is missing, and `REQUEST_ARGUMENT_INVALID` when one is present but fails to convert. These are the only errors the binder manufactures; every other code in a failure tree is yours (the converters' errors, and the envelope). @@ -420,7 +515,7 @@ var options = new RequestBinderOptions( ErrorCode.Create("ACME_ARGUMENT_INVALID"), path => new BindingMessage("An argument is invalid.", $"The argument '{path}' is invalid."))); -var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +RequestBinder bind = Bind.WithOptions(options).Request(PlaceBookingError.Invalid); ``` The message builder runs **when the error is raised**, not when the options are built — @@ -440,7 +535,8 @@ library's internal language (English) by convention, so logs for one structural never fork by request language. See [Internationalization](Internationalization.en.md). The configured definitions flow through **every** structural failure — scalars, list -elements, and the inner failures of nested binders, which inherit them. +elements, out-of-DTO arguments, and the inner failures of nested binders, which inherit +them. To **branch** on a binder failure — mapping it to an HTTP status, say — compare the error's code symbolically, never against a string literal: use the code you configured, @@ -483,7 +579,7 @@ time — so the documented entry matches what you actually emit. The same patter ## Configuring the default for the whole application -`Bind.PropertiesOf(request)` binds with `RequestBinderOptions.Default`. That default is +`Bind.Request(envelope)` binds with `RequestBinderOptions.Default`. That default is configurable **once, at application startup** — so a whole host (ASP.NET, a CLI, a worker) shares one naming policy and one set of structural-error definitions without threading options through every call, and without a DI container: @@ -496,7 +592,7 @@ RequestBinderOptions.Default = new RequestBinderOptions( argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))); // anywhere after that — no options threaded through: -var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +RequestBinder bind = Bind.Request(PlaceBookingError.Invalid); ``` The default is **frozen on first use**: the first bind reads it, and any later @@ -531,7 +627,9 @@ 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. +as `null` and the binder can tell it apart from a real value. The same holds for a +value-type **argument**: pass an `int?` / `Guid?` to `From`, so absence is a real +`null` rather than a defaulted value. ## Complete example @@ -540,16 +638,16 @@ a cross-field rule enforced through `Create`. ```csharp public Outcome BindBooking(BookingRequest request) { - var bind = Bind.PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid); + RequestBinder bind = Bind.Request(PlaceBookingError.Invalid); + PropertySource body = bind.PropertiesOf(request); - 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); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = body.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueField maxNights = body.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + RequiredField stay = body.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = body.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( @@ -563,7 +661,7 @@ public Outcome BindBooking(BookingRequest request) { } ``` -One structured error model runs from the wire to the command: a malformed field +One structured error model runs from the wire to the command: a malformed input 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. @@ -583,7 +681,8 @@ Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) ``` Assert the *whole* set of collected failures, in order — that is what proves the -collect-all behavior, not just that binding failed. +collect-all behavior, not just that binding failed. For an out-of-DTO argument, +assert its provenance too, through `RequestBindingError.ArgumentSourceKey`. ## Runnable examples @@ -603,15 +702,18 @@ living documentation. 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, +- each input is bound with the variant matching its contract — required, optional-with-fallback, or optional; +- out-of-DTO arguments state their source, and a value-type argument is passed as a + nullable (`int?`, `Guid?`) so absence stays distinguishable; - 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. +- tests assert the full, ordered set of collected errors and their argument paths + (and provenance, for arguments). --- diff --git a/doc/handwritten/for-users/RequestBinder.fr.md b/doc/handwritten/for-users/RequestBinder.fr.md index 7e1deb2c..ae494417 100644 --- a/doc/handwritten/for-users/RequestBinder.fr.md +++ b/doc/handwritten/for-users/RequestBinder.fr.md @@ -3,35 +3,40 @@ 🌍 **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. +`FirstClassErrors.RequestBinder` convertit une requête entrante — un corps DTO, des +arguments hors-DTO de route/query/en-tête, ou les deux — en une commande ou une +requête typée, faite d’objets valeur, à la frontière de l’adaptateur primaire. Il +collecte **toutes** les entrées invalides dans un seul `PrimaryPortError` documenté +au lieu de s’arrêter à la première, 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 et les +arguments, 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 ?} + A[Propriétés du DTO] --> B[Lier chaque entrée en objet valeur] + A2[Arguments hors-DTO
route / query / en-tête] --> B + B --> C{Toutes les entrées 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. +Un binder est **agnostique de la source** : il construit une commande, et ses +entrées sont attachées comme **pairs** — les propriétés d’un DTO d’un côté, des +arguments hors-DTO nommés individuellement de l’autre — dans une seule enveloppe, +avec un seul jeu de chemins. Chaque entrée est lue, convertie via une fabrique +d’objet valeur, et chaque échec est **enregistré** au lieu d’être levé. Quand le +terminal s’exécute, soit toutes les entrées se sont liées — et la commande est +assemblée —, soit au moins une a échoué, et le résultat est l’échec d’une enveloppe +unique qui les porte toutes. ## Installer le paquet @@ -45,11 +50,11 @@ 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. +Toute liaison a les trois mêmes parties : **démarrer** en déclarant l’enveloppe, +**attacher et convertir** chaque entrée — les propriétés d’un DTO, des arguments +hors-DTO, ou les deux —, 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. @@ -75,12 +80,13 @@ public sealed record PlaceBookingCommand( ```csharp public Outcome Bind(BookingRequest request) { - var bind = Bind.PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid); + RequestBinder bind = Bind.Request(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"); + PropertySource body = bind.PropertiesOf(request); + + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = body.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = body.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( @@ -91,16 +97,21 @@ public Outcome Bind(BookingRequest request) { } ``` -- **`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.Request(PlaceBookingError.Invalid)`** démarre un binder et déclare + l’enveloppe d’emblée — une fabrique de *votre* catalogue, sous laquelle chaque + échec est groupé. L’enveloppe vient **d’abord**, pas en dernier : un binder ne peut + jamais exister sans elle, exactement comme une erreur ne peut jamais rester sans + message public. +- **`bind.PropertiesOf(request)`** attache le DTO comme source d’entrées, en + renvoyant un `PropertySource` sur lequel chaque `SimpleProperty(...)` + sélectionne une propriété et la convertit. Le DTO est **une source parmi ses + pairs** ; les valeurs hors-DTO sont attachées séparément avec `bind.Argument(...)` + (voir *Arguments hors-DTO*). +- Chaque convertisseur renvoie 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. + `s`. Il renvoie `Outcome` — un succès quand toutes les + entrées se sont liées, l’échec de l’enveloppe sinon. Le type de la commande est + inféré depuis l’assembleur : vous ne le nommez jamais. La fabrique d’enveloppe est une erreur d’agrégat ordinaire de votre catalogue : @@ -120,8 +131,8 @@ public static class PlaceBookingError { ## 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 +`body.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. @@ -136,16 +147,16 @@ levant une exception. ```csharp // Requis avec conversion : EmailAddress.Parse transforme la chaîne brute en objet valeur. -RequiredField email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +RequiredField email = body.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(); +RequiredField reference = body.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"); +RequiredField currency = body.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); +OptionalValueField maxNights = body.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. @@ -163,11 +174,11 @@ pour une propriété de type référence : // DTO : public sealed record CartRequest(int? Quantity, IReadOnlyList? Lines); // Quantity.From est `int -> Outcome` ; le binder lui passe l'int déballé. -RequiredField qty = bind.SimpleProperty(r => r.Quantity).AsRequired(Quantity.From); +RequiredField qty = body.SimpleProperty(r => r.Quantity).AsRequired(Quantity.From); // Une liste de types valeur fonctionne de même : chaque élément est converti sur son int // sous-jacent, et un élément null enregistre REQUEST_ARGUMENT_REQUIRED sous son chemin indexé (Lines[2]). -RequiredField> lines = bind.ListOfSimpleProperties(r => r.Lines).AsRequired(Quantity.From); +RequiredField> lines = body.ListOfSimpleProperties(r => r.Lines).AsRequired(Quantity.From); ``` La propriété doit toujours être déclarée nullable (`int?`, pas `int`) pour que le @@ -217,7 +228,8 @@ Le type du jeton porte la nullabilité : ## 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. +assembleur — le nom recopie ce que vous écrivez à l’intérieur. Les deux infèrent le +type de la commande depuis l’assembleur : vous ne le nommez jamais. | Terminal | Votre assembleur | À utiliser quand | | --- | --- | --- | @@ -246,28 +258,28 @@ déjà lié — donc une règle inter-champs peut supposer ses entrées présent 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 +l’enveloppe du binder (une entrée manquante ou malformée), soit l’erreur propre de +la fabrique (toutes les entrées allaient bien, mais la combinaison a été rejetée). +La décision derrière ces deux noms est consignée dans [ADR-0007](../for-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 : +le suivant seulement à la resoumission. Chaque entrée 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); +RequestBinder bind = Bind.Request(PlaceBookingError.Invalid); +PropertySource body = bind.PropertiesOf(new BookingRequest( + GuestEmail: "not-an-email", // invalide + Reference: null, // manquant + Currency: "EURO", // invalide (pas 3 lettres) + /* ... */)); -bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); -bind.SimpleProperty(r => r.Reference).AsRequired(); -bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); +body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); +body.SimpleProperty(r => r.Reference).AsRequired(); +body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); Outcome outcome = bind.New(s => /* jamais atteint */ null!); @@ -277,30 +289,35 @@ Outcome outcome = bind.New(s => /* jamais atteint */ null!) // REQUEST_ARGUMENT_INVALID (Currency) ``` -Une requête dont chaque champ est invalide ne lève **aucune** exception : le binder +Une requête dont chaque entrée 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`. +vos objets valeur, et les erreurs enveloppes que vous déclarez. | 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 | +| `REQUEST_ARGUMENT_REQUIRED` | une entrée requise était absente de la requête | — | +| `REQUEST_ARGUMENT_INVALID` | une entrée présente 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 : +Les deux portent le **chemin d’argument complet** dans leur contexte (par exemple +`Guests[1].FirstName`), pour que l’entrée fautive soit identifiable sans parser les +messages. Lisez-le via la clé typée publique `RequestBindingError.ArgumentPathKey` : ```csharp Error required = outcome.Error!.InnerErrors.First(); -required.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path); +required.Context.TryGet(RequestBindingError.ArgumentPathKey, out string? path); // path == "Reference" ``` +Un argument hors-DTO porte en plus sa **provenance** (`"route"`, `"query"`, …) sous +`RequestBindingError.ArgumentSourceKey` ; une propriété de DTO ne porte aucune +source, car sa provenance — le corps de la requête — est implicite. Voir *Arguments +hors-DTO*, plus bas. + `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 @@ -316,29 +333,33 @@ bug du convertisseur, rapporté en levant, jamais enregistré comme erreur clien 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 : +groupe de méthodes ; elle reçoit le binder enfant **et** le DTO imbriqué à y +attacher : ```csharp -RequiredField stay = bind.ComplexProperty(r => r.Stay) +RequiredField stay = body.ComplexProperty(r => r.Stay) .FailWith(PlaceBookingError.StayInvalid) .AsRequired(BindStay); -private static Outcome BindStay(RequestBinder stay) { +private static Outcome BindStay(RequestBinder binder, StayDto dto) { + PropertySource stay = binder.PropertiesOf(dto); + 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))); + return binder.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 `AsOptionalReference` plutôt -qu’`AsRequired` pour un objet imbriqué nullable : l’absence donne `null` et -n’enregistre rien — le même nom `AsOptionalReference` que le côté scalaire utilise -pour une valeur de référence nullable. +Le binder imbriqué est un `RequestBinder` à part entière — l’objet valeur imbriqué +se construit exactement comme une commande de premier niveau. Il 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 +`AsOptionalReference` plutôt qu’`AsRequired` pour un objet imbriqué nullable : +l’absence donne `null` et n’enregistre rien — le même nom `AsOptionalReference` que +le côté scalaire utilise pour une valeur de référence nullable. ## Listes @@ -350,7 +371,7 @@ valeur : ```csharp RequiredField> tags = - bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); // Un élément en échec enregistre REQUEST_ARGUMENT_INVALID sous Tags[2]. ``` @@ -359,15 +380,17 @@ une enveloppe par élément : ```csharp RequiredField> guests = - bind.ListOfComplexProperties(r => r.Guests) + body.ListOfComplexProperties(r => r.Guests) .FailWith(PlaceBookingError.GuestInvalid) .AsRequired(BindGuest); -private static Outcome BindGuest(RequestBinder guest) { +private static Outcome BindGuest(RequestBinder binder, GuestDto dto) { + PropertySource guest = binder.PropertiesOf(dto); + 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))); + return binder.New(s => new Guest(s.Get(firstName), s.Get(email))); } // Un échec sur l'email du deuxième invité rapporte Guests[1].Email. ``` @@ -382,9 +405,84 @@ présente est enregistré comme un argument manquant à son index. Quand le doma exige au moins un élément, imposez cette cardinalité dans l’objet valeur ou la commande que la liste liée alimente. +## Arguments hors-DTO + +Toute entrée n’est pas une propriété de DTO. Un identifiant de route, un paramètre +de query, une valeur d’en-tête, un claim — ce sont des **arguments hors-DTO** : des +valeurs individuelles que l’hôte a déjà extraites de la requête. Le binder les +attache comme **pairs** du DTO, dans la même enveloppe et le même jeu de chemins, +pour qu’une valeur de route et une propriété de corps échouent ensemble, de façon +uniforme. + +Nommez l’argument, indiquez d’où il vient, puis liez-le avec les **mêmes** +convertisseurs qu’une propriété de DTO : + +```csharp +RequestBinder bind = Bind.Request(ConfirmBookingError.Invalid); + +// Un DTO de corps et des arguments hors-DTO, attachés comme pairs dans un seul binder : +PropertySource body = bind.PropertiesOf(request); +RequiredField payment = body.SimpleProperty(r => r.PaymentRef).AsRequired(PaymentRef.Parse); + +RequiredField id = bind.Argument("bookingId").FromRoute(routeBookingId).AsRequired(BookingId.From); +RequiredField tenant = bind.Argument("tenant").FromHeader(tenantHeader).AsRequired(TenantId.From); + +Outcome command = + bind.New(s => new ConfirmBookingCommand(s.Get(id), s.Get(tenant), s.Get(payment))); +``` + +- **`bind.Argument(name)`** nomme un argument hors-DTO. Le `name` est utilisé tel + quel comme chemin d’erreur de l’argument (`bookingId`) — il n’y a pas de propriété + de DTO d’où le dériver, donc vous l’indiquez. +- **`.From(source, value)`** fournit sa provenance et sa valeur. `source` est une + étiquette libre (`"route"`, `"query"`, `"header"`, …) enregistrée dans le contexte + de l’échec ; une valeur `null` signifie que l’argument était **absent**. Les + raccourcis de provenance `FromRoute`, `FromQuery`, `FromHeader`, `FromBody`, + `FromForm` valent exactement `From("route", …)` et ainsi de suite. +- Le convertisseur renvoyé est le **même** que celui d’une propriété de DTO : + `AsRequired`, `AsRequired()`, `AsOptional`, `AsOptionalReference`, + `AsOptionalValue`, avec la même sémantique absent/invalide et les mêmes deux codes + structurels. + +Comme les raccourcis ne font qu’**étiqueter** une valeur déjà extraite, ils ne +portent aucune dépendance à un framework web et fonctionnent de la même façon pour +un contrôleur HTTP, un consommateur de messages ou une CLI. Extraire la valeur de la +requête entrante elle-même est le travail de l’hôte, pas de la bibliothèque. + +**La provenance est capturée, pour le diagnostic.** L’échec d’un argument porte à la +fois son chemin *et* sa source, pour que vous distinguiez un mauvais segment de route +d’un mauvais en-tête sans rien parser : + +```csharp +Error inner = command.Error!.InnerErrors.First(); +inner.Context.TryGet(RequestBindingError.ArgumentPathKey, out string? path); // "bookingId" +inner.Context.TryGet(RequestBindingError.ArgumentSourceKey, out string? source); // "route" +``` + +La source n’apparaît **que** sur les arguments hors-DTO. Une propriété de DTO +enregistre son chemin mais pas de source, car sa provenance — le corps de la +requête — est implicite et la même pour chaque propriété ; l’ajouter serait du +bruit. Cette asymétrie est délibérée : un argument est une valeur dont vous avez dû +indiquer l’origine, l’origine vaut donc la peine d’être conservée. + +**Un argument de type liste** est le pendant hors-DTO de `ListOfSimpleProperties` — +un paramètre de query ou un en-tête répété, sous un seul nom et un chemin indexé : + +```csharp +RequiredField> tags = + bind.ArgumentList("tag").FromQuery(queryTags).AsOptional(Tag.Parse); +// Un élément en échec enregistre REQUEST_ARGUMENT_INVALID sous tag[2], source « query ». +``` + +Il n’y a **pas** de pendant hors-DTO d’une propriété *complexe* : une propriété +complexe est un chemin dans un DTO, et un argument hors-DTO, par définition, n’a pas +de DTO où cheminer. Construisez une valeur complexe à partir d’arguments en liant +chaque argument comme un pair et en les assemblant dans le terminal, exactement comme +l’exemple ci-dessus construit `ConfirmBookingCommand`. + ## Noms d’arguments et format du fil -Par défaut, le chemin d’argument utilise le **nom de propriété C#** +Par défaut, le chemin d’une propriété de DTO 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 @@ -396,11 +494,15 @@ public sealed class SnakeCaseNames : IArgumentNameProvider { ToSnakeCase(property.Name); // GuestEmail -> guest_email } -var bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) - .PropertiesOf(request) - .FailWith(PlaceBookingError.Invalid); +RequestBinder bind = Bind.WithOptions(new RequestBinderOptions(new SnakeCaseNames())) + .Request(PlaceBookingError.Invalid); ``` +Le fournisseur de noms s’applique aux **propriétés de DTO**, dont le nom est dérivé +par réflexion ; le chemin d’un argument hors-DTO est le nom que vous avez passé à +`bind.Argument(...)`, utilisé tel quel, puisque vous lui avez déjà donné le nom du +fil. + Les options sont choisies **une seule fois**, sur `Bind.WithOptions`, avant même que le binder n’existe — la politique de nommage d’un binder ne peut donc jamais changer en cours de liaison. Elles sont fixées pour toute la durée d’une liaison, et **les @@ -415,9 +517,10 @@ l’hôte, pas de la bibliothèque. ## Erreurs structurelles : codes et messages Le binder lève deux erreurs codées qui lui sont propres — `REQUEST_ARGUMENT_REQUIRED` -quand un argument requis est absent, et `REQUEST_ARGUMENT_INVALID` quand il est présent -mais échoue à se convertir. Ce sont les seules erreurs que le binder fabrique ; tout autre -code d’un arbre d’échec est le vôtre (les erreurs des convertisseurs, et l’enveloppe). +quand une entrée requise est absente, et `REQUEST_ARGUMENT_INVALID` quand une entrée +est présente mais échoue à se convertir. Ce sont les seules erreurs que le binder +fabrique ; tout autre code d’un arbre d’échec est le vôtre (les erreurs des +convertisseurs, et l’enveloppe). Chacune est un `BinderErrorDefinition` — un **code et ses messages publics, tenus ensemble** — que vous surchargez comme une unité cohérente, jamais un code séparé de son @@ -434,7 +537,7 @@ var options = new RequestBinderOptions( ErrorCode.Create("ACME_ARGUMENT_INVALID"), path => new BindingMessage("An argument is invalid.", $"The argument '{path}' is invalid."))); -var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +RequestBinder bind = Bind.WithOptions(options).Request(PlaceBookingError.Invalid); ``` Le constructeur de message s’exécute **au moment où l’erreur est levée**, pas à la @@ -456,7 +559,8 @@ d’un même échec structurel ne se scindent jamais selon la langue de la requ [Internationalisation](Internationalisation.fr.md). Les définitions configurées se propagent à **chaque** échec structurel — scalaires, -éléments de liste, et les échecs internes des binders imbriqués, qui en héritent. +éléments de liste, arguments hors-DTO, et les échecs internes des binders imbriqués, qui +en héritent. Pour **brancher** sur un échec du binder — le mapper vers un statut HTTP, par exemple — comparez le code de l’erreur symboliquement, jamais à une chaîne littérale : utilisez le @@ -501,7 +605,7 @@ la définition sur `RequestBindingError.DefaultArgumentRequired` / `DefaultArgum ## Configurer le défaut pour toute l’application -`Bind.PropertiesOf(request)` lie avec `RequestBinderOptions.Default`. Ce défaut est +`Bind.Request(envelope)` lie avec `RequestBinderOptions.Default`. Ce défaut est configurable **une seule fois, au démarrage de l’application** — un hôte entier (ASP.NET, une CLI, un worker) partage ainsi une politique de nommage et un jeu de définitions d’erreurs structurelles sans faire transiter d’options par chaque appel, et @@ -515,7 +619,7 @@ RequestBinderOptions.Default = new RequestBinderOptions( argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))); // n’importe où ensuite — aucune option à faire transiter : -var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +RequestBinder bind = Bind.Request(PlaceBookingError.Invalid); ``` Le défaut est **gelé à la première utilisation** : la première liaison le lit, et toute @@ -551,7 +655,9 @@ 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. +arrive comme `null` et que le binder puisse le distinguer d’une vraie valeur. Il en +va de même pour un **argument** de type valeur : passez un `int?` / `Guid?` à `From`, +pour que l’absence soit un vrai `null` plutôt qu’une valeur par défaut. ## Exemple complet @@ -560,16 +666,16 @@ 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); + RequestBinder bind = Bind.Request(PlaceBookingError.Invalid); + PropertySource body = bind.PropertiesOf(request); - 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); + RequiredField email = body.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + RequiredField reference = body.SimpleProperty(r => r.Reference).AsRequired(); + RequiredField currency = body.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR"); + OptionalValueField maxNights = body.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse); + RequiredField stay = body.ComplexProperty(r => r.Stay).FailWith(PlaceBookingError.StayInvalid).AsRequired(BindStay); + RequiredField> tags = body.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse); + RequiredField> guests = body.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( @@ -583,8 +689,8 @@ public Outcome BindBooking(BookingRequest request) { } ``` -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 +Un seul modèle d’erreur structuré court du fil jusqu’à la commande : une entrée +malformée 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. @@ -606,7 +712,8 @@ Check.That(outcome.Error!.InnerErrors.Select(e => e.Code.ToString())) 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é. +échoué. Pour un argument hors-DTO, assertez aussi sa provenance, via +`RequestBindingError.ArgumentSourceKey`. ## Exemples exécutables @@ -627,8 +734,10 @@ documentation vivante. 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, +- chaque entrée est liée avec la variante correspondant à son contrat — requise, optionnelle-avec-repli, ou optionnelle ; +- les arguments hors-DTO indiquent leur source, et un argument de type valeur est passé + en nullable (`int?`, `Guid?`) pour que l’absence reste distinguable ; - 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` ; @@ -636,7 +745,7 @@ Avant d’approuver une liaison, vérifiez que : - 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. + chemins d’argument (et la provenance, pour les arguments). ---