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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` and `Patch<T>`: 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<TFault>.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<TFault>.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

Expand Down
5 changes: 3 additions & 2 deletions design/option-result.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T1, T2>` and `Fault<T1, T2, T3>` (2.1.0)** hold one fault of one of several types, so a
Expand Down
3 changes: 0 additions & 3 deletions design/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TFault>`, 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<T, Exception>` 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.
Expand Down
21 changes: 18 additions & 3 deletions src/CodoMetis.TypeKit.AspNetCore/FaultProblem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ namespace CodoMetis.TypeKit.AspNetCore;
public sealed class FaultProblem<TFault> : 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;
}

/// <summary>The fault this response reports.</summary>
Expand All @@ -47,6 +48,12 @@ internal FaultProblem(TFault fault, string? detail = null, string? rule = null)
/// <summary>The rule that refused, <c>EmailFault.NoAt</c>, where the result carried a <see cref="Fault{TFault}"/> naming one.</summary>
public string? Rule { get; }

/// <summary>
/// The exception that caused the fault, where the result carried a <see cref="Fault{TFault}"/> with one
/// (<see cref="Fault{TFault}.Cause"/>): for the log, and never written into the problem.
/// </summary>
public Exception? Cause { get; }

/// <inheritdoc />
public async Task ExecuteAsync(HttpContext httpContext)
{
Expand All @@ -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<IFaultFeature>();
features.Set<IFaultFeature>(new FaultFeature(typeof(TFault), Fault, name, detail, Rule, !answer.Named));
features.Set<IFaultFeature>(new FaultFeature(typeof(TFault), Fault, name, detail, Rule, !answer.Named, Cause));
var extensions = new Dictionary<string, object?>();
if (answer.Named) extensions[FaultResponsesOperationTransformer.FaultMember] = name;
if (answer.Named && Rule is not null) extensions[FaultResponsesOperationTransformer.RuleMember] = Rule;
Expand Down Expand Up @@ -105,7 +112,7 @@ static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, Endpoi
builder.Metadata.Add(FaultResponsesMetadata<TFault>.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;
}

/// <summary>
Expand Down Expand Up @@ -142,6 +149,14 @@ public interface IFaultFeature
/// </summary>
/// <remarks>Implemented by default as <see langword="false"/>, so an implementation of the host's own still builds.</remarks>
bool NameWithheld => false;

/// <summary>
/// The exception that caused the fault (<see cref="Fault{TFault}.Cause"/>), or <see langword="null"/>: for
/// the host's log. The problem never carries it, and a <c>CustomizeProblemDetails</c> that copies it into
/// one tells the client what failed underneath.
/// </summary>
/// <remarks>Implemented by default as <see langword="null"/>, so an implementation of the host's own, a test's, still builds.</remarks>
Exception? Cause => null;
}

/// <summary>How <c>ToHttpResult()</c> writes a problem: <c>services.Configure&lt;FaultResponseOptions&gt;(options =&gt; …)</c>.</summary>
Expand Down
31 changes: 30 additions & 1 deletion src/CodoMetis.TypeKit.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TFault>` as its cause. Your log gets the
exception, and the client gets the same problem as before:

```csharp
Result<Account, Fault<AccountFault>> saved = Result.Catch<Account, UniqueViolationException>(() => 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<IFaultFeature>() is { Cause: { } cause } fault)
context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().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<TFault>.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
Expand Down Expand Up @@ -601,7 +630,7 @@ works in the same place. A `Result<TError>` 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.

Expand Down
10 changes: 5 additions & 5 deletions src/CodoMetis.TypeKit.AspNetCore/TypeKitHttpResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public static Results<Ok<T>, FaultProblem<TFault>> ToHttpResult<T, TFault>(this
where TFault : notnull =>
result.Match<Results<Ok<T>, FaultProblem<TFault>>>(
static value => TypedResults.Ok(value),
static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule));
static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule, fault.Cause));

/// <summary>A success as <c>204 No Content</c>; a fault as problem details for its reason, with its detail.</summary>
/// <param name="result">The outcome of the operation the endpoint ran.</param>
Expand All @@ -106,7 +106,7 @@ public static Results<NoContent, FaultProblem<TFault>> ToHttpResult<TFault>(this
where TFault : notnull =>
result.Match<Results<NoContent, FaultProblem<TFault>>>(
static () => TypedResults.NoContent(),
static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule));
static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule, fault.Cause));

/// <summary>A success as the response <paramref name="success"/> makes of its value; a fault as problem details for its reason, with its detail.</summary>
/// <param name="result">The outcome of the operation the endpoint ran.</param>
Expand All @@ -118,7 +118,7 @@ public static Results<NoContent, FaultProblem<TFault>> ToHttpResult<TFault>(this
where TSuccess : IResult
{
ArgumentNullException.ThrowIfNull(success);
return result.Match<Results<TSuccess, FaultProblem<TFault>>>(value => success(value), static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule));
return result.Match<Results<TSuccess, FaultProblem<TFault>>>(value => success(value), static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule, fault.Cause));
}

/// <summary>A success as the response <paramref name="success"/> makes; a fault as problem details for its reason, with its detail.</summary>
Expand All @@ -130,7 +130,7 @@ public static Results<NoContent, FaultProblem<TFault>> ToHttpResult<TFault>(this
where TSuccess : IResult
{
ArgumentNullException.ThrowIfNull(success);
return result.Match<Results<TSuccess, FaultProblem<TFault>>>(() => success(), static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule));
return result.Match<Results<TSuccess, FaultProblem<TFault>>>(() => success(), static fault => new FaultProblem<TFault>(fault.Reason, fault.Detail, fault.Rule, fault.Cause));
}

/// <summary>A success as <c>200 OK</c> with its value; a fault of either type as problem details for its reason, with its detail.</summary>
Expand Down Expand Up @@ -249,7 +249,7 @@ public static Results<NoContent, FaultProblem<T1>, FaultProblem<T2>, FaultProble
static fault => fault.Match<Results<TSuccess, FaultProblem<T1>, FaultProblem<T2>, FaultProblem<T3>>>(static f => Problem(f), static f => Problem(f), static f => Problem(f)));
}

private static FaultProblem<TFault> Problem<TFault>(Fault<TFault> fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule);
private static FaultProblem<TFault> Problem<TFault>(Fault<TFault> fault) where TFault : notnull => new(fault.Reason, fault.Detail, fault.Rule, fault.Cause);
}

/// <summary>Turns a <c>Result</c> whose fault is a closed set into the HTTP response an endpoint returns, as <see cref="TypeKitHttpResultExtensions"/> does for an enum.</summary>
Expand Down
47 changes: 46 additions & 1 deletion src/CodoMetis.TypeKit/Fault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace CodoMetis.TypeKit;
/// it can say anything, so whether it quotes the request is the application's to decide.
/// </para>
/// <para>
/// <see cref="Fault.Of{TFault}"/> makes one and infers the type, with a detail or without:
/// <see cref="Fault.Of{TFault}(TFault, string?)"/> makes one and infers the type, with a detail or without:
/// <c>Result.Error(Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May."))</c>. Where the reason
/// answers for several rules, <c>WithRule</c> names the one that refused, an enum's or a closed set's
/// member, as a key a client branches on: <c>Fault.Of(AccountFault.InvalidEmail).WithRule(EmailFault.NoAt)</c>. A bare reason
Expand All @@ -42,6 +42,16 @@ public Fault(TFault reason, string? detail = null)
: detail;
}

/// <summary>Creates the fault, keeping the exception that caused it.</summary>
/// <param name="reason">Which fault. Never null.</param>
/// <param name="detail">A sentence about this occurrence, or <see langword="null"/> for none. Never blank.</param>
/// <param name="cause">The exception underneath, kept for the log as <see cref="Cause"/>. Never null.</param>
/// <exception cref="ArgumentNullException"><paramref name="reason"/> or <paramref name="cause"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="detail"/> is empty or white space: say <see langword="null"/> for no detail.</exception>
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.");

/// <summary>Which fault.</summary>
public TFault Reason { get; }

Expand All @@ -64,6 +74,27 @@ public Fault(TFault reason, string? detail = null)
/// <summary>The rule's type, which equality compares beside <see cref="Rule"/>: two types' rules can read alike.</summary>
private Type? RuleType { get; init; }

/// <summary>The exception that caused this occurrence, or <see langword="null"/>: for the log, never for the client.</summary>
/// <remarks>
/// Set by <see cref="Fault.Of{TFault}(TFault, string?, Exception)"/>, kept by <c>WithRule</c> 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.
/// </remarks>
public Exception? Cause { get; }

/// <summary>Equal where the reason, the detail and the rule are; the <see cref="Cause"/> takes no part.</summary>
/// <param name="other">The other fault.</param>
/// <returns>Whether the two are the same fault.</returns>
public bool Equals(Fault<TFault> other) =>
EqualityComparer<TFault>.Default.Equals(Reason, other.Reason)
&& string.Equals(Detail, other.Detail, StringComparison.Ordinal)
&& string.Equals(Rule, other.Rule, StringComparison.Ordinal)
&& RuleType == other.RuleType;

/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(Reason, Detail, Rule, RuleType);

/// <summary>The same fault with <paramref name="rule"/>, a member of <paramref name="type"/>, as its <see cref="Rule"/>.</summary>
internal Fault<TFault> Naming(string rule, Type type) => this with { Rule = rule, RuleType = type };

Expand Down Expand Up @@ -197,4 +228,18 @@ public static class Fault
/// <exception cref="ArgumentNullException"><paramref name="reason"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="detail"/> is empty or white space.</exception>
public static Fault<TFault> Of<TFault>(TFault reason, string? detail = null) where TFault : notnull => new(reason, detail);

/// <summary>A fault and the exception that caused it, kept for the log and never written to a response.</summary>
/// <remarks>
/// The pattern is a <c>MapError</c> from a caught exception:
/// <c>Result.Catch(read).MapError(e =&gt; Fault.Of(ImportFault.Unreadable, "The file could not be read.", e))</c>.
/// </remarks>
/// <param name="reason">Which fault. Never null.</param>
/// <param name="detail">A sentence about this occurrence, or <see langword="null"/> for none. Never blank.</param>
/// <param name="cause">The exception underneath. Never null.</param>
/// <typeparam name="TFault">The reason's type, inferred.</typeparam>
/// <returns>The fault, with <paramref name="cause"/> as its <see cref="Fault{TFault}.Cause"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="reason"/> or <paramref name="cause"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="detail"/> is empty or white space.</exception>
public static Fault<TFault> Of<TFault>(TFault reason, string? detail, Exception cause) where TFault : notnull => new(reason, detail, cause);
}
Loading
Loading