Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#region Usings declarations

using NFluent;

#endregion

namespace FirstClassErrors.RequestBinder.UnitTests;

#region Value-type request DTO and value objects

internal sealed record ValueTypeRequest(
int? Quantity,
bool? Flag,
string? Note,
IReadOnlyList<int?>? Quantities,
IReadOnlyList<string?>? Notes,
int Count = 0);

/// <summary>A struct value object built from the underlying <see cref="int" /> — the shape a converter binds against.</summary>
internal readonly struct PositiveQty {

public int Value { get; private init; }

public static Outcome<PositiveQty> From(int n) {
return n > 0
? Outcome<PositiveQty>.Success(new PositiveQty { Value = n })
: Outcome<PositiveQty>.Failure(BookingDomainError.NotAPositiveNumber(n.ToString()));
}

}

/// <summary>A second struct value object over a different underlying value type (<see cref="bool" />).</summary>
internal readonly struct Consent {

public bool Given { get; private init; }

public static Outcome<Consent> From(bool given) {
return Outcome<Consent>.Success(new Consent { Given = given });
}

}

#endregion

public sealed class ValueTypePropertyBindingTests {

private static ValueTypeRequest Request(int? quantity = 5, bool? flag = true, string? note = "hello",
IReadOnlyList<int?>? quantities = null, IReadOnlyList<string?>? notes = null) {
return new ValueTypeRequest(quantity, flag, note, quantities, notes);
}

// ── Scalar value-type property ────────────────────────────────────────────────────────────────────────

[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);

RequiredField<PositiveQty> qty = bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From);

Outcome<int> outcome = bind.New(s => s.Get(qty).Value);
Check.That(outcome.IsSuccess).IsTrue();
Check.That(outcome.GetResultOrThrow()).IsEqualTo(5);
}

[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);

bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From);

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.IsFailure).IsTrue();
Error required = outcome.Error!.InnerErrors.Single();
Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantity");
}

[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);

bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From);

Outcome<string> outcome = bind.New(_ => "never");
Error invalid = outcome.Error!.InnerErrors.Single();
Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NOT_A_POSITIVE_NUMBER");
}

[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<PositiveQty> some = present.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<PositiveQty> none = absent.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);

RequiredField<PositiveQty> defaulted = absent.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);

RequiredField<Consent> consent = bind.SimpleProperty(r => r.Flag).AsRequired(Consent.From);

Check.That(bind.New(s => s.Get(consent).Given).GetResultOrThrow()).IsTrue();
}

// ── List of value-type elements ───────────────────────────────────────────────────────────────────────

[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);

RequiredField<IReadOnlyList<PositiveQty>> qtys = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);

Outcome<int> outcome = bind.New(s => s.Get(qtys).Sum(q => q.Value));
Check.That(outcome.IsSuccess).IsTrue();
Check.That(outcome.GetResultOrThrow()).IsEqualTo(6);
}

[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);

bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);

Error required = bind.New(_ => "never").Error!.InnerErrors.Single();
Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantities[1]");
}

[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);

bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);

Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single();
Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("Quantities[1]");
}

[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<IReadOnlyList<PositiveQty>> empty = optional.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);
Check.That(required.New(_ => "never").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
}

// ── Regressions: reference/string paths keep resolving to the original overloads ──────────────────────

[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);

RequiredField<EmailAddress> email = bind.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);

RequiredField<IReadOnlyList<Tag>> tags = bind.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);

Check.ThatCode(() => bind.SimpleProperty(r => r.Count)).Throws<ArgumentException>();
}

}
108 changes: 108 additions & 0 deletions FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
namespace FirstClassErrors.RequestBinder;

/// <summary>
/// Binds a list request property whose elements are a nullable <b>value type</b> (e.g. <c>int?</c>), each converted
/// by a value-object converter over the underlying non-nullable type. Each failing element is recorded
/// individually, under its indexed path (<c>Quantities[2]</c>), so one bad element never hides the others.
/// </summary>
/// <remarks>
/// The value-type counterpart of <see cref="ListOfSimplePropertiesConverter{TRequest, TArgument}" />: because a
/// <c>Nullable&lt;TArgument&gt;</c> 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 (<c>element.Value</c>) before conversion.
/// </remarks>
/// <typeparam name="TRequest">The type of the request DTO.</typeparam>
/// <typeparam name="TArgument">The underlying (non-nullable) value type of the DTO list elements.</typeparam>
public sealed class ListOfSimpleValuePropertiesConverter<TRequest, TArgument> where TArgument : struct {

#region Fields declarations

private readonly RequestBinder<TRequest> _binder;
private readonly string _argumentPath;
private readonly IEnumerable<TArgument?>? _values;
private readonly bool _isMissing;

#endregion

#region Constructors declarations

internal ListOfSimpleValuePropertiesConverter(RequestBinder<TRequest> binder, string argumentPath, IEnumerable<TArgument?>? values, bool isMissing) {
_binder = binder;
_argumentPath = argumentPath;
_values = values;
_isMissing = isMissing;
}

#endregion

/// <summary>
/// Binds a required list: a missing list records <c>REQUEST_ARGUMENT_REQUIRED</c>; each failing element
/// records <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path, and a <c>null</c> element records
/// <c>REQUEST_ARGUMENT_REQUIRED</c>.
/// </summary>
/// <typeparam name="TProperty">The type of the element value object.</typeparam>
/// <param name="convertElement">The value-object converter applied to each element's underlying value.</param>
/// <returns>The bound field token.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="convertElement" /> is <c>null</c>.</exception>
public RequiredField<IReadOnlyList<TProperty>> AsRequired<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); }

if (_isMissing) {
_binder.Record(RequestBindingError.ArgumentRequired(_argumentPath));

return new RequiredField<IReadOnlyList<TProperty>>(_binder, default!);
}

return ConvertElements(convertElement);
}

/// <summary>
/// Binds an optional list: absent yields an <b>empty</b> list (never <c>null</c>) and records nothing; each
/// failing element of a present list still records <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path.
/// </summary>
/// <typeparam name="TProperty">The type of the element value object.</typeparam>
/// <param name="convertElement">The value-object converter applied to each element's underlying value.</param>
/// <returns>The bound field token.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="convertElement" /> is <c>null</c>.</exception>
public RequiredField<IReadOnlyList<TProperty>> AsOptional<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); }

if (_isMissing) {
IReadOnlyList<TProperty> empty = new List<TProperty>();

return new RequiredField<IReadOnlyList<TProperty>>(_binder, empty);
}

return ConvertElements(convertElement);
}

private RequiredField<IReadOnlyList<TProperty>> ConvertElements<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
List<TProperty> converted = new();
int index = 0;

foreach (TArgument? element in _values!) {
string elementPath = $"{_argumentPath}[{index}]";
index++;

if (element is null) {
_binder.Record(RequestBindingError.ArgumentRequired(elementPath));

continue;
}

Outcome<TProperty> outcome = convertElement(element.Value);
if (outcome.IsFailure) {
_binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!));

continue;
}

converted.Add(outcome.GetResultOrThrow());
}

// The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded —
// i.e. only when every element converted — so `converted` is the complete list exactly when it is observed.
return new RequiredField<IReadOnlyList<TProperty>>(_binder, converted);
}

}
43 changes: 43 additions & 0 deletions FirstClassErrors.RequestBinder/RequestBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ public SimplePropertyConverter<TRequest, TArgument> SimpleProperty<TArgument>(Ex
return new SimplePropertyConverter<TRequest, TArgument>(this, path, (TArgument?)value, value is null);
}

/// <summary>
/// Selects a scalar <b>value-type</b> property declared nullable (e.g. <c>int?</c>), converted by a value-object
/// converter over the <b>underlying, non-nullable type</b> (<c>Func&lt;int, Outcome&lt;T&gt;&gt;</c>).
/// </summary>
/// <remarks>
/// A nullable value-type property surfaces its underlying type here, not its <see cref="Nullable{T}" />: a
/// converter written against the underlying type — a value-object factory such as <c>PositiveInt.From</c> — binds
/// as a method group, exactly as a reference-type converter does on the reference overload. A missing value (a
/// <c>null</c> property) is still distinguished from a legitimate default and is handled by the <c>AsRequired</c>
/// / <c>AsOptional</c> 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 <c>class</c> / <c>struct</c> constraint, which C#
/// does not treat as an overload); the two selectors carry genuinely different parameter types
/// (<c>TArgument</c> versus <c>Nullable&lt;TArgument&gt;</c>), so they coexist.
/// </remarks>
/// <typeparam name="TArgument">The underlying (non-nullable) value type of the DTO property.</typeparam>
/// <param name="selector">A direct property access on a nullable value-type property (e.g. <c>r =&gt; r.MaxNights</c>).</param>
/// <returns>The converter stage offering <c>AsRequired</c> / <c>AsOptional</c> and their variants.</returns>
public SimplePropertyConverter<TRequest, TArgument> SimpleProperty<TArgument>(Expression<Func<TRequest, TArgument?>> selector) where TArgument : struct {
(string path, object? value) = ResolveArgument(selector);

return new SimplePropertyConverter<TRequest, TArgument>(this, path, value is null ? default : (TArgument)value, value is null);
}

/// <summary>
/// Selects a complex property, bound by a nested binder. Declare the nested envelope next, with
/// <see cref="ComplexPropertyEnvelopeStage{TRequest, TArgument}.FailWith" />.
Expand All @@ -120,6 +143,26 @@ public ListOfSimplePropertiesConverter<TRequest, TArgument> ListOfSimpleProperti
return new ListOfSimplePropertiesConverter<TRequest, TArgument>(this, path, (IEnumerable<TArgument?>?)value, value is null);
}

/// <summary>
/// Selects a list property whose elements are a nullable <b>value type</b> (e.g. <c>int?</c>), each converted by
/// a value-object converter over the <b>underlying, non-nullable type</b> (<c>Func&lt;int, Outcome&lt;T&gt;&gt;</c>).
/// </summary>
/// <remarks>
/// 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 <c>null</c> element is still recorded as a
/// missing argument under its indexed path. It exists for the same reason as the value-type
/// <c>SimpleProperty</c> overload — an <c>IEnumerable&lt;TArgument&gt;</c> selector and an
/// <c>IEnumerable&lt;Nullable&lt;TArgument&gt;&gt;</c> selector are distinct parameter types, so the two coexist.
/// </remarks>
/// <typeparam name="TArgument">The underlying (non-nullable) value type of the DTO list elements.</typeparam>
/// <param name="selector">A direct property access on a list of nullable value types (e.g. <c>r =&gt; r.Quantities</c>).</param>
/// <returns>The converter stage offering <c>AsRequired</c> / <c>AsOptional</c>.</returns>
public ListOfSimpleValuePropertiesConverter<TRequest, TArgument> ListOfSimpleProperties<TArgument>(Expression<Func<TRequest, IEnumerable<TArgument?>?>> selector) where TArgument : struct {
(string path, object? value) = ResolveArgument(selector);

return new ListOfSimpleValuePropertiesConverter<TRequest, TArgument>(this, path, (IEnumerable<TArgument?>?)value, value is null);
}

/// <summary>
/// Selects a list property whose elements are bound by a nested binder (one per element). Declare the
/// per-element envelope next, with
Expand Down
Loading
Loading