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
Expand Up @@ -325,7 +325,7 @@ public void CrossBinderReadMessageNamesTheCause() {

[Fact(DisplayName = "REQUEST_ARGUMENT_REQUIRED carries its public summary, and its detailed and diagnostic messages name the argument path.")]
public void RequiredArgumentErrorCarriesItsMessages() {
PrimaryPortError error = RequestBindingError.ArgumentRequired("GuestEmail");
PrimaryPortError error = RequestBindingError.ArgumentRequired(RequestBindingError.DefaultArgumentRequiredCode, "GuestEmail");

Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(error.ShortMessage).IsEqualTo("A required argument is missing.");
Expand All @@ -336,7 +336,7 @@ public void RequiredArgumentErrorCarriesItsMessages() {

[Fact(DisplayName = "REQUEST_ARGUMENT_INVALID carries its public summary, its detailed message names the path, and it wraps the cause.")]
public void InvalidArgumentErrorCarriesItsMessages() {
PrimaryPortError error = RequestBindingError.ArgumentInvalid("GuestEmail", BookingDomainError.EmailInvalid("x"));
PrimaryPortError error = RequestBindingError.ArgumentInvalid(RequestBindingError.DefaultArgumentInvalidCode, "GuestEmail", BookingDomainError.EmailInvalid("x"));

Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(error.ShortMessage).IsEqualTo("An argument is invalid.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#region Usings declarations

using NFluent;

#endregion

namespace FirstClassErrors.RequestBinder.UnitTests;

public sealed class StructuralCodeOverrideTests {

private static readonly ErrorCode AcmeRequired = ErrorCode.Create("ACME_ARGUMENT_REQUIRED");
private static readonly ErrorCode AcmeInvalid = ErrorCode.Create("ACME_ARGUMENT_INVALID");

private static RequestBinderOptions CustomCodes() {
return new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider, AcmeRequired, AcmeInvalid);
}

private static BookingRequest Request(string? guestEmail = "a@b.c", StayDto? stay = null,
IReadOnlyList<string?>? tags = null) {
return new BookingRequest(guestEmail, "REF-1", null, null, stay, tags, null);
}

private static Outcome<Stay> BindStay(RequestBinder<StayDto> stay) {
RequiredField<BookingDate> checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
RequiredField<BookingDate> checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);

return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut)));
}

// ── Defaults are unchanged ────────────────────────────────────────────────────────────────────────────

[Fact(DisplayName = "The public default codes are exposed and are REQUEST_ARGUMENT_REQUIRED / REQUEST_ARGUMENT_INVALID.")]
public void PublicDefaultCodesAreExposed() {
Check.That(RequestBindingError.DefaultArgumentRequiredCode.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(RequestBindingError.DefaultArgumentInvalidCode.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
}

[Fact(DisplayName = "Options without custom codes keep the default structural codes.")]
public void OptionsDefaultToTheStructuralCodes() {
Check.That(RequestBinderOptions.Default.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
Check.That(RequestBinderOptions.Default.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();

var namesOnly = new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider);
Check.That(namesOnly.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue();
Check.That(namesOnly.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue();
}

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

bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);

Check.That(bind.New(_ => "x").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
}

// ── Overridden codes flow through every structural path ───────────────────────────────────────────────

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

bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);

Error error = bind.New(_ => "x").Error!.InnerErrors.Single();
Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("GuestEmail");
}

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

bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);

Error error = bind.New(_ => "x").Error!.InnerErrors.Single();
Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_INVALID");
Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID");
}

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

bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);

IReadOnlyList<Error> errors = bind.New(_ => "x").Error!.InnerErrors;
Check.That(errors.Select(e => e.Code.ToString())).ContainsExactly("ACME_ARGUMENT_REQUIRED", "ACME_ARGUMENT_INVALID");
Check.That(errors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Tags[1]", "Tags[2]");
}

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

bind.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");
Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("Stay");
}

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

bind.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.".
Check.That(stayEnvelope.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly("ACME_ARGUMENT_INVALID", "ACME_ARGUMENT_REQUIRED");
Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Stay.CheckIn", "Stay.CheckOut");
}

// ── The #147 crux: branch symbolically, never on a string ─────────────────────────────────────────────

[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);
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);
Error owned = overridden.New(_ => "x").Error!.InnerErrors.Single();
Check.That(owned.Code == AcmeRequired).IsTrue();
}

}
6 changes: 3 additions & 3 deletions FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ public RequiredField<TProperty> AsRequired<TProperty>(Func<RequestBinder<TArgume
if (bindNested is null) { throw new ArgumentNullException(nameof(bindNested)); }

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

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

RequestBinder<TArgument> nested = NestedBinder();
Outcome<TProperty> outcome = bindNested(nested);
if (outcome.IsFailure) {
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath));
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalidCode));

return new RequiredField<TProperty>(_binder, default!);
}
Expand All @@ -81,7 +81,7 @@ public OptionalReferenceField<TProperty> AsOptional<TProperty>(Func<RequestBinde
RequestBinder<TArgument> nested = NestedBinder();
Outcome<TProperty> outcome = bindNested(nested);
if (outcome.IsFailure) {
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath));
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalidCode));

return new OptionalReferenceField<TProperty>(_binder, value: null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public RequiredField<IReadOnlyList<TProperty>> AsRequired<TProperty>(Func<Reques
if (bindElement is null) { throw new ArgumentNullException(nameof(bindElement)); }

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

return new RequiredField<IReadOnlyList<TProperty>>(_binder, default!);
}
Expand Down Expand Up @@ -86,15 +86,15 @@ private RequiredField<IReadOnlyList<TProperty>> BindElements<TProperty>(Func<Req
index++;

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

continue;
}

RequestBinder<TArgument> nested = new(element!, _envelope, _binder.Options, elementPath);
Outcome<TProperty> outcome = bindElement(nested);
if (outcome.IsFailure) {
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath));
_binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binder.Options.ArgumentInvalidCode));

continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public RequiredField<IReadOnlyList<TProperty>> AsRequired<TProperty>(Func<TArgum
if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); }

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

return new RequiredField<IReadOnlyList<TProperty>>(_binder, default!);
}
Expand Down Expand Up @@ -80,14 +80,14 @@ private RequiredField<IReadOnlyList<TProperty>> ConvertElements<TProperty>(Func<
index++;

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

continue;
}

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

continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public RequiredField<IReadOnlyList<TProperty>> AsRequired<TProperty>(Func<TArgum
if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); }

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

return new RequiredField<IReadOnlyList<TProperty>>(_binder, default!);
}
Expand Down Expand Up @@ -87,14 +87,14 @@ private RequiredField<IReadOnlyList<TProperty>> ConvertElements<TProperty>(Func<
index++;

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

continue;
}

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

continue;
}
Expand Down
5 changes: 3 additions & 2 deletions FirstClassErrors.RequestBinder/NestedFailure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ internal static class NestedFailure {
/// <param name="error">The failure the nested binding returned.</param>
/// <param name="nestedEnvelope">The envelope the nested binder's build terminal produced, or <c>null</c> when it built none.</param>
/// <param name="argumentPath">The argument path to attach when wrapping.</param>
/// <param name="argumentInvalidCode">The code to wrap under — the parent binder's configured <see cref="RequestBinderOptions.ArgumentInvalidCode" />.</param>
/// <returns>The error to record on the parent binder.</returns>
internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath) {
internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath, ErrorCode argumentInvalidCode) {
return ReferenceEquals(error, nestedEnvelope)
? (PrimaryPortError)error
: RequestBindingError.ArgumentInvalid(argumentPath, error);
: RequestBindingError.ArgumentInvalid(argumentInvalidCode, argumentPath, error);
}

#endregion
Expand Down
10 changes: 10 additions & 0 deletions FirstClassErrors.RequestBinder/RequestBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,16 @@ internal void Record(PrimaryPortError error) {
_errors.Add(error);
}

/// <summary>Records a missing-required-argument failure at <paramref name="argumentPath" /> under this binder's configured <see cref="RequestBinderOptions.ArgumentRequiredCode" />.</summary>
internal void RecordArgumentRequired(string argumentPath) {
Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequiredCode, argumentPath));
}

/// <summary>Records a present-but-invalid-argument failure at <paramref name="argumentPath" /> under this binder's configured <see cref="RequestBinderOptions.ArgumentInvalidCode" />, wrapping <paramref name="cause" />.</summary>
internal void RecordArgumentInvalid(string argumentPath, Error cause) {
Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalidCode, argumentPath, cause));
}

/// <summary>Prepends this binder's argument prefix to a path segment ("CheckIn" -&gt; "Stay.CheckIn").</summary>
internal string PathOf(string argumentName) {
return _argumentPrefix is null ? argumentName : $"{_argumentPrefix}.{argumentName}";
Expand Down
23 changes: 20 additions & 3 deletions FirstClassErrors.RequestBinder/RequestBinderOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,42 @@ public sealed class RequestBinderOptions {

#region Statics members declarations

/// <summary>The default options: argument names are the C# property names.</summary>
/// <summary>The default options: argument names are the C# property names, structural codes are the defaults.</summary>
public static RequestBinderOptions Default { get; } = new(new DefaultArgumentNameProvider());

#endregion

#region Constructors declarations

/// <summary>Instantiates options with the given argument-name provider.</summary>
/// <summary>Instantiates options with the given argument-name provider and, optionally, custom structural error codes.</summary>
/// <param name="argumentNameProvider">The provider resolving the argument name of a bound DTO property.</param>
/// <param name="argumentRequiredCode">
/// The code raised when a required argument is missing. <c>null</c> keeps the default
/// <see cref="RequestBindingError.DefaultArgumentRequiredCode" /> (<c>REQUEST_ARGUMENT_REQUIRED</c>); pass a
/// code of your own catalog's convention to keep the binder's failures consistent with your other error codes.
/// </param>
/// <param name="argumentInvalidCode">
/// The code raised when an argument is present but fails to convert. <c>null</c> keeps the default
/// <see cref="RequestBindingError.DefaultArgumentInvalidCode" /> (<c>REQUEST_ARGUMENT_INVALID</c>).
/// </param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="argumentNameProvider" /> is <c>null</c>.</exception>
public RequestBinderOptions(IArgumentNameProvider argumentNameProvider) {
public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCode? argumentRequiredCode = null, ErrorCode? argumentInvalidCode = null) {
if (argumentNameProvider is null) { throw new ArgumentNullException(nameof(argumentNameProvider)); }

ArgumentNameProvider = argumentNameProvider;
ArgumentRequiredCode = argumentRequiredCode ?? RequestBindingError.DefaultArgumentRequiredCode;
ArgumentInvalidCode = argumentInvalidCode ?? RequestBindingError.DefaultArgumentInvalidCode;
}

#endregion

/// <summary>The provider resolving the argument name of a bound DTO property (see <see cref="IArgumentNameProvider" />).</summary>
public IArgumentNameProvider ArgumentNameProvider { get; }

/// <summary>The code the binder raises when a required argument is missing (defaults to <c>REQUEST_ARGUMENT_REQUIRED</c>).</summary>
public ErrorCode ArgumentRequiredCode { get; }

/// <summary>The code the binder raises when an argument is present but fails to convert (defaults to <c>REQUEST_ARGUMENT_INVALID</c>).</summary>
public ErrorCode ArgumentInvalidCode { get; }

}
Loading