diff --git a/CHANGELOG.md b/CHANGELOG.md index bae1803..9125dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ section is the GitHub release's notes. - AspNetCore: `FaultResponse.WithoutFaultName()` answers a member without naming it: no `fault`, no `rule` and no member description in the problem, so two causes on one status look alike; the document says that status names no fault, and `IFaultFeature.NameWithheld` tells the host ([Faults a client must not tell apart](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#faults-a-client-must-not-tell-apart)). - `Result.CatchAsync` takes a call that returns a `ValueTask`, `stream.ReadAsync(buffer)` without `.AsTask()`; an `async` lambda binds as before ([An exception as an outcome](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/result.md#an-exception-as-an-outcome)). - `TraverseAsync` on `Option` and `Patch`: an asynchronous check of an optional input or a patch field, with a token form; nothing is called without a value ([Collect many results](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/result.md#collect-many-results)). +- `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 diff --git a/design/option-result.md b/design/option-result.md index 7db07b7..17a25a2 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 d264933..8598325 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)). - A default value-object key over `ulong` generated as a plain one is, which needs a per-key value generator a compiled model can write ([decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)). - Testing: async assertions. diff --git a/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs b/src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs index 1558afb..1aa6711 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) { @@ -71,7 +78,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, !answer.Named)); + features.Set(new FaultFeature(typeof(TFault), Fault, name, detail, Rule, !answer.Named, Cause)); var extensions = new Dictionary(); if (answer.Named) extensions[FaultResponsesOperationTransformer.FaultMember] = name; if (answer.Named && Rule is not null) extensions[FaultResponsesOperationTransformer.RuleMember] = Rule; @@ -105,7 +112,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, bool NameWithheld) : IFaultFeature; + private sealed record FaultFeature(Type FaultType, object Fault, string Name, string? Detail, string? Rule, bool NameWithheld, Exception? Cause) : IFaultFeature; } /// @@ -142,6 +149,14 @@ public interface IFaultFeature /// /// Implemented by default as , so an implementation of the host's own still builds. bool NameWithheld => false; + + /// + /// 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 79251ad..ebf405b 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 6df36fb..efbe6b0 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 e2f33a8..f44ca27 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 c62ac64..f48fcf1 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 c32133d..9fe9ca8 100644 --- a/src/CodoMetis.TypeKit/README.md +++ b/src/CodoMetis.TypeKit/README.md @@ -281,6 +281,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 @@ -290,6 +291,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 0ac7324..5169eed 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 f652557..1503307 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); @@ -161,6 +169,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 @@ -241,6 +271,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 8348827..e9dac22 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 e13485b..cc192cd 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 }