diff --git a/FirstClassErrors.RequestBinder.UnitTests/BinderErrorDefinitionTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BinderErrorDefinitionTests.cs new file mode 100644 index 00000000..11a84104 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/BinderErrorDefinitionTests.cs @@ -0,0 +1,91 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +/// +/// Unit tests for the two configuration value types the structural-error definitions introduce: +/// (a plain public-message carrier) and (a code +/// bundled with its message builder, immutable, derivable through WithCode / WithMessage). +/// +public sealed class BinderErrorDefinitionTests { + + private static readonly ErrorCode SampleCode = ErrorCode.Create("SOME_CODE"); + + private static BindingMessage Message(string argumentPath) { + return new BindingMessage($"short {argumentPath}", $"detailed {argumentPath}"); + } + + // ── BindingMessage ──────────────────────────────────────────────────────────────────────────────────── + + [Fact(DisplayName = "BindingMessage carries the short and detailed messages; the detail defaults to null.")] + public void BindingMessageCarriesBothMessages() { + var full = new BindingMessage("short", "detailed"); + Check.That(full.ShortMessage).IsEqualTo("short"); + Check.That(full.DetailedMessage).IsEqualTo("detailed"); + + var shortOnly = new BindingMessage("short"); + Check.That(shortOnly.DetailedMessage).IsNull(); + } + + [Fact(DisplayName = "BindingMessage never throws on a null short message — coalescing is deferred to the error factory.")] + public void BindingMessageToleratesNullShortMessage() { + Check.ThatCode(() => new BindingMessage(null!)).DoesNotThrow(); + } + + // ── BinderErrorDefinition: construction and accessors ───────────────────────────────────────────────── + + [Fact(DisplayName = "GetMessage invokes the builder with the argument path; Code exposes the code.")] + public void GetMessageInvokesTheBuilder() { + var definition = new BinderErrorDefinition(SampleCode, Message); + + BindingMessage message = definition.GetMessage("Guests[1].FirstName"); + + Check.That(definition.Code).IsEqualTo(SampleCode); + Check.That(message.ShortMessage).IsEqualTo("short Guests[1].FirstName"); + Check.That(message.DetailedMessage).IsEqualTo("detailed Guests[1].FirstName"); + } + + [Fact(DisplayName = "A null code or a null message builder is rejected at construction.")] + public void ConstructorRejectsNulls() { + Check.ThatCode(() => new BinderErrorDefinition(null!, Message)).Throws(); + Check.ThatCode(() => new BinderErrorDefinition(SampleCode, null!)).Throws(); + } + + // ── Immutable derivation: WithCode / WithMessage ────────────────────────────────────────────────────── + + [Fact(DisplayName = "WithCode swaps the code and keeps the message builder, without mutating the original.")] + public void WithCodeSwapsCodeKeepsMessage() { + var original = new BinderErrorDefinition(SampleCode, Message); + var other = ErrorCode.Create("OTHER_CODE"); + + BinderErrorDefinition derived = original.WithCode(other); + + Check.That(derived.Code).IsEqualTo(other); + Check.That(derived.GetMessage("X").ShortMessage).IsEqualTo("short X"); // same builder + Check.That(original.Code).IsEqualTo(SampleCode); // original untouched + } + + [Fact(DisplayName = "WithMessage swaps the message builder and keeps the code, without mutating the original.")] + public void WithMessageSwapsMessageKeepsCode() { + var original = new BinderErrorDefinition(SampleCode, Message); + + BinderErrorDefinition derived = original.WithMessage(argumentPath => new BindingMessage($"new {argumentPath}")); + + Check.That(derived.Code).IsEqualTo(SampleCode); // same code + Check.That(derived.GetMessage("X").ShortMessage).IsEqualTo("new X"); + Check.That(original.GetMessage("X").ShortMessage).IsEqualTo("short X"); // original untouched + } + + [Fact(DisplayName = "WithCode and WithMessage reject nulls.")] + public void WithersRejectNulls() { + var definition = new BinderErrorDefinition(SampleCode, Message); + + Check.ThatCode(() => definition.WithCode(null!)).Throws(); + Check.ThatCode(() => definition.WithMessage(null!)).Throws(); + } + +} diff --git a/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs b/FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs index a62f6d96..ab069754 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(RequestBindingError.DefaultArgumentRequiredCode, "GuestEmail"); + PrimaryPortError error = RequestBindingError.ArgumentRequired(RequestBindingError.DefaultArgumentRequired, "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(RequestBindingError.DefaultArgumentInvalidCode, "GuestEmail", BookingDomainError.EmailInvalid("x")); + PrimaryPortError error = RequestBindingError.ArgumentInvalid(RequestBindingError.DefaultArgumentInvalid, "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/DefaultOptionsTests.cs b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs index cdf0d666..1163c6ad 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/DefaultOptionsTests.cs @@ -30,8 +30,8 @@ private static Error BindMissingEmail(RequestBinderEnvelopeStage [Fact(DisplayName = "Bind.PropertiesOf binds with the configured default options — naming and structural codes — without WithOptions.")] public void BindPropertiesOfUsesTheConfiguredDefault() { var configured = new RequestBinderOptions(new SnakeCaseNameProvider(), - ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), - ErrorCode.Create("ACME_ARGUMENT_INVALID")); + RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")), + RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))); using (RequestBinderOptions.OverrideDefaultForTests(configured)) { Error error = BindMissingEmail(Bind.PropertiesOf(MissingEmail())); @@ -54,11 +54,11 @@ public void OutsideScopeFallsBackToBuiltIn() { [Fact(DisplayName = "A per-call Bind.WithOptions overrides the configured default.")] public void WithOptionsWinsOverTheConfiguredDefault() { var appDefault = new RequestBinderOptions(new SnakeCaseNameProvider(), - ErrorCode.Create("DEFAULT_REQUIRED"), - ErrorCode.Create("DEFAULT_INVALID")); + RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("DEFAULT_REQUIRED")), + RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("DEFAULT_INVALID"))); var perCall = new RequestBinderOptions(new SnakeCaseNameProvider(), - ErrorCode.Create("PERCALL_REQUIRED"), - ErrorCode.Create("PERCALL_INVALID")); + RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("PERCALL_REQUIRED")), + RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("PERCALL_INVALID"))); using (RequestBinderOptions.OverrideDefaultForTests(appDefault)) { Error error = BindMissingEmail(Bind.WithOptions(perCall).PropertiesOf(MissingEmail())); @@ -71,8 +71,8 @@ public void WithOptionsWinsOverTheConfiguredDefault() { [Fact(DisplayName = "Unconfigured, RequestBinderOptions.Default is the built-in default (default structural codes).")] public void DefaultIsBuiltInWhenUnconfigured() { - Check.That(RequestBinderOptions.Default.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); - Check.That(RequestBinderOptions.Default.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); + Check.That(RequestBinderOptions.Default.ArgumentRequired.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); + Check.That(RequestBinderOptions.Default.ArgumentInvalid.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); } // ── Setter contract: null-rejecting and frozen-after-first-use ──────────────────────────────────────── diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs index 8b9e6440..45a8443d 100644 --- a/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs +++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralCodeOverrideTests.cs @@ -12,7 +12,10 @@ public sealed class StructuralCodeOverrideTests { private static readonly ErrorCode AcmeInvalid = ErrorCode.Create("ACME_ARGUMENT_INVALID"); private static RequestBinderOptions CustomCodes() { - return new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider, AcmeRequired, AcmeInvalid); + return new RequestBinderOptions( + RequestBinderOptions.Default.ArgumentNameProvider, + RequestBindingError.DefaultArgumentRequired.WithCode(AcmeRequired), + RequestBindingError.DefaultArgumentInvalid.WithCode(AcmeInvalid)); } private static BookingRequest Request(string? guestEmail = "a@b.c", StayDto? stay = null, @@ -37,12 +40,12 @@ public void PublicDefaultCodesAreExposed() { [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(); + Check.That(RequestBinderOptions.Default.ArgumentRequired.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); + Check.That(RequestBinderOptions.Default.ArgumentInvalid.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); var namesOnly = new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider); - Check.That(namesOnly.ArgumentRequiredCode == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); - Check.That(namesOnly.ArgumentInvalidCode == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); + Check.That(namesOnly.ArgumentRequired.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); + Check.That(namesOnly.ArgumentInvalid.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); } [Fact(DisplayName = "By default, a missing required argument still records REQUEST_ARGUMENT_REQUIRED.")] diff --git a/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs b/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs new file mode 100644 index 00000000..53db91c0 --- /dev/null +++ b/FirstClassErrors.RequestBinder.UnitTests/StructuralMessageOverrideTests.cs @@ -0,0 +1,158 @@ +#region Usings declarations + +using System.Globalization; + +using NFluent; + +#endregion + +namespace FirstClassErrors.RequestBinder.UnitTests; + +/// +/// The binder's structural public messages are overridable and localizable through the same options seam as the +/// codes: a bundles a code with its message builder. These tests lock the +/// default rendering, the message-only override, the code-and-message override as one unit, the per-emission +/// localization, and the guarantee that custom options which never touch the definitions keep the shipped defaults. +/// +public sealed class StructuralMessageOverrideTests { + + private static BookingRequest MissingEmail() { + return new BookingRequest(null, "REF-1", null, null, null, null, null); + } + + private static Error BindMissingEmail(RequestBinderOptions? options = null) { + RequestBinderEnvelopeStage start = options is null + ? Bind.PropertiesOf(MissingEmail()) + : Bind.WithOptions(options).PropertiesOf(MissingEmail()); + + var bind = start.FailWith(BookingEnvelopeError.CommandInvalid); + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + return bind.New(_ => "x").Error!.InnerErrors.Single(); + } + + private static Error BindInvalidEmail(RequestBinderOptions options) { + var request = new BookingRequest("not-an-email", "REF-1", null, null, null, null, null); + var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid); + bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse); + + return bind.New(_ => "x").Error!.InnerErrors.Single(); + } + + // ── The default rendering is preserved (the "same output out of the box" guarantee) ─────────────────── + + [Fact(DisplayName = "By default, a missing required argument carries the shipped English public messages.")] + public void DefaultMessagesArePreserved() { + Error error = BindMissingEmail(); + + Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(error.ShortMessage).IsEqualTo("A required argument is missing."); + Check.That(error.DetailedMessage).IsEqualTo("The argument 'GuestEmail' is required."); + } + + // ── Overriding the messages, keeping the default code ───────────────────────────────────────────────── + + [Fact(DisplayName = "Overriding only the messages changes the public text while keeping the default code.")] + public void MessageOverrideKeepsDefaultCode() { + var options = new RequestBinderOptions( + RequestBinderOptions.Default.ArgumentNameProvider, + RequestBindingError.DefaultArgumentRequired.WithMessage( + argumentPath => new BindingMessage("This field is mandatory.", $"Please provide '{argumentPath}'."))); + + Error error = BindMissingEmail(options); + + Check.That(error.Code == RequestBindingError.DefaultArgumentRequiredCode).IsTrue(); // code untouched + Check.That(error.ShortMessage).IsEqualTo("This field is mandatory."); + Check.That(error.DetailedMessage).IsEqualTo("Please provide 'GuestEmail'."); + } + + // ── Overriding code and message together, as one coherent unit ──────────────────────────────────────── + + [Fact(DisplayName = "A definition can carry a custom code and custom messages together; both flow to the raised error.")] + public void CodeAndMessageOverrideTogether() { + var options = new RequestBinderOptions( + RequestBinderOptions.Default.ArgumentNameProvider, + new BinderErrorDefinition( + ErrorCode.Create("ACME_REQUIRED"), + argumentPath => new BindingMessage("Champ obligatoire.", $"Le champ '{argumentPath}' est obligatoire."))); + + Error error = BindMissingEmail(options); + + Check.That(error.Code.ToString()).IsEqualTo("ACME_REQUIRED"); + Check.That(error.ShortMessage).IsEqualTo("Champ obligatoire."); + Check.That(error.DetailedMessage).IsEqualTo("Le champ 'GuestEmail' est obligatoire."); + } + + // ── Localization is per-emission: one options instance serves several languages ─────────────────────── + + [Fact(DisplayName = "The message builder is evaluated per emission, so one options instance localizes by ambient culture.")] + public void MessageIsLocalizedPerEmission() { + var options = new RequestBinderOptions( + RequestBinderOptions.Default.ArgumentNameProvider, + RequestBindingError.DefaultArgumentRequired.WithMessage(Localized)); + + CultureInfo original = CultureInfo.CurrentUICulture; + try { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr"); + Check.That(BindMissingEmail(options).ShortMessage).IsEqualTo("Un argument requis est manquant."); + + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en"); + Check.That(BindMissingEmail(options).ShortMessage).IsEqualTo("A required argument is missing."); + } finally { + CultureInfo.CurrentUICulture = original; + } + } + + private static BindingMessage Localized(string argumentPath) { + return CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "fr" + ? new BindingMessage("Un argument requis est manquant.", $"L'argument '{argumentPath}' est requis.") + : new BindingMessage("A required argument is missing.", $"The argument '{argumentPath}' is required."); + } + + // ── The invalid path has its own message wiring — override it too ───────────────────────────────────── + + [Fact(DisplayName = "Overriding only the invalid message changes the public text, keeps the default code, and still wraps the cause.")] + public void InvalidMessageOverrideKeepsCodeAndCause() { + var options = new RequestBinderOptions( + RequestBinderOptions.Default.ArgumentNameProvider, + argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithMessage( + argumentPath => new BindingMessage("The value is malformed.", $"'{argumentPath}' could not be parsed."))); + + Error error = BindInvalidEmail(options); + + Check.That(error.Code == RequestBindingError.DefaultArgumentInvalidCode).IsTrue(); // code untouched + Check.That(error.ShortMessage).IsEqualTo("The value is malformed."); + Check.That(error.DetailedMessage).IsEqualTo("'GuestEmail' could not be parsed."); + Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); // cause still wrapped + } + + [Fact(DisplayName = "An invalid definition can carry a custom code and custom messages together; both flow, and the cause is still wrapped.")] + public void InvalidCodeAndMessageOverrideTogether() { + var options = new RequestBinderOptions( + RequestBinderOptions.Default.ArgumentNameProvider, + argumentInvalid: new BinderErrorDefinition( + ErrorCode.Create("ACME_INVALID"), + argumentPath => new BindingMessage("Valeur invalide.", $"Le champ '{argumentPath}' est invalide."))); + + Error error = BindInvalidEmail(options); + + Check.That(error.Code.ToString()).IsEqualTo("ACME_INVALID"); + Check.That(error.ShortMessage).IsEqualTo("Valeur invalide."); + Check.That(error.DetailedMessage).IsEqualTo("Le champ 'GuestEmail' est invalide."); + Check.That(error.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_EMAIL_INVALID"); + } + + // ── Custom options that never touch the definitions keep the shipped defaults ────────────────────────── + + [Fact(DisplayName = "Options built only for a naming policy keep the default code AND the default messages.")] + public void CustomOptionsWithoutOverridesKeepDefaults() { + var namingOnly = new RequestBinderOptions(RequestBinderOptions.Default.ArgumentNameProvider); + + Error error = BindMissingEmail(namingOnly); + + Check.That(error.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); + Check.That(error.ShortMessage).IsEqualTo("A required argument is missing."); + Check.That(error.DetailedMessage).IsEqualTo("The argument 'GuestEmail' is required."); + } + +} diff --git a/FirstClassErrors.RequestBinder/BinderErrorDefinition.cs b/FirstClassErrors.RequestBinder/BinderErrorDefinition.cs new file mode 100644 index 00000000..688c416d --- /dev/null +++ b/FirstClassErrors.RequestBinder/BinderErrorDefinition.cs @@ -0,0 +1,69 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The definition of one of the binder's structural errors: its and its public messages, kept +/// together so a consumer overrides them as one coherent unit — never a code stranded from its message. The message +/// is a builder over the failing argument path, invoked when the error is raised (not when the options are +/// built), so it may read the ambient culture (CultureInfo.CurrentUICulture) and return a localized message +/// per request, consistent with the library's internationalization pattern. +/// +/// +/// The type is immutable: and return a new definition rather than +/// mutating this one, so deriving a consumer override from a shared default (for example +/// ) never alters the default other callers see. +/// +public sealed class BinderErrorDefinition { + + #region Fields declarations + + private readonly Func _message; + + #endregion + + #region Constructors declarations + + /// Instantiates a structural-error definition from its code and its public-message builder. + /// The structural error code raised for this failure. + /// + /// Builds the public messages for a given argument path. Invoked at error emission, so it may resolve + /// culture-specific resources; it must not be null, but the it returns is + /// taken as-is (a missing short message is coalesced downstream, never rejected). + /// + /// Thrown when or is null. + public BinderErrorDefinition(ErrorCode code, Func message) { + if (code is null) { throw new ArgumentNullException(nameof(code)); } + if (message is null) { throw new ArgumentNullException(nameof(message)); } + + Code = code; + _message = message; + } + + #endregion + + /// The structural error code raised for this failure. + public ErrorCode Code { get; } + + /// Builds the public messages for the failing argument at . + /// The full path of the failing argument (for example Guests[1].FirstName). + /// The public messages to attach to the raised error. + public BindingMessage GetMessage(string argumentPath) { + return _message(argumentPath); + } + + /// Returns a copy of this definition raising instead, keeping the same messages. + /// The structural error code the copy raises. + /// A new definition with the given code and this definition's message builder. + /// Thrown when is null. + public BinderErrorDefinition WithCode(ErrorCode code) { + return new BinderErrorDefinition(code, _message); + } + + /// Returns a copy of this definition building its messages with instead, keeping the same code. + /// The public-message builder the copy uses. + /// A new definition with this definition's code and the given message builder. + /// Thrown when is null. + public BinderErrorDefinition WithMessage(Func message) { + return new BinderErrorDefinition(Code, message); + } + +} diff --git a/FirstClassErrors.RequestBinder/BindingMessage.cs b/FirstClassErrors.RequestBinder/BindingMessage.cs new file mode 100644 index 00000000..fa3d31f3 --- /dev/null +++ b/FirstClassErrors.RequestBinder/BindingMessage.cs @@ -0,0 +1,30 @@ +namespace FirstClassErrors.RequestBinder; + +/// +/// The public messages of a binder structural error: the mandatory short summary surfaced to an end user or API +/// client, and an optional controlled detail. It is a plain carrier, not a validated value object: following the +/// library's "manufacturing an error never throws" doctrine it rejects nothing — a null or whitespace +/// is coalesced to downstream, exactly as a +/// directly-authored error would be. +/// +public sealed class BindingMessage { + + #region Constructors declarations + + /// Instantiates the public messages of a binder structural error. + /// The mandatory short public summary, safe to surface to an end user or API client. + /// An optional, controlled public detail. Defaults to null. + public BindingMessage(string shortMessage, string? detailedMessage = null) { + ShortMessage = shortMessage; + DetailedMessage = detailedMessage; + } + + #endregion + + /// The mandatory short public summary of the error. + public string ShortMessage { get; } + + /// The optional, controlled public detail of the error. + public string? DetailedMessage { get; } + +} diff --git a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs index f94eef9e..d697d9f5 100644 --- a/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs +++ b/FirstClassErrors.RequestBinder/ComplexPropertyConverter.cs @@ -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.Options.ArgumentInvalidCode)); + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalid)); return new RequiredField(_binder, default!); } @@ -83,7 +83,7 @@ public OptionalReferenceField AsOptionalReference(Func nested = NestedBinder(); Outcome outcome = bindNested(nested); if (outcome.IsFailure) { - _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalidCode)); + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, _argumentPath, _binder.Options.ArgumentInvalid)); return new OptionalReferenceField(_binder, value: null); } diff --git a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs index e70e026d..710fe25b 100644 --- a/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs +++ b/FirstClassErrors.RequestBinder/ListOfComplexPropertiesConverter.cs @@ -82,7 +82,7 @@ private RequiredField> 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.Options.ArgumentInvalidCode)); + _binder.Record(NestedFailure.Group(outcome.Error!, nested.BuiltEnvelope, elementPath, _binder.Options.ArgumentInvalid)); } return outcome; diff --git a/FirstClassErrors.RequestBinder/NestedFailure.cs b/FirstClassErrors.RequestBinder/NestedFailure.cs index 34aaa8fd..40528391 100644 --- a/FirstClassErrors.RequestBinder/NestedFailure.cs +++ b/FirstClassErrors.RequestBinder/NestedFailure.cs @@ -21,12 +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 structural-error definition 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, ErrorCode argumentInvalidCode) { + internal static PrimaryPortError Group(Error error, PrimaryPortError? nestedEnvelope, string argumentPath, BinderErrorDefinition argumentInvalid) { return ReferenceEquals(error, nestedEnvelope) ? (PrimaryPortError)error - : RequestBindingError.ArgumentInvalid(argumentInvalidCode, argumentPath, error); + : RequestBindingError.ArgumentInvalid(argumentInvalid, argumentPath, error); } #endregion diff --git a/FirstClassErrors.RequestBinder/RequestBinder.cs b/FirstClassErrors.RequestBinder/RequestBinder.cs index a8104f69..e589761d 100644 --- a/FirstClassErrors.RequestBinder/RequestBinder.cs +++ b/FirstClassErrors.RequestBinder/RequestBinder.cs @@ -232,14 +232,14 @@ internal void Record(PrimaryPortError error) { _errors.Add(error); } - /// Records a missing-required-argument failure at under this binder's configured . + /// Records a missing-required-argument failure at under this binder's configured . internal void RecordArgumentRequired(string argumentPath) { - Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequiredCode, argumentPath)); + Record(RequestBindingError.ArgumentRequired(Options.ArgumentRequired, argumentPath)); } - /// Records a present-but-invalid-argument failure at under this binder's configured , wrapping . + /// 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)); + Record(RequestBindingError.ArgumentInvalid(Options.ArgumentInvalid, argumentPath, cause)); } /// diff --git a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs index 08e1b7cd..3c1ac736 100644 --- a/FirstClassErrors.RequestBinder/RequestBinderOptions.cs +++ b/FirstClassErrors.RequestBinder/RequestBinderOptions.cs @@ -21,7 +21,7 @@ public sealed class RequestBinderOptions { /// once at application startup — before any binding — to configure the binder host-wide without threading /// options through every call; a per-call still overrides it. The first bind /// reads it and thereby freezes it, so the default cannot drift once binding has begun. Defaults to the - /// built-in options (C# property names and the default structural codes). + /// built-in options (C# property names and the default structural-error definitions). /// /// Thrown when set to null. /// Thrown when set after the first bind has already read it. @@ -76,24 +76,27 @@ internal static IDisposable OverrideDefaultForTests(RequestBinderOptions options #region Constructors declarations - /// Instantiates options with the given argument-name provider and, optionally, custom structural error codes. + /// Instantiates options with the given argument-name provider and, optionally, custom structural-error definitions. /// 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 definition — code and public messages, kept together — raised when a required argument is missing. + /// null keeps the default + /// (REQUEST_ARGUMENT_REQUIRED and its English messages). Derive from the default with + /// / to align the + /// code with your catalog, localize the messages, or both — code and message stay one coherent unit. /// - /// - /// The code raised when an argument is present but fails to convert. null keeps the default - /// (REQUEST_ARGUMENT_INVALID). + /// + /// The definition — code and public messages, kept together — raised when an argument is present but fails to + /// convert. null keeps the default + /// (REQUEST_ARGUMENT_INVALID and its English messages). /// /// Thrown when is null. - public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCode? argumentRequiredCode = null, ErrorCode? argumentInvalidCode = null) { + public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, BinderErrorDefinition? argumentRequired = null, BinderErrorDefinition? argumentInvalid = null) { if (argumentNameProvider is null) { throw new ArgumentNullException(nameof(argumentNameProvider)); } ArgumentNameProvider = argumentNameProvider; - ArgumentRequiredCode = argumentRequiredCode ?? RequestBindingError.DefaultArgumentRequiredCode; - ArgumentInvalidCode = argumentInvalidCode ?? RequestBindingError.DefaultArgumentInvalidCode; + ArgumentRequired = argumentRequired ?? RequestBindingError.DefaultArgumentRequired; + ArgumentInvalid = argumentInvalid ?? RequestBindingError.DefaultArgumentInvalid; } #endregion @@ -101,11 +104,11 @@ public RequestBinderOptions(IArgumentNameProvider argumentNameProvider, ErrorCod /// 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 definition — code and public messages — the binder raises when a required argument is missing (defaults to ). + public BinderErrorDefinition ArgumentRequired { get; } - /// The code the binder raises when an argument is present but fails to convert (defaults to REQUEST_ARGUMENT_INVALID). - public ErrorCode ArgumentInvalidCode { get; } + /// The definition — code and public messages — the binder raises when an argument is present but fails to convert (defaults to ). + public BinderErrorDefinition ArgumentInvalid { get; } #region Nested types diff --git a/FirstClassErrors.RequestBinder/RequestBindingError.cs b/FirstClassErrors.RequestBinder/RequestBindingError.cs index 20478edb..0e97ad7c 100644 --- a/FirstClassErrors.RequestBinder/RequestBindingError.cs +++ b/FirstClassErrors.RequestBinder/RequestBindingError.cs @@ -16,41 +16,73 @@ public static class RequestBindingError { #region Statics members declarations + /// + /// The default definition — code and public messages, kept together — of the missing-required-argument failure: + /// the code REQUEST_ARGUMENT_REQUIRED and its English messages. A consumer overrides it through + /// , deriving from this default with + /// / to change the + /// code, the messages, or both; whatever is left untouched keeps the value shown here. + /// + public static BinderErrorDefinition DefaultArgumentRequired { get; } = + new BinderErrorDefinition( + ErrorCode.Create("REQUEST_ARGUMENT_REQUIRED"), + argumentPath => new BindingMessage( + "A required argument is missing.", + $"The argument '{argumentPath}' is required.")); + + /// + /// The default definition — code and public messages, kept together — of the present-but-invalid-argument + /// failure: the code REQUEST_ARGUMENT_INVALID and its English messages. Override it through + /// , exactly as for . + /// + public static BinderErrorDefinition DefaultArgumentInvalid { get; } = + new BinderErrorDefinition( + ErrorCode.Create("REQUEST_ARGUMENT_INVALID"), + argumentPath => new BindingMessage( + "An argument is invalid.", + $"The argument '{argumentPath}' is invalid.")); + /// /// 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. + /// 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; + public static ErrorCode DefaultArgumentRequiredCode => DefaultArgumentRequired.Code; /// /// 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 + /// 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; + public static ErrorCode DefaultArgumentInvalidCode => DefaultArgumentInvalid.Code; /// /// The argument at is required but was absent from the request. /// - /// Non-transient by nature: resubmitting the same request cannot succeed. + /// The structural-error definition to raise — the binder's configured , defaulting to . + /// The full path of the missing argument. + /// + /// Non-transient by nature: resubmitting the same request cannot succeed. The diagnostic message stays in the + /// library's internal language (English) by convention; only the public messages are localizable, through + /// . + /// [DocumentedBy(nameof(ArgumentRequiredDocumentation))] - internal static PrimaryPortError ArgumentRequired(ErrorCode code, string argumentPath) { + internal static PrimaryPortError ArgumentRequired(BinderErrorDefinition definition, string argumentPath) { + BindingMessage message = definition.GetMessage(argumentPath); + return PrimaryPortError.Create( - code, + definition.Code, $"Argument '{argumentPath}' is required but was missing from the request.", Transience.NonTransient, ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) - .WithPublicMessage( - "A required argument is missing.", - $"The argument '{argumentPath}' is required."); + .WithPublicMessage(message.ShortMessage, message.DetailedMessage); } /// /// 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 structural-error definition to raise — the binder's configured , defaulting to . /// The full path of the failing argument. /// /// The error the converter failed with. Converters must fail with a or a @@ -59,7 +91,7 @@ internal static PrimaryPortError ArgumentRequired(ErrorCode code, string argumen /// /// Thrown when belongs to another error family. [DocumentedBy(nameof(ArgumentInvalidDocumentation))] - internal static PrimaryPortError ArgumentInvalid(ErrorCode code, string argumentPath, Error cause) { + internal static PrimaryPortError ArgumentInvalid(BinderErrorDefinition definition, string argumentPath, Error cause) { PrimaryPortInnerErrors innerErrors = new(); switch (cause) { case DomainError domainError: @@ -75,14 +107,14 @@ internal static PrimaryPortError ArgumentInvalid(ErrorCode code, string argument $"The converter of argument '{argumentPath}' failed with a {cause.GetType().Name}; a converter must fail with a DomainError or a PrimaryPortError."); } + BindingMessage message = definition.GetMessage(argumentPath); + return PrimaryPortError.Create( - code, + definition.Code, $"Argument '{argumentPath}' is invalid.", innerErrors, ctx => ctx.Add(ErrCtxKey.RequestArgument, argumentPath)) - .WithPublicMessage( - "An argument is invalid.", - $"The argument '{argumentPath}' is invalid."); + .WithPublicMessage(message.ShortMessage, message.DetailedMessage); } private static ErrorDocumentation ArgumentRequiredDocumentation() { @@ -95,7 +127,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(Code.ArgumentRequired, "Guests[1].FirstName")); + .WithExamples(() => ArgumentRequired(DefaultArgumentRequired, "Guests[1].FirstName")); } private static ErrorDocumentation ArgumentInvalidDocumentation() { @@ -108,7 +140,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(Code.ArgumentInvalid, "GuestEmail", SampleCause())); + .WithExamples(() => ArgumentInvalid(DefaultArgumentInvalid, "GuestEmail", SampleCause())); } /// A representative converter failure used only by the documentation example above. @@ -123,17 +155,6 @@ private static DomainError SampleCause() { #region Nested types declarations - private static class Code { - - #region Statics members declarations - - public static readonly ErrorCode ArgumentRequired = ErrorCode.Create("REQUEST_ARGUMENT_REQUIRED"); - public static readonly ErrorCode ArgumentInvalid = ErrorCode.Create("REQUEST_ARGUMENT_INVALID"); - - #endregion - - } - private static class ErrCtxKey { #region Statics members declarations 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 index f3728553..2b6aea52 100644 --- 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 @@ -2,7 +2,7 @@ 🌍 🇬🇧 [English](0016-make-the-binders-structural-error-codes-configurable.md) · 🇫🇷 Français (ce fichier) -**Statut :** Proposé +**Statut :** Remplacé par [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.fr.md) **Date :** 2026-07-18 **Décideurs :** Reefact 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 index 6be4df54..889deac5 100644 --- 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 @@ -2,7 +2,7 @@ 🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0016-make-the-binders-structural-error-codes-configurable.fr.md) -**Status:** Proposed +**Status:** Superseded by [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.md) **Date:** 2026-07-18 **Decision Makers:** Reefact diff --git a/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.fr.md b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.fr.md new file mode 100644 index 00000000..9d2a8b79 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.fr.md @@ -0,0 +1,146 @@ +# ADR-0018 | Regrouper le code et les messages d’une erreur structurelle du binder dans une seule définition + +🌍 🇬🇧 [English](0018-bundle-the-binders-structural-error-code-and-messages.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**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`). L’ADR-0016 a rendu leurs **codes** configurables sur + `RequestBinderOptions`, résolvant l’issue #147. +* Chacune de ces deux erreurs porte aussi des **messages publics** — un résumé court et un + détail optionnel — exposés à un utilisateur final ou à un client d’API. Avant cette + décision, ces messages étaient des littéraux anglais codés en dur, sans point de + surcharge ni de localisation (issue #149). +* Le reste de la bibliothèque localise les messages publics selon le patron + d’internationalisation : la fabrique d’une erreur résout des ressources propres à la + culture **au moment où l’erreur est construite**, si bien que le message suit la culture + ambiante de la requête ; le message de diagnostic reste dans une seule langue interne par + convention. +* Le binder fabrique lui-même ces deux erreurs — leurs fabriques sont internes — de sorte + qu’un consommateur ne peut pas injecter de chaînes localisées comme il le fait lorsqu’il + écrit les fabriques de ses propres erreurs. C’est la faille qui brise le récit i18n de la + bibliothèque à la frontière de l’adaptateur primaire. +* `RequestBinderOptions` est la configuration unique, immuable, du point d’entrée du binder, + fixée une fois avant que la liaison ne commence (ADR-0012) et héritée par les binders + imbriqués ; son défaut applicatif est gelé à la première utilisation. +* Un code et son message sont un seul concept dans le modèle d’erreurs codées de la + bibliothèque. Les exposer comme deux réglages indépendants permet à un consommateur de + surcharger l’un et d’oublier l’autre, produisant un code séparé d’un message qui ne lui + correspond plus. +* La bibliothèque est en pré-version (`v0.1.0-preview.1`), non publiée, sans consommateur + externe : remodeler la surface des options n’a aucun coût de migration — et cette forme + doit être arrêtée avant le gel de l’API v1. + +## Décision + +Chacune des deux erreurs structurelles du binder est configurée sur `RequestBinderOptions` +comme une définition unique regroupant son code d’erreur et un constructeur de message +évalué au moment où l’erreur est levée — remplaçant la surcharge du code seul de l’ADR-0016 +et rendant les messages publics localisables par requête. + +## Justification + +* Regrouper le code avec son message garde le modèle d’erreurs codées cohérent : les deux + facettes qui, ensemble, présentent un échec structurel à un consommateur sont configurées + comme une unité, si bien qu’une surcharge ne peut jamais séparer un code de son message — + le mode de défaillance qu’autorisaient les deux réglages indépendants. +* Réutiliser `RequestBinderOptions` (fixée une fois, héritée par les binders imbriqués, + ADR-0012) garde un seul endroit et une seule durée de vie pour toute la configuration du + binder, le mécanisme même qu’utilisent déjà la politique de nommage et les codes de + l’ADR-0016. +* Faire du message un **constructeur évalué à l’émission**, plutôt qu’une chaîne stockée, + est ce qui permet à un même hôte de servir plusieurs langues : il lit la culture ambiante + par requête, conformément au patron de la bibliothèque qui résout les messages quand + l’erreur est construite. Un message capturé à la construction des options figerait une + langue, et la durée de vie « gelé à la première utilisation » des options la rendrait + permanente — annulant l’exigence même. +* Ne localiser que les messages publics, et laisser le diagnostic dans une seule langue + interne, préserve la convention selon laquelle les logs d’un même type d’erreur ne se + scindent pas selon la langue de la requête. +* Faire défauter chaque définition sur le code livré et ses messages anglais garde le + comportement sans configuration inchangé et le catalogue propre du paquet binder exact ; + un consommateur qui ne surcharge rien n’est pas affecté, et les défauts sont exposés pour + qu’un consommateur puisse toujours brancher symboliquement sur un code structurel. +* Remplacer la surcharge du code seul de l’ADR-0016, plutôt qu’ajouter un réglage de message + en parallèle, est sans coût de migration tant que la bibliothèque est en pré-version, et + arrête la surface des options comme un seul concept avant le gel v1. + +## Alternatives considérées + +### Un point de surcharge de message séparé, à côté des codes configurables + +Considéré parce que c’est le plus petit ajout à la surface que l’ADR-0016 a déjà livrée. + +Rejeté parce qu’il recrée exactement le découplage que le modèle d’erreurs codées existe +pour éviter : un consommateur pourrait aligner le code et oublier le message, ou l’inverse, +et la configuration du binder se scinderait en deux concepts sans gain. + +### Stocker les messages de surcharge comme des chaînes fixes sur les options + +Considéré parce qu’une simple chaîne est plus simple qu’un constructeur. + +Rejeté parce que les options gèlent à la première utilisation : des chaînes stockées +figeraient une langue pour la durée de vie du processus — annulant la localisation par +requête, la seule propriété qu’exige l’issue #149. + +### Exposer publiquement les fabriques de messages, pour que le consommateur fabrique les erreurs + +Considéré par symétrie avec la façon dont un consommateur localise ses propres erreurs (il +en écrit les fabriques). + +Rejeté parce qu’il ouvre les invariants structurels du binder — transience, la clé de +contexte du chemin d’argument, le câblage de l’erreur interne — à l’erreur du consommateur. +Le binder doit continuer de fabriquer ces erreurs et n’exposer que leur présentation : le +code et les messages publics. + +## Conséquences + +### Positives + +* Le code et les messages structurels du binder se surchargent comme une unité cohérente, et + un consommateur servant des clients non anglophones les localise par requête — clôturant + l’issue #149. +* Toute la configuration du binder reste sur un seul objet d’options avec une seule durée de + vie (ADR-0012), héritée par les binders imbriqués. +* Le comportement sans configuration et le catalogue propre du paquet binder sont inchangés + (défauts préservés et toujours documentés, et toujours exposés pour le branchement + symbolique). + +### Négatives + +* La surface des options change de forme : la surcharge du code seul consignée par + l’ADR-0016 est remplacée par une définition code-et-message. Un changement cassant, + acceptable en pré-version. +* Le message de diagnostic reste non surchargeable et anglais par convention ; un + consommateur qui veut un diagnostic localisé n’est délibérément pas servi. + +### Risques + +* Le constructeur de message d’un consommateur s’exécute pendant la fabrication de l’erreur + et pourrait lever, contrairement aux fabriques toujours sûres de la bibliothèque. Cela + rejoint le contrat des points d’extension existants (le fournisseur de nom d’argument est + aussi du code consommateur invoqué pendant la liaison) et relève de la responsabilité du + consommateur. + +## Actions de suivi + +* Résolu à l’acceptation : l’ADR-0016 est marquée `Superseded`, remplacée par celle-ci — + décision du mainteneur. +* La question de la dérive de catalogue d’un code surchargé, différée par l’ADR-0016 (liée à + #140), est inchangée par cette décision. + +## Références + +* ADR-0016 — rendre configurables les codes d’erreur structurels du binder ; la surcharge du + code seul que cette décision remplace. +* ADR-0012 — fixer les options du binder avant que la liaison ne commence ; la durée de vie + des options que cette décision réutilise. +* Issue #149 — le constat que cette décision résout. +* Issue #147 — le constat que l’ADR-0016 a résolu (la moitié des codes). +* [Internationalisation](../../for-users/Internationalisation.fr.md) — le patron de + localisation de la bibliothèque que cette décision suit. diff --git a/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.md b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.md new file mode 100644 index 00000000..ece4d52d --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0018-bundle-the-binders-structural-error-code-and-messages.md @@ -0,0 +1,137 @@ +# ADR-0018 | Bundle the binder's structural error code and messages in one definition + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0018-bundle-the-binders-structural-error-code-and-messages.fr.md) + +**Status:** Accepted +**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`). ADR-0016 made their **codes** configurable on + `RequestBinderOptions`, resolving issue #147. +* Each of these two errors also carries **public messages** — a short summary and an + optional detail — surfaced to an end user or an API client. Before this decision those + messages were hardcoded English literals with no override or localization hook (issue + #149). +* The rest of the library localizes public messages by the internationalization pattern: + an error's factory resolves culture-specific resources **at the point the error is + built**, so the message follows the ambient culture of the request; the diagnostic + message stays in one internal language by convention. +* The binder manufactures these two errors itself — their factories are internal — so a + consumer cannot inject localized strings the way it does when it writes its own errors' + factories. This is the gap that breaks the library's i18n story at the primary-adapter + boundary. +* `RequestBinderOptions` is the binder's single, immutable, entry-point configuration, + fixed once before binding begins (ADR-0012) and inherited by nested binders; its + application-wide default freezes on first use. +* A code and its message are one concept in the library's coded-error model. Exposing them + as two independent knobs lets a consumer override one and forget the other, producing a + code stranded from a message that no longer fits it. +* The library is pre-release (`v0.1.0-preview.1`), unpublished, with no external consumers, + so reshaping the options surface carries no migration cost — and the shape should be + settled before the v1 API freeze. + +## Decision + +Each of the binder's two structural errors is configured on `RequestBinderOptions` as a +single definition bundling its error code with a message builder evaluated when the error +is raised — superseding the code-only override of ADR-0016 and making the public messages +localizable per request. + +## Rationale + +* Bundling the code with its message keeps the coded-error model coherent: the two facets + that together present a structural failure to a consumer are configured as one unit, so + an override can never strand a code from its message — the failure mode the two + independent knobs allowed. +* Reusing `RequestBinderOptions` (fixed once, inherited by nested binders, ADR-0012) keeps + one place and one lifetime for all binder configuration, the same mechanism the naming + policy and ADR-0016's codes already use. +* Making the message a **builder evaluated at emission**, rather than a stored string, is + what lets one host serve several languages: it reads the ambient culture per request, + matching the library's pattern of resolving messages when the error is built. A message + captured at options-construction time would fix one language, and the options' + freeze-on-first-use lifetime would make that permanent — defeating the very requirement. +* Localizing only the public messages, and leaving the diagnostic in one internal language, + preserves the convention that logs for one error type do not fork by request language. +* Defaulting each definition to the shipped code and its English messages keeps the + zero-configuration behaviour unchanged and the binder package's own catalog accurate; a + consumer that overrides nothing is unaffected, and the defaults are exposed so a consumer + can still branch on a structural code symbolically. +* Superseding ADR-0016's code-only override, rather than adding a parallel message knob, is + free of migration cost while the library is pre-release, and settles the options surface + as one concept before the v1 freeze. + +## Alternatives Considered + +### A separate message hook alongside the configurable codes + +Considered because it is the smallest addition to the surface ADR-0016 already shipped. + +Rejected because it re-creates exactly the decoupling the coded-error model exists to +avoid: a consumer could align the code and forget the message, or the reverse, and binder +configuration would split across two concepts for no gain. + +### Store the overriding messages as fixed strings on the options + +Considered because a plain string is simpler than a builder. + +Rejected because the options freeze on first use, so stored strings would fix one language +for the process's lifetime — defeating per-request localization, the one property issue +#149 requires. + +### Expose the message-building factories publicly, so a consumer manufactures the errors + +Considered for symmetry with how a consumer localizes its own errors (it writes their +factories). + +Rejected because it opens the binder's structural invariants — transience, the +argument-path context key, the inner-error wiring — to consumer error. The binder must keep +manufacturing these errors and expose only their presentation: the code and the public +messages. + +## Consequences + +### Positive + +* The binder's structural code and messages are overridden as one coherent unit, and a + consumer serving non-English clients localizes them per request — closing issue #149. +* All binder configuration stays on one options object with one lifetime (ADR-0012), + inherited by nested binders. +* Zero-configuration behaviour and the binder package's own catalog are unchanged (defaults + preserved and still documented, and still exposed for symbolic branching). + +### Negative + +* The options surface changes shape: the code-only override recorded by ADR-0016 is + replaced by a code-and-message definition. A breaking change, acceptable pre-release. +* The diagnostic message stays non-overridable and English by convention; a consumer that + wants a localized diagnostic is deliberately not served. + +### Risks + +* A consumer's message builder runs during error manufacturing and could throw, unlike the + library's own always-safe factories. This matches the existing extension-point contract + (the argument-name provider is consumer code invoked during binding too) and is the + consumer's responsibility. + +## Follow-up Actions + +* Resolved on acceptance: ADR-0016 is marked `Superseded` by this ADR, per the maintainer's + decision. +* The overridden-code catalog-drift question deferred by ADR-0016 (relates to #140) is + unchanged by this decision. + +## References + +* ADR-0016 — make the binder's structural error codes configurable; the code-only override + this decision supersedes. +* ADR-0012 — fix the binder options before binding begins; the options lifetime this + decision reuses. +* Issue #149 — the finding this decision resolves. +* Issue #147 — the finding ADR-0016 resolved (the codes half). +* [Internationalization](../../for-users/Internationalization.en.md) — the library's + localization pattern this decision follows. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 48f9c20f..25a48e56 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -189,5 +189,6 @@ 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 | +| [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Superseded | | [ADR-0017](0017-provide-a-configurable-application-wide-default-for-the-binder-options.md) | Provide a configurable application-wide default for the binder options | Accepted | +| [ADR-0018](0018-bundle-the-binders-structural-error-code-and-messages.md) | Bundle the binder's structural error code and messages in one definition | Accepted | diff --git a/doc/handwritten/for-users/RequestBinder.en.md b/doc/handwritten/for-users/RequestBinder.en.md index 824b0913..0cc47d70 100644 --- a/doc/handwritten/for-users/RequestBinder.en.md +++ b/doc/handwritten/for-users/RequestBinder.en.md @@ -398,27 +398,48 @@ created — so `Stay.check_in` is renamed consistently, top to bottom. The entry 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 +## Structural errors: codes and messages 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 +fails to convert. These are the only errors 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: +Each is a `BinderErrorDefinition` — a **code and its public messages, kept together** — +so you override them as one coherent unit, never a code stranded from its message. Start +from the exposed default and change the code, the messages, or both; whatever you leave +untouched keeps the shipped value: ```csharp var options = new RequestBinderOptions( new SnakeCaseNames(), - argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), - argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + // align the code with your catalog's convention, keep the default messages: + argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")), + // a code and its message, defined together in one place: + argumentInvalid: new BinderErrorDefinition( + ErrorCode.Create("ACME_ARGUMENT_INVALID"), + path => new BindingMessage("An argument is invalid.", $"The argument '{path}' is invalid."))); var bind = Bind.WithOptions(options).PropertiesOf(request).FailWith(PlaceBookingError.Invalid); ``` -The configured codes flow through **every** structural failure — scalars, list +The message builder runs **when the error is raised**, not when the options are built — +so it can read the ambient culture (`CultureInfo.CurrentUICulture`) and return a message +localized per request. A host serving several languages localizes the binder's structural +messages through its own resource accessor, exactly as it localizes any other error: + +```csharp +argumentRequired: RequestBindingError.DefaultArgumentRequired.WithMessage( + path => new BindingMessage( + BinderStrings.ArgumentRequired_Short, // .resx, resolved on CurrentUICulture + string.Format(BinderStrings.ArgumentRequired_Detailed, path))) +``` + +Only the two **public** messages are localizable; the diagnostic message stays in the +library's internal language (English) by convention, so logs for one structural failure +never fork by request language. See [Internationalization](Internationalization.en.md). + +The configured definitions 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 @@ -433,15 +454,15 @@ if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422; `Bind.PropertiesOf(request)` binds with `RequestBinderOptions.Default`. That default is configurable **once, at application startup** — so a whole host (ASP.NET, a CLI, a -worker) shares one naming policy and one set of structural codes without threading -options through every call, and without a DI container: +worker) shares one naming policy and one set of structural-error definitions without +threading options through every call, and without a DI container: ```csharp // Program.cs, before the first bind: RequestBinderOptions.Default = new RequestBinderOptions( new SnakeCaseNames(), - argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), - argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")), + argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))); // anywhere after that — no options threaded through: var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid); diff --git a/doc/handwritten/for-users/RequestBinder.fr.md b/doc/handwritten/for-users/RequestBinder.fr.md index 95d8468e..dca74862 100644 --- a/doc/handwritten/for-users/RequestBinder.fr.md +++ b/doc/handwritten/for-users/RequestBinder.fr.md @@ -412,28 +412,51 @@ 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 +## Erreurs structurelles : codes et messages 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 +mais échoue à se convertir. Ce sont les seules erreurs 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 : +Chacune est un `BinderErrorDefinition` — un **code et ses messages publics, tenus +ensemble** — que vous surchargez comme une unité cohérente, jamais un code séparé de son +message. Partez du défaut exposé et changez le code, les messages, ou les deux ; ce que +vous laissez intact garde la valeur livrée : ```csharp var options = new RequestBinderOptions( new SnakeCaseNames(), - argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), - argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + // aligner le code sur la convention de votre catalogue, garder les messages par défaut : + argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")), + // un code et son message, définis ensemble au même endroit : + argumentInvalid: new BinderErrorDefinition( + ErrorCode.Create("ACME_ARGUMENT_INVALID"), + path => new BindingMessage("An argument is invalid.", $"The argument '{path}' is 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. +Le constructeur de message s’exécute **au moment où l’erreur est levée**, pas à la +construction des options — il peut donc lire la culture ambiante +(`CultureInfo.CurrentUICulture`) et renvoyer un message localisé par requête. Un hôte qui +sert plusieurs langues localise les messages structurels du binder via son propre +accesseur de ressources, exactement comme il localise toute autre erreur : + +```csharp +argumentRequired: RequestBindingError.DefaultArgumentRequired.WithMessage( + path => new BindingMessage( + BinderStrings.ArgumentRequired_Short, // .resx, résolu sur CurrentUICulture + string.Format(BinderStrings.ArgumentRequired_Detailed, path))) +``` + +Seuls les deux messages **publics** sont localisables ; le message de diagnostic reste +dans la langue interne de la bibliothèque (l’anglais) par convention, pour que les logs +d’un même échec structurel ne se scindent jamais selon la langue de la requête. Voir +[Internationalisation](Internationalisation.fr.md). + +Les définitions configurées 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 @@ -448,15 +471,16 @@ if (error.Code == RequestBindingError.DefaultArgumentRequiredCode) { return 422; `Bind.PropertiesOf(request)` lie avec `RequestBinderOptions.Default`. Ce défaut est configurable **une seule fois, au démarrage de l’application** — un hôte entier -(ASP.NET, une CLI, un worker) partage ainsi une politique de nommage et un jeu de codes -structurels sans faire transiter d’options par chaque appel, et sans conteneur DI : +(ASP.NET, une CLI, un worker) partage ainsi une politique de nommage et un jeu de +définitions d’erreurs structurelles sans faire transiter d’options par chaque appel, et +sans conteneur DI : ```csharp // Program.cs, avant la première liaison : RequestBinderOptions.Default = new RequestBinderOptions( new SnakeCaseNames(), - argumentRequiredCode: ErrorCode.Create("ACME_ARGUMENT_REQUIRED"), - argumentInvalidCode: ErrorCode.Create("ACME_ARGUMENT_INVALID")); + argumentRequired: RequestBindingError.DefaultArgumentRequired.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED")), + argumentInvalid: RequestBindingError.DefaultArgumentInvalid.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))); // n’importe où ensuite — aucune option à faire transiter : var bind = Bind.PropertiesOf(request).FailWith(PlaceBookingError.Invalid);