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 378dd6cf..b979c46d 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -49,7 +49,7 @@ public RequiredField> AsRequired(Func>(_binder, default!); } @@ -86,7 +86,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 0446709b..031b4857 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimplePropertiesConverter.cs @@ -43,7 +43,7 @@ public RequiredField> AsRequired(Func>(_binder, default!); } @@ -80,14 +80,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 4cd65fb6..3f6e1c4b 100644 --- a/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfSimpleValuePropertiesConverter.cs @@ -50,7 +50,7 @@ public RequiredField> AsRequired(Func>(_binder, default!); } @@ -87,14 +87,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-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md new file mode 100644 index 00000000..f3728553 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.fr.md @@ -0,0 +1,141 @@ +# ADR-0016 | Rendre les codes d'erreur structurels du binder configurables + +🌍 🇬🇧 [English](0016-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/0016-make-the-binders-structural-error-codes-configurable.md b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.md new file mode 100644 index 00000000..6be4df54 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0016-make-the-binders-structural-error-codes-configurable.md @@ -0,0 +1,127 @@ +# ADR-0016 | Make the binder's structural error codes configurable + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0016-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 9608bb59..6ce8c625 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -189,3 +189,4 @@ Optional supporting material: | [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-bind-a-required-list-by-presence-not-cardinality.md) | Bind a required list by presence, not cardinality | Accepted | | [ADR-0015](0015-cap-any-combine-at-arity-eight.md) | Cap Any.Combine at arity eight | Proposed | +| [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Proposed | diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md index 66e801d5..2e9af29f 100644 --- a/doc/handwritten/for-users/RequestBinder.en.md +++ b/doc/handwritten/for-users/RequestBinder.en.md @@ -397,6 +397,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 6cb0957d..6cae81e2 100644 --- a/doc/handwritten/for-users/RequestBinder.fr.md +++ b/doc/handwritten/for-users/RequestBinder.fr.md @@ -411,6 +411,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,