From 0aea84483c8bf4531a6ab0b137fe94cd70598f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 28 Oct 2026 13:17:36 +0100 Subject: [PATCH] feat: a fault keeps the exception that caused it, for the log and never the problem A caught exception was lost on the way to a Fault: the MapError that turns it into the fault an endpoint answers dropped it, so a log of the refusal could not say what failed underneath (decision 52). - Fault.Cause, set by Fault.Of(reason, detail, cause) and the constructor of the same shape, and Of(reason, detail, cause) on Fault and Fault. WithRule and `with` keep it. A null cause is refused where one is given. - Equality and the hash code are the reason's, the detail's and the rule's, written by hand so the cause takes no part; ToString prints as 2.2 did. - AspNetCore: FaultProblem.Cause, and IFaultFeature.Cause as a default member, so a host's own implementation still builds. The problem's members are the same with a cause as without one. - READMEs (core, and AspNetCore's "What failed underneath" with compiled samples), changelog, the option-result note; the roadmap item goes. - The C03 seeds are regenerated against the new source. The private Problem() helper stays the composed overloads' alone, so each-error-own-set still breaks composed faults only. Proved by seeding: equality over the cause fails two tests, a dropped cause in ToHttpResult six, and the cause's message written into the problem five. Closes #84 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ design/option-result.md | 5 +- design/roadmap.md | 3 - .../FaultProblem.cs | 21 +++++- src/CodoMetis.TypeKit.AspNetCore/README.md | 31 +++++++- .../TypeKitHttpResultExtensions.cs | 10 +-- src/CodoMetis.TypeKit/Fault.cs | 47 +++++++++++- src/CodoMetis.TypeKit/FaultOfSets.cs | 35 +++++++++ src/CodoMetis.TypeKit/README.md | 4 ++ .../FaultResponsePartsTests.cs | 68 ++++++++++++++++++ .../AspNetCore/FaultResponseSamples.cs | 49 +++++++++++++ .../ComposedFaultTests.cs | 25 +++++++ test/CodoMetis.TypeKit.Tests/FaultTests.cs | 71 +++++++++++++++++++ .../seeds/C03-detail-and-rule-intact.patch | 6 +- .../corpus/seeds/C03-each-error-own-set.patch | 16 ++--- 15 files changed, 369 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cb7e37..67120782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ section is the GitHub release's notes. ## 2.3.0 — Unreleased +### Added + +- `Fault.Cause`: `Fault.Of(reason, detail, exception)` keeps the exception underneath for your log; equality and `ToString()` leave it out, and `ToHttpResult()` hands it to the host as `IFaultFeature.Cause` and `FaultProblem.Cause`, never into the problem ([What failed underneath](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-failed-underneath)). + ## 2.2.0 — 2026-10-18 ### Fixed diff --git a/design/option-result.md b/design/option-result.md index cc443135..b4a40674 100644 --- a/design/option-result.md +++ b/design/option-result.md @@ -53,8 +53,9 @@ is the value-less shape. Anything that turns this into a second LanguageExt is o occurrence. It is made by `Fault.Of` alone, and comparing it with a bare reason is refused, since a fault with a detail never equals one ([decision 53](adr/0053-fault-made-by-fault-of-alone.md)); `Reason` is what a caller compares. - A `Cause` beside them joins 2.1 ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)), and so does - `Rule`, the qualified name of the enum or closed-set member that refused, which the problem carries + A `Cause` beside them (2.3.0) is an exception for the log, which equality and printing leave out + and no problem carries ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). `Rule` (2.1.0) is the + qualified name of the enum or closed-set member that refused, which the problem carries as `rule` ([decision 67](adr/0067-rule-that-refused-on-fault.md)); equality compares the rule's type, since two types' rules can read alike ([decision 72](adr/0072-rule-identity-is-its-type.md)). - **`Fault` and `Fault` (2.1.0)** hold one fault of one of several types, so a diff --git a/design/roadmap.md b/design/roadmap.md index 716f8ba0..2d3df137 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -22,9 +22,6 @@ defect, a cost or a missing guard may land here; one that adds surface waits for Decided, moved to 2.2 when 2.1.0 shipped without them ([decision 88](adr/0088-2-1-0-ships-without-its-open-roadmap-items.md)), and then to 2.3 when 2.2.0 became a consolidation release ([decision 97](adr/0097-2-2-0-is-a-consolidation-release-and-the-gate-binds.md)). -- An optional `Cause` on `Fault`, an exception kept beside the reason and the detail for the - log and the fault feature, never written to the problem; `Fault.Of(reason, detail, cause)`, and a - `MapError` from a `Result` that keeps the exception ([decision 52](adr/0052-cause-on-fault-joins-2-1.md)). - An opt-in that withholds the `fault` name from the response for chosen members or a whole fault type, so two causes can share a status without a mapping at the boundary ([decision 64](adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md)). diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs index f9919a21..822adc3b 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs @@ -31,11 +31,12 @@ namespace CodoMetis.TypeKit.AspNetCore; public sealed class FaultProblem : IResult, IEndpointMetadataProvider where TFault : notnull { - internal FaultProblem(TFault fault, string? detail = null, string? rule = null) + internal FaultProblem(TFault fault, string? detail = null, string? rule = null, Exception? cause = null) { Fault = fault; Detail = detail; Rule = rule; + Cause = cause; } /// The fault this response reports. @@ -47,6 +48,12 @@ internal FaultProblem(TFault fault, string? detail = null, string? rule = null) /// The rule that refused, EmailFault.NoAt, where the result carried a naming one. public string? Rule { get; } + /// + /// The exception that caused the fault, where the result carried a with one + /// (): for the log, and never written into the problem. + /// + public Exception? Cause { get; } + /// public async Task ExecuteAsync(HttpContext httpContext) { @@ -70,7 +77,7 @@ public async Task ExecuteAsync(HttpContext httpContext) // for this problem and for no other the request writes. var features = httpContext.Features; var previous = features.Get(); - features.Set(new FaultFeature(typeof(TFault), Fault, name, detail, Rule)); + features.Set(new FaultFeature(typeof(TFault), Fault, name, detail, Rule, Cause)); var extensions = new Dictionary { [FaultResponsesOperationTransformer.FaultMember] = name }; if (Rule is not null) extensions[FaultResponsesOperationTransformer.RuleMember] = Rule; try @@ -103,7 +110,7 @@ static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, Endpoi builder.Metadata.Add(FaultResponsesMetadata.Instance); } - private sealed record FaultFeature(Type FaultType, object Fault, string Name, string? Detail, string? Rule) : IFaultFeature; + private sealed record FaultFeature(Type FaultType, object Fault, string Name, string? Detail, string? Rule, Exception? Cause) : IFaultFeature; } /// @@ -129,6 +136,14 @@ public interface IFaultFeature /// The problem's rule, the rule that refused (), or . /// Implemented by default as , so an implementation of the host's own, a test's, still builds. string? Rule => null; + + /// + /// The exception that caused the fault (), or : for + /// the host's log. The problem never carries it, and a CustomizeProblemDetails that copies it into + /// one tells the client what failed underneath. + /// + /// Implemented by default as , so an implementation of the host's own, a test's, still builds. + Exception? Cause => null; } /// How ToHttpResult() writes a problem: services.Configure<FaultResponseOptions>(options => …). diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 0428cc4a..a0563d0d 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -439,6 +439,35 @@ document stay the error's. error's name, the rule is your application's vocabulary. So give rule types distinct names, for instance by nesting a rule in its value object: `Email.Rule.Blank`. +### What failed underneath + +When an exception causes the error, keep it in the `Fault` as its cause. Your log gets the +exception, and the client gets the same problem as before: + +```csharp +Result> saved = Result.Catch(() => store.Save(account)) + .MapError(e => Fault.Of(AccountFault.EmailTaken, "Another account took the address a moment ago.", e)); +``` + +`IFaultFeature.Cause` hands the exception to your `CustomizeProblemDetails`, so you can log it with +the response it led to: + +```csharp +builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => +{ + if (context.HttpContext.Features.Get() is { Cause: { } cause } fault) + context.HttpContext.RequestServices.GetRequiredService().CreateLogger("Faults") + .LogWarning(cause, "Answered {Fault} for an exception", fault.Name); // your log gets the exception, the client doesn't +}); +``` + +- **The cause stays out of the response.** The problem has the same members with a cause as without + one, and names neither the exception's type nor its message. If your `CustomizeProblemDetails` + copies it into the problem, the client learns what failed underneath. +- **`FaultProblem.Cause`** holds it too, for a test or a filter that reads the result before + it's written. +- **It's one exception, not a chain.** An `AggregateException` is fine as the cause. + ### Faults of several types Some rejections mean the same on every route: a change that raced the request, or a version that @@ -601,7 +630,7 @@ works in the same place. A `Result` takes a factory without a value: ### What the host adds to a problem While `ToHttpResult()` writes a problem, the request carries an `IFaultFeature`. It holds the error -type, the error, the name the problem gives it, its detail and its rule. Your +type, the error, the name the problem gives it, its detail, its rule and its cause. Your `CustomizeProblemDetails` reads it to add what the package doesn't name. No other problem the request writes has it. diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs index 6df36fb1..efbe6b09 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs @@ -97,7 +97,7 @@ public static Results, FaultProblem> ToHttpResult(this where TFault : notnull => result.Match, FaultProblem>>( static value => TypedResults.Ok(value), - static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule)); + static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule, fault.Cause)); /// A success as 204 No Content; a fault as problem details for its reason, with its detail. /// The outcome of the operation the endpoint ran. @@ -106,7 +106,7 @@ public static Results> ToHttpResult(this where TFault : notnull => result.Match>>( static () => TypedResults.NoContent(), - static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule)); + static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule, fault.Cause)); /// A success as the response makes of its value; a fault as problem details for its reason, with its detail. /// The outcome of the operation the endpoint ran. @@ -118,7 +118,7 @@ public static Results> ToHttpResult(this where TSuccess : IResult { ArgumentNullException.ThrowIfNull(success); - return result.Match>>(value => success(value), static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule)); + return result.Match>>(value => success(value), static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule, fault.Cause)); } /// A success as the response makes; a fault as problem details for its reason, with its detail. @@ -130,7 +130,7 @@ public static Results> ToHttpResult(this where TSuccess : IResult { ArgumentNullException.ThrowIfNull(success); - return result.Match>>(() => success(), static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule)); + return result.Match>>(() => success(), static fault => new FaultProblem(fault.Reason, fault.Detail, fault.Rule, fault.Cause)); } /// A success as 200 OK with its value; a fault of either type as problem details for its reason, with its detail. @@ -249,7 +249,7 @@ public static Results, FaultProblem, FaultProble static fault => fault.Match, FaultProblem, FaultProblem>>(static f => Problem(f), static f => Problem(f), static f => Problem(f))); } - private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule); + private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule, fault.Cause); } /// Turns a Result whose fault is a closed set into the HTTP response an endpoint returns, as does for an enum. diff --git a/src/CodoMetis.TypeKit/Fault.cs b/src/CodoMetis.TypeKit/Fault.cs index e2f33a88..f44ca270 100644 --- a/src/CodoMetis.TypeKit/Fault.cs +++ b/src/CodoMetis.TypeKit/Fault.cs @@ -15,7 +15,7 @@ namespace CodoMetis.TypeKit; /// it can say anything, so whether it quotes the request is the application's to decide. /// /// -/// makes one and infers the type, with a detail or without: +/// makes one and infers the type, with a detail or without: /// Result.Error(Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May.")). Where the reason /// answers for several rules, WithRule names the one that refused, an enum's or a closed set's /// member, as a key a client branches on: Fault.Of(AccountFault.InvalidEmail).WithRule(EmailFault.NoAt). A bare reason @@ -42,6 +42,16 @@ public Fault(TFault reason, string? detail = null) : detail; } + /// Creates the fault, keeping the exception that caused it. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath, kept for the log as . Never null. + /// or is null. + /// is empty or white space: say for no detail. + public Fault(TFault reason, string? detail, Exception cause) + : this(reason, detail) => + Cause = cause ?? throw new ArgumentNullException(nameof(cause), "A fault's cause is the exception underneath; leave it out for none."); + /// Which fault. public TFault Reason { get; } @@ -64,6 +74,27 @@ public Fault(TFault reason, string? detail = null) /// The rule's type, which equality compares beside : two types' rules can read alike. private Type? RuleType { get; init; } + /// The exception that caused this occurrence, or : for the log, never for the client. + /// + /// Set by , kept by WithRule and by a fault of + /// several types. CodoMetis.TypeKit.AspNetCore hands it to the host beside the problem and never writes it into + /// one. Equality leaves it out, so a fault with a cause equals the same fault without one, and the fault prints + /// without it: log it as the exception it is. + /// + public Exception? Cause { get; } + + /// Equal where the reason, the detail and the rule are; the takes no part. + /// The other fault. + /// Whether the two are the same fault. + public bool Equals(Fault other) => + EqualityComparer.Default.Equals(Reason, other.Reason) + && string.Equals(Detail, other.Detail, StringComparison.Ordinal) + && string.Equals(Rule, other.Rule, StringComparison.Ordinal) + && RuleType == other.RuleType; + + /// + public override int GetHashCode() => HashCode.Combine(Reason, Detail, Rule, RuleType); + /// The same fault with , a member of , as its . internal Fault Naming(string rule, Type type) => this with { Rule = rule, RuleType = type }; @@ -197,4 +228,18 @@ public static class Fault /// is null. /// is empty or white space. public static Fault Of(TFault reason, string? detail = null) where TFault : notnull => new(reason, detail); + + /// A fault and the exception that caused it, kept for the log and never written to a response. + /// + /// The pattern is a MapError from a caught exception: + /// Result.Catch(read).MapError(e => Fault.Of(ImportFault.Unreadable, "The file could not be read.", e)). + /// + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath. Never null. + /// The reason's type, inferred. + /// The fault, with as its . + /// or is null. + /// is empty or white space. + public static Fault Of(TFault reason, string? detail, Exception cause) where TFault : notnull => new(reason, detail, cause); } diff --git a/src/CodoMetis.TypeKit/FaultOfSets.cs b/src/CodoMetis.TypeKit/FaultOfSets.cs index c62ac641..f48fcf18 100644 --- a/src/CodoMetis.TypeKit/FaultOfSets.cs +++ b/src/CodoMetis.TypeKit/FaultOfSets.cs @@ -54,12 +54,26 @@ namespace CodoMetis.TypeKit; /// The fault. public static Fault Of(T1 reason, string? detail = null) => Fault.Of(reason, detail); + /// A fault of the first type, with the exception that caused it. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath, kept as the fault's Cause. Never null. + /// The fault. + public static Fault Of(T1 reason, string? detail, Exception cause) => Fault.Of(reason, detail, cause); + /// A fault of the second type, with a sentence about this occurrence or none. /// Which fault. Never null. /// A sentence about this occurrence, or for none. Never blank. /// The fault. public static Fault Of(T2 reason, string? detail = null) => Fault.Of(reason, detail); + /// A fault of the second type, with the exception that caused it. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath, kept as the fault's Cause. Never null. + /// The fault. + public static Fault Of(T2 reason, string? detail, Exception cause) => Fault.Of(reason, detail, cause); + /// The fault is, of the first type: what the conversion does, by name. /// The fault. /// The fault. @@ -163,18 +177,39 @@ private static Fault Checked(Fault fault) where T : notnull => /// The fault. public static Fault Of(T1 reason, string? detail = null) => Fault.Of(reason, detail); + /// A fault of the first type, with the exception that caused it. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath, kept as the fault's Cause. Never null. + /// The fault. + public static Fault Of(T1 reason, string? detail, Exception cause) => Fault.Of(reason, detail, cause); + /// A fault of the second type, with a sentence about this occurrence or none. /// Which fault. Never null. /// A sentence about this occurrence, or for none. Never blank. /// The fault. public static Fault Of(T2 reason, string? detail = null) => Fault.Of(reason, detail); + /// A fault of the second type, with the exception that caused it. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath, kept as the fault's Cause. Never null. + /// The fault. + public static Fault Of(T2 reason, string? detail, Exception cause) => Fault.Of(reason, detail, cause); + /// A fault of the third type, with a sentence about this occurrence or none. /// Which fault. Never null. /// A sentence about this occurrence, or for none. Never blank. /// The fault. public static Fault Of(T3 reason, string? detail = null) => Fault.Of(reason, detail); + /// A fault of the third type, with the exception that caused it. + /// Which fault. Never null. + /// A sentence about this occurrence, or for none. Never blank. + /// The exception underneath, kept as the fault's Cause. Never null. + /// The fault. + public static Fault Of(T3 reason, string? detail, Exception cause) => Fault.Of(reason, detail, cause); + /// The fault is, of the first type: what the conversion does, by name. /// The fault. /// The fault. diff --git a/src/CodoMetis.TypeKit/README.md b/src/CodoMetis.TypeKit/README.md index 53be9727..eeb70f0b 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -279,6 +279,7 @@ Result fetched = await Result.CatchAsync(() => client.GetByte propagates. - **An exception is for the code that called, not for a client.** `ToHttpResult()` doesn't accept a `Result`. Use `MapError` to turn the exception into a fault that it does accept. + `MapError(e => Fault.Of(reason, detail, e))` keeps the exception as the fault's cause, for your log. ### A fault with a sentence @@ -288,6 +289,9 @@ beside it, in a `Fault`: the fault as its `Reason`, and an optional `Det - **`Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May.")`** infers the type. `Fault.Of(OrderFault.NotFound)` makes one without a detail. A blank detail throws `ArgumentException`. Leave the detail out instead, and it is `null`. +- **`Fault.Of(reason, detail, exception)` keeps what failed underneath** as the fault's `Cause`. + It's for your log. A fault with a cause equals the same fault without one, and prints the same. + CodoMetis.TypeKit.AspNetCore hands it to your host and never writes it into a response. - **A bare fault doesn't convert to one**, so comparing the two doesn't compile. Write `fault.Reason == OrderFault.NotFound`. - **`WithRule` names the rule that failed**, where the reason covers several rules. The rule is an diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs index 0ac7324a..5169eed2 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/FaultResponsePartsTests.cs @@ -50,6 +50,18 @@ private static Result> ArchiveWithRule(int id) => private static Result> ArchiveWithDetail(int id) => id == 1 ? Result.Success() : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.")); + /// What failed underneath a refusal, which names a host the client must not learn of. + private static readonly TimeoutException Underneath = new("db-host-7 timed out after 30 s"); + + private static Result> RenameWithCause(int id) => + id == 1 ? new Order(1, "renamed") : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.", Underneath)); + + private static Result> ArchiveWithCause(int id) => + id == 1 ? Result.Success() : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.", Underneath)); + + private static Result> RenameComposedWithCause(int id) => + id == 1 ? new Order(1, "renamed") : Result.Error(Fault.Of(OrderFault.NameTaken, "Order 7 is called that.", Underneath)); + private static Result Find(int id) => id switch { 1 => new Order(1, "book"), @@ -412,11 +424,67 @@ public async Task CustomizeProblemDetails_sees_the_rule() (await ProblemAsync(app, "/detailed/orders/2", HttpStatusCode.Conflict))["code"]!.GetValue().ShouldBe("-"); } + /// + /// A fault's cause reaches the host through the feature, for its log, through every overload over a + /// and over a fault of several types, and never the client: the problem + /// has the members the same fault without a cause writes, and names neither the exception's type nor + /// its message (decision 52). + /// + [Theory] + [InlineData("PUT", "/caused/orders/2")] // Result> + [InlineData("DELETE", "/caused/orders/2")] // Result> + [InlineData("POST", "/caused/orders/2/copies")] // Result>, with the endpoint's success + [InlineData("POST", "/caused/orders/2/archive")] // Result>, with the endpoint's success + [InlineData("PATCH", "/caused/orders/2")] // Result> + public async Task A_cause_reaches_the_host_through_the_feature_and_never_the_problem(string method, string path) + { + var causes = new List(); + await using var app = await StartAsync( + services: services => + { + Responses(services); + services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + causes.Add(context.HttpContext.Features.Get()?.Cause)); + }, + endpoints: app => + { + app.MapPut("/caused/orders/{id:int}", (int id) => RenameWithCause(id).ToHttpResult()); + app.MapDelete("/caused/orders/{id:int}", (int id) => ArchiveWithCause(id).ToHttpResult()); + app.MapPost("/caused/orders/{id:int}/copies", (int id) => RenameWithCause(id).ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order))); + app.MapPost("/caused/orders/{id:int}/archive", (int id) => ArchiveWithCause(id).ToHttpResult(() => TypedResults.Accepted("/jobs/7"))); + app.MapPatch("/caused/orders/{id:int}", (int id) => RenameComposedWithCause(id).ToHttpResult()); + }); + + using var request = new HttpRequestMessage(new HttpMethod(method), path); + using var response = await app.GetTestClient().SendAsync(request, TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.Conflict, $"{method} {path}"); + causes.ShouldHaveSingleItem().ShouldBeSameAs(Underneath, $"{method} {path}"); + JsonNode.Parse(body)!.AsObject().Select(member => member.Key).Order().ToArray().ShouldBe(["detail", "fault", "status", "title", "traceId", "type"], customMessage: body); + body.ShouldNotContain(nameof(TimeoutException), Case.Insensitive); + body.ShouldNotContain("db-host-7", Case.Insensitive); + } + + /// The response an endpoint returns carries the cause as well, for a test or a filter that reads it before it is written. + [Fact] + public void The_problem_carries_the_cause_and_a_fault_without_one_none() + { + RenameWithCause(2).ToHttpResult().Result.ShouldBeOfType>().Cause.ShouldBeSameAs(Underneath); + RenameWithDetail(2).ToHttpResult().Result.ShouldBeOfType>().Cause.ShouldBeNull(); + RenameWithCause(2).Map(order => order).ToHttpResult().Result.ShouldBeOfType>().Rule.ShouldBeNull(); + } + /// The rule joined the feature with a default, so a host's or a test's own implementation still builds, and names none. [Fact] public void A_fault_feature_of_the_host_s_own_names_no_rule() => ((IFaultFeature)new HostFeature()).Rule.ShouldBeNull(); + /// The cause joined the feature with a default too. + [Fact] + public void A_fault_feature_of_the_host_s_own_has_no_cause() => + ((IFaultFeature)new HostFeature()).Cause.ShouldBeNull(); + private sealed class HostFeature : IFaultFeature { public Type FaultType => typeof(OrderFault); diff --git a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs index d094614b..5a7b2e19 100644 --- a/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/AspNetCore/FaultResponseSamples.cs @@ -39,6 +39,14 @@ public sealed record Account(int Id, string Email); public sealed record NewAccount(string Email); +/// What a store throws when a unique index rejects a row. +public sealed class UniqueViolationException(string message) : Exception(message); + +public interface IAccountStore +{ + Account Save(Account account); +} + public interface IAccounts { Result Open(NewAccount body); @@ -142,6 +150,28 @@ public static Result> Reserved() return reserved; } + public static Result> Saved(IAccountStore store, Account account) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-cause-domain + Result> saved = Result.Catch(() => store.Save(account)) + .MapError(e => Fault.Of(AccountFault.EmailTaken, "Another account took the address a moment ago.", e)); + // end sample + + return saved; + } + + public static void Logged(WebApplicationBuilder builder) + { + // sample: CodoMetis.TypeKit.AspNetCore/fault-cause-log + builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => + { + if (context.HttpContext.Features.Get() is { Cause: { } cause } fault) + context.HttpContext.RequestServices.GetRequiredService().CreateLogger("Faults") + .LogWarning(cause, "Answered {Fault} for an exception", fault.Name); // your log gets the exception, the client doesn't + }); + // end sample + } + public static void Shaped(WebApplicationBuilder builder) { // sample: CodoMetis.TypeKit.AspNetCore/fault-feature @@ -222,6 +252,25 @@ private static async Task StartWithAsync(Action throw thrown; + } + + /// The README's cause: the store's exception is kept on the fault, and the fault is still the one the client sees. + [Fact] + public void A_caught_exception_is_kept_as_the_cause() + { + var thrown = new UniqueViolationException("duplicate key value violates unique constraint \"ix_accounts_email\""); + + var saved = Faults.Saved(new TakenStore(thrown), new Account(7, "ada@example.com")); + + saved.TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.Reason.ShouldBe(AccountFault.EmailTaken); + fault.Cause.ShouldBeSameAs(thrown); + fault.ShouldBe(Fault.Of(AccountFault.EmailTaken, "Another account took the address a moment ago.")); + } + /// The README's one call for every fault set: AccountFault answered by its kind with no mapping of its own. [Fact] public async Task Every_fault_set_is_answered_by_its_kind_with_one_call() diff --git a/test/CodoMetis.TypeKit.Tests/ComposedFaultTests.cs b/test/CodoMetis.TypeKit.Tests/ComposedFaultTests.cs index 8348827b..e9dac228 100644 --- a/test/CodoMetis.TypeKit.Tests/ComposedFaultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/ComposedFaultTests.cs @@ -67,6 +67,31 @@ public void Of_names_the_type_where_a_fault_becomes_a_result() thirdFault.ShouldBe(Fault.Of(RemovalFault.StillReferenced)); } + /// + /// A fault of several types holds the fault intact, its cause included, whether it converts or is made + /// by Of; and a cause takes no part in its equality, as in the fault's own (decision 52). + /// + [Fact] + public void The_cause_is_kept_and_takes_no_part_in_equality() + { + var timeout = new TimeoutException(); + + Fault converted = Fault.Of(ConcurrencyFault.Stale, null, timeout); + var first = Fault.Of(OrderFault.NotFound, "Order 7 is gone.", timeout); + var second = Fault.Of(ConcurrencyFault.Conflict, null, timeout); + var third = Fault.Of(RemovalFault.StillReferenced, null, timeout); + + converted.Match(f => f.Cause, f => f.Cause).ShouldBeSameAs(timeout); + first.Match(f => f.Cause, f => f.Cause).ShouldBeSameAs(timeout); + second.Match(f => f.Cause, f => f.Cause).ShouldBeSameAs(timeout); + third.Match(f => f.Cause, f => f.Cause, f => f.Cause).ShouldBeSameAs(timeout); + Fault.Of(OrderFault.NameTaken, null, timeout).Match(f => f.Cause, f => null, f => null).ShouldBeSameAs(timeout); + Fault.Of(ConcurrencyFault.Stale, null, timeout).Match(f => null, f => f.Cause, f => null).ShouldBeSameAs(timeout); + first.ShouldBe(Fault.Of(OrderFault.NotFound, "Order 7 is gone.")); + first.GetHashCode().ShouldBe(Fault.Of(OrderFault.NotFound, "Order 7 is gone.").GetHashCode()); + first.ToString().ShouldBe(Fault.Of(OrderFault.NotFound, "Order 7 is gone.").ToString()); + } + [Fact] public void Two_faults_are_equal_when_they_hold_the_same_type_s_equal_fault() { diff --git a/test/CodoMetis.TypeKit.Tests/FaultTests.cs b/test/CodoMetis.TypeKit.Tests/FaultTests.cs index e13485b2..cc192cd9 100644 --- a/test/CodoMetis.TypeKit.Tests/FaultTests.cs +++ b/test/CodoMetis.TypeKit.Tests/FaultTests.cs @@ -104,6 +104,77 @@ public void Two_faults_are_equal_by_reason_and_detail() Fault.Of(OrderFault.NotFound, "Order 7 is gone.").Reason.ShouldBe(OrderFault.NotFound); } + /// A cause is kept for the log beside the reason and the detail (decision 52). + [Fact] + public void A_fault_keeps_the_exception_that_caused_it() + { + var timeout = new TimeoutException("db-host-7 timed out"); + + var fault = Fault.Of(OrderFault.NotFound, "Order 7 could not be read.", timeout); + + fault.Cause.ShouldBeSameAs(timeout); + fault.Reason.ShouldBe(OrderFault.NotFound); + fault.Detail.ShouldBe("Order 7 could not be read."); + Fault.Of(OrderFault.NotFound, null, timeout).Detail.ShouldBeNull(); + new Fault(OrderFault.NotFound, null, timeout).Cause.ShouldBeSameAs(timeout); + Fault.Of(OrderFault.NotFound, "Order 7 is gone.").Cause.ShouldBeNull(); + } + + /// The pattern the docs show: a caught exception mapped to a fault keeps the exception as its cause. + [Fact] + public void MapError_from_a_caught_exception_keeps_it_as_the_cause() + { + var thrown = new InvalidOperationException("the file is locked"); + + var mapped = Result.Catch(() => Read(thrown)) + .MapError(e => Fault.Of(OrderFault.NotFound, "The order could not be read.", e)); + + mapped.TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.Cause.ShouldBeSameAs(thrown); + + static int Read(Exception thrown) => throw thrown; + } + + /// + /// The cause is for the log: a fault with one equals the same fault without one and hashes alike, so a + /// test that compares faults, or a set of them, is unchanged by where the fault came from; and it + /// prints as 2.2 printed it, since assertion messages quote it. + /// + [Fact] + public void A_cause_takes_no_part_in_equality_or_printing() + { + var caused = Fault.Of(OrderFault.NotFound, "Order 7 is gone.", new TimeoutException("db-host-7")); + var plain = Fault.Of(OrderFault.NotFound, "Order 7 is gone."); + + caused.ShouldBe(plain); + (caused == plain).ShouldBeTrue(); + caused.GetHashCode().ShouldBe(plain.GetHashCode()); + caused.ToString().ShouldBe(plain.ToString()); + caused.ToString().ShouldNotContain("db-host-7"); + caused.ShouldNotBe(Fault.Of(OrderFault.NotFound, "Order 8 is gone.", new TimeoutException())); + caused.WithRule(Access.Read).ShouldNotBe(plain); + } + + [Fact] + public void A_rule_and_a_copy_keep_the_cause() + { + var timeout = new TimeoutException(); + var fault = Fault.Of(OrderFault.NotFound, null, timeout); + + fault.WithRule(Access.Read).Cause.ShouldBeSameAs(timeout); + fault.WithRule(EmailRule.NoAt).Cause.ShouldBeSameAs(timeout); + (fault with { }).Cause.ShouldBeSameAs(timeout); + } + + /// A null cause is a mistake where one is given: leave it out for none. + [Fact] + public void A_null_cause_is_refused() + { + Should.Throw(() => Fault.Of(OrderFault.NotFound, "Order 7 is gone.", null!)).ParamName.ShouldBe("cause"); + Should.Throw(() => new Fault(OrderFault.NotFound, null, null!)).ParamName.ShouldBe("cause"); + Should.Throw(() => Fault.Of(OrderFault.NotFound, " ", new TimeoutException())).ParamName.ShouldBe("detail"); + } + [Flags] private enum Access { Read = 1, Write = 2 } diff --git a/tools/probe/corpus/seeds/C03-detail-and-rule-intact.patch b/tools/probe/corpus/seeds/C03-detail-and-rule-intact.patch index a7ea20bf..6c83cea9 100644 --- a/tools/probe/corpus/seeds/C03-detail-and-rule-intact.patch +++ b/tools/probe/corpus/seeds/C03-detail-and-rule-intact.patch @@ -1,13 +1,13 @@ diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs -index 6df36fb..17611f3 100644 +index efbe6b0..3377074 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs @@ -249,7 +249,7 @@ public static class TypeKitHttpResultExtensions static fault => fault.Match, FaultProblem, FaultProblem>>(static f => Problem(f), static f => Problem(f), static f => Problem(f))); } -- private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule); -+ private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, null, fault.Rule); +- private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule, fault.Cause); ++ private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, null, fault.Rule, fault.Cause); } /// Turns a Result whose fault is a closed set into the HTTP response an endpoint returns, as does for an enum. diff --git a/tools/probe/corpus/seeds/C03-each-error-own-set.patch b/tools/probe/corpus/seeds/C03-each-error-own-set.patch index 61b70590..23bd22a7 100644 --- a/tools/probe/corpus/seeds/C03-each-error-own-set.patch +++ b/tools/probe/corpus/seeds/C03-each-error-own-set.patch @@ -1,17 +1,17 @@ diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs -index f9919a2..1e4ed87 100644 +index 822adc3..0c131de 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs -@@ -47,6 +47,8 @@ public sealed class FaultProblem : IResult, IEndpointMetadataProvider - /// The rule that refused, EmailFault.NoAt, where the result carried a naming one. - public string? Rule { get; } +@@ -54,6 +54,8 @@ public sealed class FaultProblem : IResult, IEndpointMetadataProvider + /// + public Exception? Cause { get; } + internal bool Composed { get; init; } + /// public async Task ExecuteAsync(HttpContext httpContext) { -@@ -77,7 +79,7 @@ public sealed class FaultProblem : IResult, IEndpointMetadataProvider +@@ -84,7 +86,7 @@ public sealed class FaultProblem : IResult, IEndpointMetadataProvider { await TypedResults.Problem( statusCode: status, @@ -21,15 +21,15 @@ index f9919a2..1e4ed87 100644 detail: detail, extensions: extensions) diff --git a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs -index 6df36fb..83ae661 100644 +index efbe6b0..e5e89d7 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs @@ -249,7 +249,7 @@ public static class TypeKitHttpResultExtensions static fault => fault.Match, FaultProblem, FaultProblem>>(static f => Problem(f), static f => Problem(f), static f => Problem(f))); } -- private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule); -+ private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule) { Composed = true }; +- private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule, fault.Cause); ++ private static FaultProblem Problem(Fault fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule, fault.Cause) { Composed = true }; } /// Turns a Result whose fault is a closed set into the HTTP response an endpoint returns, as does for an enum.