From e7193271e9be5c6c3c3d5351fb083c01d7336da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 10:48:10 +0000 Subject: [PATCH 1/2] fix(binder): make the structural error codes configurable RequestBindingError exposed no referenceable member: its two codes (REQUEST_ARGUMENT_REQUIRED / REQUEST_ARGUMENT_INVALID) were private and its factories internal, so a consumer could only branch on a string literal, and an API that prefixes its codes was stuck with two foreign ones the binder imposed. Carry the two codes on RequestBinderOptions (defaulting to the current values, exposed publicly as RequestBindingError.Default*Code) so a consumer overrides them once at the entry point; the binder helpers and NestedFailure source the configured code, so every structural failure -- scalars, list elements, and nested binders (which inherit the options) -- uses it. Factories stay internal: a consumer recognises these errors, it does not manufacture them. Refs: #147 --- .../BindingContractTests.cs | 4 +- .../StructuralCodeOverrideTests.cs | 134 ++++++++++++++++++ .../ComplexPropertyConverter.cs | 6 +- .../ListOfComplexPropertiesConverter.cs | 6 +- .../ListOfSimplePropertiesConverter.cs | 6 +- .../ListOfSimpleValuePropertiesConverter.cs | 6 +- .../NestedFailure.cs | 5 +- .../RequestBinder.cs | 10 ++ .../RequestBinderOptions.cs | 23 ++- .../RequestBindingError.cs | 27 +++- .../SimplePropertyConverter.cs | 6 +- doc/handwritten/for-users/RequestBinder.en.md | 31 ++++ doc/handwritten/for-users/RequestBinder.fr.md | 32 +++++ 13 files changed, 268 insertions(+), 28 deletions(-) create mode 100644 FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index 9c401dbf..c7bad911 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs @@ -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."); @@ -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."); diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs new file mode 100644 index 00000000..8b9e6440 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs @@ -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? tags = null) { + return new BookingRequest(guestEmail, "REF-1", null, null, stay, tags, null); + } + + private static Outcome BindStay(RequestBinder stay) { + RequiredField checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse); + RequiredField checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse); + + return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut))); + } + + // ── 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 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(); + } + +} diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs index 67294c8f..53abfce2 100644 --- a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -48,7 +48,7 @@ public RequiredField AsRequired(Func(_binder, default!); } @@ -56,7 +56,7 @@ public RequiredField AsRequired(Func nested = NestedBinder(); Outcome 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(_binder, default!); } @@ -81,7 +81,7 @@ public OptionalReferenceField AsOptional(Func nested = NestedBinder(); Outcome 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(_binder, value: null); } diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs index 94ce653e..00953b18 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -47,7 +47,7 @@ public RequiredField> AsRequired(Func>(_binder, default!); } @@ -84,7 +84,7 @@ private RequiredField> BindElements(Func> BindElements(Func nested = new(element!, _envelope, _binder.Options, elementPath); Outcome 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; } diff --git a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs index b0c587bd..795e3963 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -41,7 +41,7 @@ public RequiredField> AsRequired(Func>(_binder, default!); } @@ -78,14 +78,14 @@ private RequiredField> ConvertElements(Func< index++; if (element is null) { - _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); + _binder.RecordArgumentRequired(elementPath); continue; } Outcome outcome = convertElement(element!); if (outcome.IsFailure) { - _binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!)); + _binder.RecordArgumentInvalid(elementPath, outcome.Error!); continue; } diff --git a/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs index a9297bd8..122173dc 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs @@ -48,7 +48,7 @@ public RequiredField> AsRequired(Func>(_binder, default!); } @@ -85,14 +85,14 @@ private RequiredField> ConvertElements(Func< index++; if (element is null) { - _binder.Record(RequestBindingError.ArgumentRequired(elementPath)); + _binder.RecordArgumentRequired(elementPath); continue; } Outcome outcome = convertElement(element.Value); if (outcome.IsFailure) { - _binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!)); + _binder.RecordArgumentInvalid(elementPath, outcome.Error!); continue; } diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs index 8dd138c9..34aaa8fd 100644 --- a/FirstClassErrors.RequestBinder/NestedFailure.cs +++ b/FirstClassErrors.RequestBinder/NestedFailure.cs @@ -21,11 +21,12 @@ internal static class NestedFailure { /// The failure the nested binding returned. /// The envelope the nested binder's build terminal produced, or null when it built none. /// The argument path to attach when wrapping. + /// The code to wrap under — the parent binder's configured . /// The error to record on the parent binder. - 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 diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index 6e83a449..087ba298 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -232,6 +232,16 @@ 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.ArgumentRequiredCode, 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.ArgumentInvalidCode, argumentPath, cause)); + } + /// 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}"; diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs index e25a7dc3..a983a17f 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -9,20 +9,31 @@ public sealed class RequestBinderOptions { #region Statics members declarations - /// The default options: argument names are the C# property names. + /// The default options: argument names are the C# property names, structural codes are the defaults. public static RequestBinderOptions Default { get; } = new(new DefaultArgumentNameProvider()); #endregion #region Constructors declarations - /// Instantiates options with the given argument-name provider. + /// Instantiates options with the given argument-name provider and, optionally, custom structural error codes. /// The provider resolving the argument name of a bound DTO property. + /// + /// The code raised when a required argument is missing. null keeps the default + /// (REQUEST_ARGUMENT_REQUIRED); pass a + /// code of your own catalog's convention to keep the binder's failures consistent with your other error codes. + /// + /// + /// The code raised when an argument is present but fails to convert. null keeps the default + /// (REQUEST_ARGUMENT_INVALID). + /// /// Thrown when is null. - 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 @@ -30,4 +41,10 @@ public RequestBinderOptions(IArgumentNameProvider argumentNameProvider) { /// The provider resolving the argument name of a bound DTO property (see ). public IArgumentNameProvider ArgumentNameProvider { get; } + /// The code the binder raises when a required argument is missing (defaults to REQUEST_ARGUMENT_REQUIRED). + public ErrorCode ArgumentRequiredCode { get; } + + /// The code the binder raises when an argument is present but fails to convert (defaults to REQUEST_ARGUMENT_INVALID). + public ErrorCode ArgumentInvalidCode { get; } + } diff --git a/FirstClassErrors.RequestBinder/RequestBindingError.cs b/FirstClassErrors.RequestBinder/RequestBindingError.cs index 8b3d4eb7..20478edb 100644 --- a/FirstClassErrors.RequestBinder/RequestBindingError.cs +++ b/FirstClassErrors.RequestBinder/RequestBindingError.cs @@ -16,14 +16,28 @@ public static class RequestBindingError { #region Statics members declarations + /// + /// The default code raised when a required argument is missing (REQUEST_ARGUMENT_REQUIRED), unless a + /// consumer overrides it through . Exposed so a + /// consumer can branch on the binder's structural failures symbolically rather than by string. + /// + public static ErrorCode DefaultArgumentRequiredCode => Code.ArgumentRequired; + + /// + /// The default code raised when an argument is present but fails to convert (REQUEST_ARGUMENT_INVALID), + /// unless a consumer overrides it through . Exposed so a + /// consumer can branch on the binder's structural failures symbolically rather than by string. + /// + public static ErrorCode DefaultArgumentInvalidCode => Code.ArgumentInvalid; + /// /// The argument at is required but was absent from the request. /// /// Non-transient by nature: resubmitting the same request cannot succeed. [DocumentedBy(nameof(ArgumentRequiredDocumentation))] - internal static PrimaryPortError ArgumentRequired(string argumentPath) { + internal static PrimaryPortError ArgumentRequired(ErrorCode code, string argumentPath) { return PrimaryPortError.Create( - Code.ArgumentRequired, + code, $"Argument '{argumentPath}' is required but was missing from the request.", Transience.NonTransient, ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) @@ -36,6 +50,7 @@ internal static PrimaryPortError ArgumentRequired(string argumentPath) { /// The argument at was present but failed to convert; the conversion error is /// attached as the inner error. /// + /// The structural code to raise — the binder's configured code, defaulting to . /// The full path of the failing argument. /// /// The error the converter failed with. Converters must fail with a or a @@ -44,7 +59,7 @@ internal static PrimaryPortError ArgumentRequired(string argumentPath) { /// /// Thrown when belongs to another error family. [DocumentedBy(nameof(ArgumentInvalidDocumentation))] - internal static PrimaryPortError ArgumentInvalid(string argumentPath, Error cause) { + internal static PrimaryPortError ArgumentInvalid(ErrorCode code, string argumentPath, Error cause) { PrimaryPortInnerErrors innerErrors = new(); switch (cause) { case DomainError domainError: @@ -61,7 +76,7 @@ internal static PrimaryPortError ArgumentInvalid(string argumentPath, Error caus } return PrimaryPortError.Create( - Code.ArgumentInvalid, + code, $"Argument '{argumentPath}' is invalid.", innerErrors, ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) @@ -80,7 +95,7 @@ private static ErrorDocumentation ArgumentRequiredDocumentation() { .AndDiagnostic("The argument name reported in the path does not match the wire format (the binder uses the C# property name unless an IArgumentNameProvider is configured).", ErrorOrigin.Internal, "Configure an IArgumentNameProvider aligned with the serializer naming policy.") - .WithExamples(() => ArgumentRequired("Guests[1].FirstName")); + .WithExamples(() => ArgumentRequired(Code.ArgumentRequired, "Guests[1].FirstName")); } private static ErrorDocumentation ArgumentInvalidDocumentation() { @@ -93,7 +108,7 @@ private static ErrorDocumentation ArgumentInvalidDocumentation() { .AndDiagnostic("The converter rejects values the contract intends to accept (over-strict parsing rule).", ErrorOrigin.Internal, "Review the value object's parsing rule against the API contract.") - .WithExamples(() => ArgumentInvalid("GuestEmail", SampleCause())); + .WithExamples(() => ArgumentInvalid(Code.ArgumentInvalid, "GuestEmail", SampleCause())); } /// A representative converter failure used only by the documentation example above. diff --git a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs index 23cb94b6..d01b55c6 100644 --- a/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/SimplePropertyConverter.cs @@ -56,7 +56,7 @@ public RequiredField AsRequired(FuncThe bound field token. public RequiredField AsRequired() { if (_isMissing) { - _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + _binder.RecordArgumentRequired(_argumentPath); return new RequiredField(_binder, default!); } @@ -142,7 +142,7 @@ public OptionalValueField AsOptionalValue(Func RequiredMissing() { - _binder.Record(RequestBindingError.ArgumentRequired(_argumentPath)); + _binder.RecordArgumentRequired(_argumentPath); return new RequiredField(_binder, default!); } @@ -158,7 +158,7 @@ private RequiredField RecordIfInvalid(Outcome o } private void RecordInvalid(Error cause) { - _binder.Record(RequestBindingError.ArgumentInvalid(_argumentPath, cause)); + _binder.RecordArgumentInvalid(_argumentPath, cause); } } diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md index b0dd0826..40ac95b2 100644 --- a/doc/handwritten/for-users/RequestBinder.en.md +++ b/doc/handwritten/for-users/RequestBinder.en.md @@ -393,6 +393,37 @@ it once (for example at application startup) and reuse it for every request. The library deliberately ships only the default (C# property names): which serializer names the wire keys is the host's knowledge, not the library's. +## Structural error codes + +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 +fails to convert. These are the only codes the binder manufactures; every other code +in a failure tree is yours (the converters' errors, and the envelope). + +Because those two codes land in **your** catalog, an API that prefixes its codes can +override them so they stay consistent with the rest — set them on the options, once, +alongside the naming policy: + +```csharp +var options = new RequestBinderOptions( + new SnakeCaseNames(), + argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), + argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + +var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +``` + +The configured codes flow through **every** structural failure — scalars, list +elements, 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, +or, when you keep the defaults, the ones the binder exposes. + +```csharp +if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422; } +``` + ## The bug channel: what throws vs what is collected The binder draws a hard line between a **client error** (recorded, surfaced once diff --git a/doc/handwritten/for-users/RequestBinder.fr.md b/doc/handwritten/for-users/RequestBinder.fr.md index f86dac01..c8a7d96e 100644 --- a/doc/handwritten/for-users/RequestBinder.fr.md +++ b/doc/handwritten/for-users/RequestBinder.fr.md @@ -407,6 +407,38 @@ chaque requête. La bibliothèque ne fournit délibérément que le défaut (nom propriété C#) : quel sérialiseur nomme les clés du fil relève de la connaissance de l’hôte, pas de la bibliothèque. +## Codes d’erreur structurels + +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 seuls codes que le binder fabrique ; tout autre +code d’un arbre d’échec est le vôtre (les erreurs des convertisseurs, et l’enveloppe). + +Comme ces deux codes atterrissent dans **votre** catalogue, une API qui préfixe ses +codes peut les surcharger pour qu’ils restent cohérents avec le reste — définissez-les +sur les options, une seule fois, à côté de la politique de nommage : + +```csharp +var options = new RequestBinderOptions( + new SnakeCaseNames(), + argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), + argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + +var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid); +``` + +Les codes configurés se propagent à **chaque** échec structurel — scalaires, éléments +de liste, 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 +code que vous avez configuré, ou, quand vous gardez les défauts, ceux que le binder +expose. + +```csharp +if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422; } +``` + ## Le canal des bugs : ce qui lève vs ce qui est collecté Le binder trace une frontière nette entre une **erreur client** (enregistrée, From 4c29578a458346579af44bedc8cccb0ecaf7c4cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 10:48:10 +0000 Subject: [PATCH 2/2] docs(binder): draft ADR-0014 for configurable structural codes Record the public-API-contract decision behind #147: the binder's two structural error codes are carried on RequestBinderOptions, defaulting to the current codes (exposed publicly), so a consumer overrides them once. Drafted as Proposed and indexed (EN + FR); the maintainer decides its status. The catalog-drift half is deferred (relates to #140). Refs: #147 --- ...-structural-error-codes-configurable.fr.md | 141 ++++++++++++++++++ ...ers-structural-error-codes-configurable.md | 127 ++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 269 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.md diff --git a/doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.fr.md b/doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.fr.md new file mode 100644 index 00000000..f5945598 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.fr.md @@ -0,0 +1,141 @@ +# ADR-0014 | Rendre les codes d'erreur structurels du binder configurables + +🌍 🇬🇧 [English](0014-make-the-binders-structural-error-codes-configurable.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-18 +**Décideurs :** Reefact + +## Contexte + +* Le binder fabrique exactement deux erreurs structurelles qui lui sont propres : un + argument requis absent (`REQUEST_ARGUMENT_REQUIRED`) et un argument présent mais + invalide (`REQUEST_ARGUMENT_INVALID`). Toute autre erreur d'un arbre d'échec vient de + l'application — les erreurs des convertisseurs et l'enveloppe. +* Ces deux erreurs remontent dans l'enveloppe d'échec du consommateur, et donc dans sa + surface d'erreurs et son catalogue généré. +* Avant cette décision, les deux codes étaient des constantes `private` en dur et les + factories étaient `internal` : `RequestBindingError` n'exposait donc aucun membre + référençable. Un consommateur devant brancher sur un code structurel — pour le mapper + vers un statut HTTP, par exemple — ne pouvait comparer qu'une chaîne littérale, ce que + le modèle d'erreurs codées de la bibliothèque existe précisément pour éviter. +* Une application donne couramment à tous ses codes d'erreur une convention unique (un + préfixe ou un schéma partagé) ; deux codes d'apparence étrangère injectés par le binder + brisent cette convention. +* `RequestBinderOptions` est la configuration unique, immuable, du binder, au point + d'entrée, fixée une fois avant le début de la liaison (ADR-0012) et héritée par les + binders imbriqués. +* `ErrorCode` a une égalité de valeur : un consommateur peut donc brancher sur un code + qu'il détient symboliquement plutôt que par chaîne. +* Le catalogue généré du paquet binder lui-même est produit statiquement à partir des + factories documentées, qui décrivent les codes par défaut. +* La bibliothèque est en pré-version, non publiée sur NuGet et sans consommateur externe : + déplacer l'endroit où les codes sont définis n'entraîne aucun coût de migration en aval. + +## Décision + +Les deux codes d'erreur structurels du binder sont portés par `RequestBinderOptions`, par +défaut `REQUEST_ARGUMENT_REQUIRED` / `REQUEST_ARGUMENT_INVALID` (dont les défauts sont +exposés publiquement), pour qu'un consommateur les surcharge une seule fois au point +d'entrée et que chaque échec structurel — y compris celui d'un binder imbriqué — utilise +les codes configurés. + +## Justification + +* Porter les codes sur le sac d'options laisse un consommateur les aligner avec la + convention de son catalogue, réglant l'incohérence qu'un préfixe étranger figé + créerait — et cela réutilise exactement le mécanisme déjà employé par la politique de + nommage (options, fixées une fois au point d'entrée, héritées par les binders + imbriqués, ADR-0012) : il y a donc un seul endroit et une seule durée de vie pour toute + la configuration du binder. +* Surcharger les codes résout le problème du branchement par *appropriation* plutôt que + par simple exposition : un consommateur qui définit les codes détient les symboles + `ErrorCode` sur lesquels il branche, donc il ne compare jamais une chaîne ; un + consommateur qui garde les défauts branche sur les défauts exposés publiquement. Dans + les deux cas, la promesse du modèle d'erreurs codées — référencer les codes + symboliquement — est enfin tenue aussi pour les erreurs propres au binder. +* Garder les factories `internal` tout en n'exposant que les codes correspond à la façon + dont un consommateur rencontre ces erreurs : il les *reconnaît* (il a besoin du code) + mais ne les *fabrique* pas (c'est le binder), donc la surface de fabrication reste + fermée. +* Choisir les codes actuels par défaut et les exposer publiquement laisse le comportement + zéro-configuration inchangé et le catalogue propre du paquet binder exact, puisque les + factories documentées décrivent toujours ces défauts. +* Le statut de pré-version signifie que la surface des options est arrêtée maintenant, + quand il n'y a aucun consommateur à migrer. + +## Alternatives considérées + +### Exposer les deux codes en lecture seule, sans surcharge + +Considérée parce que c'est le plus petit changement qui tue le branchement par chaîne +magique : un consommateur référence des constantes `ErrorCode` publiques au lieu de +littéraux. + +Rejetée parce qu'elle laisse l'incohérence plus profonde décrite dans le Contexte — le +binder impose toujours deux codes préfixés étrangers au catalogue du consommateur. Lire +les codes laisse un consommateur les reconnaître ; cela ne lui laisse pas les faire +cadrer. + +### Des prédicats (`IsRequestArgumentRequired` / `IsRequestArgumentInvalid`) + +Considérés parce qu'ils se lisent par l'intention et encapsulent la comparaison. + +Rejetés comme moins composables et toujours non-surchargeables : un booléen ne peut pas +indexer une table code-vers-statut, et il ne fait rien pour la cohérence du catalogue. Des +`ErrorCode` surchargeables sur les options couvrent les deux besoins, et un prédicat peut +toujours se poser par-dessus plus tard. + +### Une abstraction de politique de codes (un `IBindingCodeProvider` calqué sur `IArgumentNameProvider`) + +Considérée pour la symétrie avec la politique de nom d'argument. + +Rejetée comme surface inutile : deux propriétés `ErrorCode` expriment la même intention, +et un préfixe est un `ErrorCode.Create` qu'un consommateur écrit une fois ; une interface +ajouterait un type sans ajouter de capacité. + +## Conséquences + +### Positives + +* Les codes structurels du binder peuvent être alignés avec la convention du catalogue du + consommateur, et on branche dessus symboliquement (codes possédés, ou défauts publics) — + fermant les deux moitiés du constat. +* Toute la configuration du binder vit dans un seul objet d'options avec une seule durée + de vie (ADR-0012) ; les codes sont hérités par les binders imbriqués exactement comme la + politique de nommage. +* Le comportement zéro-configuration et le catalogue propre du paquet binder sont + inchangés (les défauts sont préservés et toujours documentés). + +### Négatives + +* `RequestBinderOptions` gagne deux propriétés et deux paramètres de constructeur + optionnels, et `RequestBindingError` gagne deux membres publics de code par défaut à + documenter. +* La **documentation** d'un code surchargé est une préoccupation distincte : le catalogue + du paquet binder documente les défauts, donc un consommateur qui surcharge les codes + doit faire apparaître les codes effectifs dans son propre catalogue. + +### Risques + +* Un consommateur qui surcharge les codes à l'exécution mais documente les défauts + dériverait entre le code émis et le code documenté ; atténué seulement en partie ici + (les défauts restent documentés) et reporté à la décision distincte de liaison des + catalogues. + +## Actions de suivi + +* Décider, séparément, comment le catalogue généré d'un consommateur fait apparaître les + codes — éventuellement surchargés — du binder sans dériver de l'exécution (en lien avec + #140). + +## Références + +* ADR-0012 — fixer les options du binder avant le début de la liaison ; la durée de vie + des options que cette décision réutilise. +* ADR-0006 — fournir les valeurs de test arbitraires depuis une source unique + réamorçable ; la position « aucun état ambiant mutable » que l'approche par options + respecte. +* Issue #147 — le constat que cette décision résout. +* Issue #140 — faire apparaître les codes du binder dans le catalogue généré d'un + consommateur (la moitié documentation reportée). diff --git a/doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.md b/doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.md new file mode 100644 index 00000000..848e1762 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0014-make-the-binders-structural-error-codes-configurable.md @@ -0,0 +1,127 @@ +# ADR-0014 | Make the binder's structural error codes configurable + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0014-make-the-binders-structural-error-codes-configurable.fr.md) + +**Status:** Proposed +**Date:** 2026-07-18 +**Decision Makers:** Reefact + +## Context + +* The binder manufactures exactly two structural errors of its own: a missing required + argument (`REQUEST_ARGUMENT_REQUIRED`) and a present-but-invalid argument + (`REQUEST_ARGUMENT_INVALID`). Every other error in a failure tree comes from the + application — the converters' errors and the envelope. +* These two errors surface in the consumer's failure envelope, and therefore in the + consumer's error surface and its generated catalog. +* Before this decision the two codes were hardcoded `private` constants and the factories + were `internal`, so `RequestBindingError` exposed no member a consumer could reference: + a consumer that needed to branch on a structural code — to map it to an HTTP status, + for example — could only compare a string literal, the very thing the library's + coded-error model exists to avoid. +* An application commonly gives all its error codes one convention (a shared prefix or + scheme); two foreign-looking codes injected by the binder break that convention. +* `RequestBinderOptions` is the binder's single, immutable, entry-point configuration, + fixed once before binding begins (ADR-0012) and inherited by nested binders. +* `ErrorCode` has value equality, so a consumer can branch on a code it holds + symbolically rather than by string. +* The binder package's own generated catalog is produced statically from the documented + factories, which describe the default codes. +* The library is pre-release, unpublished on NuGet with no external consumers, so moving + where the codes are set carries no downstream migration cost. + +## Decision + +The binder's two structural error codes are carried on `RequestBinderOptions`, defaulting +to `REQUEST_ARGUMENT_REQUIRED` / `REQUEST_ARGUMENT_INVALID` (whose defaults are exposed +publicly), so a consumer overrides them once at the entry point and every structural +failure — including a nested binder's — uses the configured codes. + +## Rationale + +* Carrying the codes on the options bag lets a consumer align them with its catalog's + convention, resolving the inconsistency a fixed foreign prefix would create — and it + reuses the exact mechanism the naming policy already uses (options, fixed once at the + entry point, inherited by nested binders, ADR-0012), so there is one place and one + lifetime for all binder configuration. +* Overriding the codes solves the branching problem by *ownership* rather than by mere + exposure: a consumer that sets the codes holds the `ErrorCode` symbols it branches on, + so it never compares a string; a consumer that keeps the defaults branches on the + publicly exposed defaults. Either way the coded-error model's promise — reference codes + symbolically — is finally kept for the binder's own errors too. +* Keeping the factories `internal` while exposing only the codes matches how a consumer + meets these errors: it recognises them (needs the code) but does not manufacture them + (the binder does), so the manufacturing surface stays closed. +* Defaulting to the current codes and exposing them publicly keeps the zero-configuration + behaviour unchanged and the binder package's own catalog accurate, since the documented + factories still describe those defaults. +* The pre-release status means the options surface is settled now, when there are no + consumers to migrate. + +## Alternatives Considered + +### Expose the two codes read-only, without an override + +Considered because it is the smallest change that kills the magic-string branching: a +consumer references public `ErrorCode` constants instead of literals. + +Rejected because it leaves the deeper inconsistency the Context describes — the binder +still imposes two foreign-prefixed codes on the consumer's catalog. Reading the codes +lets a consumer recognise them; it does not let the consumer make them fit. + +### Predicates (`IsRequestArgumentRequired` / `IsRequestArgumentInvalid`) + +Considered because they read intent-fully and encapsulate the comparison. + +Rejected as less composable and still non-overridable: a boolean cannot key a +code-to-status map, and it does nothing for catalog consistency. Overridable `ErrorCode`s +on the options subsume both needs, and a predicate can always be layered on later. + +### A code-naming policy abstraction (an `IBindingCodeProvider` mirroring `IArgumentNameProvider`) + +Considered for symmetry with the argument-name policy. + +Rejected as unnecessary surface: two `ErrorCode` properties express the same intent, and +a prefix is one `ErrorCode.Create` a consumer writes once; an interface would add a type +without adding capability. + +## Consequences + +### Positive + +* The binder's structural codes can be aligned with the consumer's catalog convention, + and are branched on symbolically (owned codes, or the public defaults) — closing both + halves of the finding. +* All binder configuration lives in one options object with one lifetime (ADR-0012); the + codes are inherited by nested binders exactly like the naming policy. +* Zero-configuration behaviour and the binder package's own catalog are unchanged (the + defaults are preserved and still documented). + +### Negative + +* `RequestBinderOptions` grows two properties and two optional constructor parameters, and + `RequestBindingError` grows two public default-code members to document. +* The **documentation** of an overridden code is a separate concern: the binder package's + catalog documents the defaults, so a consumer that overrides the codes must surface the + effective codes in its own catalog. + +### Risks + +* A consumer that overrides the codes at runtime but documents the defaults would drift + between the emitted and the documented code; only partially mitigated here (the defaults + stay documented) and deferred to the separate catalog-linking decision. + +## Follow-up Actions + +* Decide, separately, how a consumer's generated catalog surfaces the binder's — possibly + overridden — codes without drifting from runtime (relates to #140). + +## References + +* ADR-0012 — fix the binder options before binding begins; the options lifetime this + decision reuses. +* ADR-0006 — supply arbitrary test values from a single seedable source; the + no-ambient-mutable-state stance the options approach respects. +* Issue #147 — the finding this decision resolves. +* Issue #140 — surfacing the binder's codes in a consumer's generated catalog (the + deferred documentation half). diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 17220ad3..33f969ea 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -187,3 +187,4 @@ Optional supporting material: | [ADR-0011](0011-host-dummies-as-a-standalone-package.md) | Host Dummies as a standalone package in this repository | Proposed | | [ADR-0012](0012-fix-the-binder-options-before-binding-begins.md) | Fix the binder options before binding begins | Accepted | | [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) | Gate distinct collections by cardinality, otherwise by a bounded draw | Proposed | +| [ADR-0014](0014-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Proposed |