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

Filter by extension

Filter by extension

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

using NFluent;

#endregion

namespace FirstClassErrors.RequestBinder.UnitTests;

/// <summary>
/// The public documentation seams a consumer uses to surface its <b>overridden</b> binder structural errors in its
/// own catalog: <c>SampleArgumentRequired/Invalid</c> (a faithful example error) and
/// <c>DescribeArgumentRequired/Invalid</c> (the binder's generic prose plus that example). Both are built from a
/// <see cref="BinderErrorDefinition" />, so a consumer that renamed the codes or localized the messages documents
/// what it actually emits — structurally identical to what the binder raises at binding time.
/// </summary>
public sealed class StructuralErrorDescriptionTests {

private static readonly BinderErrorDefinition CustomRequired =
RequestBindingError.DefaultArgumentRequired
.WithCode(ErrorCode.Create("ACME_ARGUMENT_REQUIRED"))
.WithMessage(argumentPath => new BindingMessage("Champ obligatoire.", $"Le champ '{argumentPath}' est obligatoire."));

private static readonly BinderErrorDefinition CustomInvalid =
RequestBindingError.DefaultArgumentInvalid
.WithCode(ErrorCode.Create("ACME_ARGUMENT_INVALID"))
.WithMessage(argumentPath => new BindingMessage("Valeur invalide.", $"Le champ '{argumentPath}' est invalide."));

// ── Samples carry the definition's code + messages, and the binder's structural shape ─────────────────

[Fact(DisplayName = "SampleArgumentRequired builds an error with the definition's custom code and messages, under the argument-path context.")]
public void SampleRequiredCarriesTheDefinition() {
PrimaryPortError error = RequestBindingError.SampleArgumentRequired(CustomRequired);

Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_REQUIRED");
Check.That(error.ShortMessage).IsEqualTo("Champ obligatoire.");
Check.That(error.DetailedMessage).IsEqualTo("Le champ 'Guests[1].FirstName' est obligatoire.");
Check.That(BindingAssertions.ArgumentPathOf(error)).IsEqualTo("Guests[1].FirstName");
}

[Fact(DisplayName = "SampleArgumentInvalid builds an error with the definition's custom code and messages, and still wraps a sample cause.")]
public void SampleInvalidCarriesTheDefinitionAndCause() {
PrimaryPortError error = RequestBindingError.SampleArgumentInvalid(CustomInvalid);

Check.That(error.Code.ToString()).IsEqualTo("ACME_ARGUMENT_INVALID");
Check.That(error.ShortMessage).IsEqualTo("Valeur invalide.");
Check.That(error.InnerErrors).Not.IsEmpty();
}

// ── Describe reuses the binder's generic prose, with the definition's example ──────────────────────────

[Fact(DisplayName = "DescribeArgumentRequired reuses the binder's title and diagnoses; its example carries the definition's messages.")]
public void DescribeRequiredReusesProseWithCustomExample() {
ErrorDocumentation doc = RequestBindingError.DescribeArgumentRequired(CustomRequired);

Check.That(doc.Title).IsEqualTo("Required request argument missing"); // binder's generic prose
Check.That(doc.Diagnostics.Select(d => d.Origin)).ContainsExactly(ErrorOrigin.External, ErrorOrigin.Internal);
Check.That(doc.Examples.Single().ShortMessage).IsEqualTo("Champ obligatoire."); // consumer's message
Check.That(doc.Examples.Single().DetailedMessage).IsEqualTo("Le champ 'Guests[1].FirstName' est obligatoire.");
}

[Fact(DisplayName = "DescribeArgumentInvalid reuses the binder's title and diagnoses; its example carries the definition's messages.")]
public void DescribeInvalidReusesProseWithCustomExample() {
ErrorDocumentation doc = RequestBindingError.DescribeArgumentInvalid(CustomInvalid);

Check.That(doc.Title).IsEqualTo("Request argument invalid");
Check.That(doc.Diagnostics.Select(d => d.Origin)).ContainsExactly(ErrorOrigin.External, ErrorOrigin.Internal);
Check.That(doc.Examples.Single().ShortMessage).IsEqualTo("Valeur invalide.");
}

// ── The default-catalog path is unchanged (the parameterless documentation delegates here) ────────────

[Fact(DisplayName = "The seams with the default definition still yield the shipped default codes and messages.")]
public void DefaultDefinitionYieldsTheShippedCatalog() {
Check.That(RequestBindingError.SampleArgumentRequired(RequestBindingError.DefaultArgumentRequired).Code.ToString())
.IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(RequestBindingError.DescribeArgumentRequired(RequestBindingError.DefaultArgumentRequired).Examples.Single().ShortMessage)
.IsEqualTo("A required argument is missing.");
}

}
64 changes: 60 additions & 4 deletions FirstClassErrors.RequestBinder/RequestBindingError.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
#region Usings declarations

using System.Diagnostics.CodeAnalysis;

#endregion

namespace FirstClassErrors.RequestBinder;

/// <summary>
Expand Down Expand Up @@ -117,7 +123,30 @@ internal static PrimaryPortError ArgumentInvalid(BinderErrorDefinition definitio
.WithPublicMessage(message.ShortMessage, message.DetailedMessage);
}

private static ErrorDocumentation ArgumentRequiredDocumentation() {
/// <summary>
/// Builds a representative missing-required-argument error (<c>REQUEST_ARGUMENT_REQUIRED</c> by default) from
/// <paramref name="definition" />, for documentation. A consumer that overrides the definition through
/// <see cref="RequestBinderOptions.ArgumentRequired" /> uses this to render, in its own catalog, the structural
/// error it actually emits — built the same way the binder builds it at binding time, so the documented example
/// stays faithful to runtime.
/// </summary>
/// <param name="definition">The definition to render — typically the one injected into <see cref="RequestBinderOptions.ArgumentRequired" />.</param>
/// <returns>A representative error carrying the definition's code and public messages.</returns>
[SuppressMessage("FirstClassErrors.DocumentationWiring", "FCE009:ErrorFactoryNotDocumented",
Justification = "Not a catalog entry: a public sample builder a consumer calls to document, in its own catalog, the structural error it emits after overriding the definition. The binder's own documented factory is ArgumentRequired.")]
public static PrimaryPortError SampleArgumentRequired(BinderErrorDefinition definition) {
return ArgumentRequired(definition, "Guests[1].FirstName");
}

/// <summary>
/// The full documentation of the missing-required-argument failure for <paramref name="definition" /> — the
/// binder's own generic prose (title, rule, diagnoses), with a live example built from the definition. A consumer
/// that overrides the definition surfaces its effective structural error in its own catalog by returning this from
/// a <c>[DocumentedBy]</c> documentation method (see the RequestBinder guide).
/// </summary>
/// <param name="definition">The definition to document — typically the one injected into <see cref="RequestBinderOptions.ArgumentRequired" />.</param>
/// <returns>The error documentation, ready to return from a documentation method.</returns>
public static ErrorDocumentation DescribeArgumentRequired(BinderErrorDefinition definition) {
return DescribeError.WithTitle("Required request argument missing")
.WithDescription("An incoming request omits an argument that the bound command requires. The full path of the missing argument is carried in the error context.")
.WithRule("Every argument bound with AsRequired must be present in the request.")
Expand All @@ -127,10 +156,29 @@ 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(DefaultArgumentRequired, "Guests[1].FirstName"));
.WithExamples(() => SampleArgumentRequired(definition));
}

private static ErrorDocumentation ArgumentInvalidDocumentation() {
/// <summary>
/// Builds a representative present-but-invalid-argument error (<c>REQUEST_ARGUMENT_INVALID</c> by default) from
/// <paramref name="definition" />, for documentation — see <see cref="SampleArgumentRequired" />. A sample
/// converter cause is attached, exactly as the binder attaches the real converter's error at binding time.
/// </summary>
/// <param name="definition">The definition to render — typically the one injected into <see cref="RequestBinderOptions.ArgumentInvalid" />.</param>
/// <returns>A representative error carrying the definition's code and public messages, wrapping a sample cause.</returns>
[SuppressMessage("FirstClassErrors.DocumentationWiring", "FCE009:ErrorFactoryNotDocumented",
Justification = "Not a catalog entry: a public sample builder a consumer calls to document, in its own catalog, the structural error it emits after overriding the definition. The binder's own documented factory is ArgumentInvalid.")]
public static PrimaryPortError SampleArgumentInvalid(BinderErrorDefinition definition) {
return ArgumentInvalid(definition, "GuestEmail", SampleCause());
}

/// <summary>
/// The full documentation of the present-but-invalid-argument failure for <paramref name="definition" /> — see
/// <see cref="DescribeArgumentRequired" />.
/// </summary>
/// <param name="definition">The definition to document — typically the one injected into <see cref="RequestBinderOptions.ArgumentInvalid" />.</param>
/// <returns>The error documentation, ready to return from a documentation method.</returns>
public static ErrorDocumentation DescribeArgumentInvalid(BinderErrorDefinition definition) {
return DescribeError.WithTitle("Request argument invalid")
.WithDescription("An incoming request carries an argument that fails to convert into its value object. The full path of the failing argument is carried in the error context, and the precise conversion error is attached as the inner error.")
.WithRule("Every bound argument must convert successfully into its target value object.")
Expand All @@ -140,7 +188,15 @@ 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(DefaultArgumentInvalid, "GuestEmail", SampleCause()));
.WithExamples(() => SampleArgumentInvalid(definition));
}

private static ErrorDocumentation ArgumentRequiredDocumentation() {
return DescribeArgumentRequired(DefaultArgumentRequired);
}

private static ErrorDocumentation ArgumentInvalidDocumentation() {
return DescribeArgumentInvalid(DefaultArgumentInvalid);
}

/// <summary>A representative converter failure used only by the documentation example above.</summary>
Expand Down
Loading