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