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
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ
| `src/src/<Project>/Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README; `Sdk/.agents/**` would ship agent skills with the package |
| `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests |
| `src/examples` | Runnable, non-packable examples: one project per integration aspect on a shared Tenant* domain |
| `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website |
| `Directory.Packages.props` | Centrally managed NuGet versions |
| `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour |
| `global.json` | Required .NET SDK, `Purview.BuildSdk` and Microsoft.Testing.Platform selection |
Expand Down Expand Up @@ -243,6 +244,10 @@ document: `Examples.Basic` (the result type and the generated helpers), `Example
- Keep each package `Sdk/README.md`, the repository-root `README.md`, `AGENTS.md` and the code aligned. If a
change alters diagnostics, build properties, defaults, resolution order or public API, change all of them in
the same commit.
- `docs/wiki` is the user-facing documentation suite the purview-dev website aggregates
(`source: github-path`, `path: docs/wiki`, landing page `Getting-Started.md`). Keep the affected page in step
with the code in the same commit, keep `_Sidebar.md`'s order matching the pages that exist, and keep every page
to a single top-level `#` heading because the site derives the page title from the first one.
- Examples must compile conceptually against the current public API. Use placeholders for credentials and
environment-specific values.

Expand Down Expand Up @@ -376,8 +381,8 @@ Before handing work back:
- Confirm only intended files changed.
- Review public API, package-content and dependency-direction implications.
- Add or update focused tests for code changes, including incremental-cache coverage for pipeline changes.
- Update the affected package `Sdk/README.md`, the root `README.md`, `AGENTS.md` and `AnalyzerReleases` when the
change affects them.
- Update the affected package `Sdk/README.md`, the root `README.md`, `docs/wiki`, `AGENTS.md` and
`AnalyzerReleases` when the change affects them.
- Watch for the packaging traps: `<IsPackable>true</IsPackable>` missing from a new package project, a new
union-declaring file missing from `.csharpierignore`, and a new diagnostic missing from
`AnalyzerReleases.Unshipped.md`.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ the host mapping it — unless a rule answers one of its codes or categories:
}
```

## Documentation

The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md):

- [Getting started](docs/wiki/Getting-Started.md)
- [Core concepts](docs/wiki/Core-Concepts.md) and [combinators](docs/wiki/Combinators.md)
- [Union errors](docs/wiki/Union-Errors.md)
- [Source generator](docs/wiki/Source-Generator.md) and [diagnostics](docs/wiki/Diagnostics.md)
- [ASP.NET Core integration](docs/wiki/AspNetCore-Integration.md)
- [ZodSharp integration](docs/wiki/ZodSharp-Integration.md) and [problem details](docs/wiki/ZodSharp-ProblemDetails.md)
- [Guarantees and limitations](docs/wiki/Guarantees-and-Limitations.md)

## Requirements

- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets
Expand All @@ -224,6 +236,7 @@ the host mapping it — unless a rule answers one of its codes or categories:
| `src/src/<Project>/Sdk` | Package-only assets: `README.md` (packed as the package README) and any `Sdk/.agents/**` skills |
| `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests |
| `src/examples` | Runnable, non-packable examples: one project per integration aspect, built on the Tenant* domain |
| `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website |
| `Directory.Packages.props` | Centrally managed NuGet versions |
| `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour |
| `global.json` | Required .NET SDK, `Purview.BuildSdk` and Microsoft.Testing.Platform selection |
Expand Down
38 changes: 38 additions & 0 deletions docs/wiki/Agent-Skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Agent Skills

The packages ship **agent skills** to consumers. A repository that imports `Purview.BuildSdk` receives them in
its own `.agents/` folder on the next restore or build, so AI agents working there get the guidance
automatically. The content is authored in this repository under `src/src/<Project>/Sdk/.agents/**` and packed as
`.agents/**`; there is nothing to install by hand.

## Skills shipped by this repository

| Content | Package | Covers |
| --- | --- | --- |
| `skills/purview-results-core` | `Purview.Results` | The result type, its three states, combinators and the throw-on-misuse contract |
| `skills/purview-results-union-errors` | `Purview.Results.SourceGenerator` | Union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper necessary |
| `skills/purview-results-http-mapping` | `Purview.Results.AspNetCore` | Result-to-HTTP mapping, the failure resolution order, and unmapped-failure `500`s |
| `skills/purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | ZodSharp validation flowing through results |
| `skills/purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | Rendering validation-carrying failures as ProblemDetails |

The generator package also ships the `purview-results-union-author` agent and the
`migrate-error-returns-to-result-unions` prompt.

## When to load which skill

- Modelling or migrating to result error unions → `purview-results-union-errors`
- The result type, its states or its combinators → `purview-results-core`
- Result-to-HTTP mapping or unmapped-failure `500`s → `purview-results-http-mapping`
- ZodSharp validation flowing through results → `purview-results-zodsharp-validation`, then
`purview-results-zodsharp-problems`

## Upstream skills

`Purview.BuildSdk` and the source-generator framework packages also deliver their own skills (project placement,
SDK configuration, generator authoring and testing, TUnit authoring) into the same `.agents/` tree. Those are
owned by the package that ships them — changes belong in the owning repository, not here.

## Related

- [Contributing](Contributing.md) — where the authored content lives in this repository.
- [Source Generator](Source-Generator.md) — packaging and distribution of the component.
148 changes: 148 additions & 0 deletions docs/wiki/AspNetCore-Integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# ASP.NET Core Integration

`Purview.Results.AspNetCore` maps a `Result<TValue, TError>` onto an ASP.NET Core response, so an endpoint can
return a result and let the host decide what each error case looks like on the wire.

## Installation

```bash
dotnet add package Purview.Results.AspNetCore
```

## Quick start

```csharp
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(error => TypedResults.NotFound())
.Map<TenantDisabled>(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden))
.Map<TenantError>(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) // every unmapped case
.AddFallback((error, context) => error is null ? null : TypedResults.Problem())
);

var app = builder.Build();

app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp();
```

`AddResultsHttp` also registers problem-details services (`AddProblemDetails()`), so the unmapped-failure and
uninitialized-result paths work without further host setup. It uses `TryAddSingleton` for `IResultsHttpMapper` and
`ResultsEndpointFilter`, so a host can register its own mapper first and replace the defaults.

`WithResultsHttp()` exists on both `RouteHandlerBuilder` and `RouteGroupBuilder`. The endpoint filter is
non-invasive: a handler that returns something other than an `IResultValue` is left untouched.

## How a result becomes a response

**Success** — the value is serialized with `SuccessStatusCode` (`200 OK` by default). A result whose successful
value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set.

**Failure** — the error is resolved to its *case* value (the active case of a union error, or the error itself for
a non-union error), then mapped in this order:

1. a mapping registered for the **case** type — `Map<TenantNotFound>(...)`
2. a mapping registered for the **error** type — `Map<TenantError>(...)`, which handles every case without its own
mapping
3. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper<TMapper>()`
mappers share one ordered list; a fallback or mapper returns `null` to defer to the next entry
4. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` extension
naming the unmapped case — or an `InvalidOperationException` when `ThrowOnUnmappedFailure` is set

An **uninitialized** result (`default`) takes the same path and is logged, because an endpoint returning `default`
is a host bug rather than a domain outcome.

## Options

| Option | Default | Purpose |
| --- | --- | --- |
| `SuccessStatusCode` | `200` | Status code for a serialized successful value |
| `SuccessMapper` | `null` | Replaces the default success handling entirely |
| `UnmappedStatusCode` | `500` | Status code for a failure with no mapping |
| `UnmappedTitle` | *"The operation failed with an error that is not mapped to an HTTP response."* | Title of the unmapped `ProblemDetails` |
| `ThrowOnUnmappedFailure` | `false` | Throw instead of producing a problem response; useful during development |
| `IncludeTraceId` | `true` | Whether problem responses this package writes itself carry the request trace identifier |
| `Map<TCase>(Func<TCase, IResult>)` | — | Maps a case (or the error itself) to a response |
| `Map<TCase>(Func<TCase, HttpContext, IResult>)` | — | Same, with access to the request |
| `AddFallback(Func<object?, HttpContext, IResult?>)` | — | Consulted in order for unmapped failures, with the case value |
| `AddFailureMapper<TMapper>()` | — | Same stage, for a mapper class resolved from dependency injection |

Registering the same type twice replaces the earlier mapping.

## Choosing an extension point

| The rule needs… | Use |
| --- | --- |
| One answer per error or case **type** | `Map<TCase>(...)` |
| The **value** the failure carries — a validation code, a category, a field | `IResultsFailureMapper` via `AddFailureMapper<TMapper>()` |
| A quick inline rule, with no dependencies | `AddFallback((error, context) => ...)` |
| To replace the whole pipeline | Your own `IResultsHttpMapper` |

A failure mapper is a **shape** rule, not a catch-all:

```csharp
public sealed class BlankIdentifierMapper : IResultsFailureMapper
{
public IResult? Map(ResultsFailureContext context) =>
context.Case is ITenantFailure { TenantId.Value: var id } && string.IsNullOrWhiteSpace(id)
? TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "An identifier is required.")
: null; // defer: the case mappings and the other fallbacks still apply
}

builder.Services.AddSingleton<BlankIdentifierMapper>();
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(_ => TypedResults.NotFound())
.AddFailureMapper<BlankIdentifierMapper>());
```

`ResultsFailureContext` carries the failure's **case** (the active case of a union error, or the error itself),
the **error** the result carries, and the request. A mapper is resolved from the failing request's services the
first time it is needed, so it may take its own dependencies in its constructor; register it before the first
request. A failure that reaches an unregistered mapper throws an `InvalidOperationException` naming the
registration that is missing.

Answering every failure in a mapper — with a generic problem or a `202`, for example — turns a mapping gap in the
host into a plausible-looking response, which is exactly what the unmapped-failure path exists to expose. Map the
shapes you can name and return `null` for the rest.

## Converting a result by hand

```csharp
app.MapGet("/tenants/{id:int}", (int id, HttpContext context) =>
GetTenant(id).ToHttpResult(context));
```

`ToHttpResult(HttpContext)` resolves `IResultsHttpMapper` from the request services; the
`ToHttpResult(IResultsHttpMapper, HttpContext)` overload takes one directly.

## Extensibility

`IResultsHttpMapper` is registered with `AddResultsHttp` as `DefaultResultsHttpMapper` via `TryAddSingleton`, so a
host can register its own implementation first to replace the defaults entirely. A host that replaces it also
bypasses `ResultsHttpOptions` — including the failure mappers — so prefer `Map`, `AddFallback` and
`AddFailureMapper` unless the pipeline itself has to change.

## Example responses

Running `Examples.AspNetCore`:

| Request | Response |
| --- | --- |
| `GET /tenants/acme` | `200 OK` with the tenant |
| `GET /tenants/initech` | `404 Not Found` — the mapping for the `TenantNotFound` case |
| `GET /tenants/globex/usage` | `403 Forbidden` — the mapping for the `TenantDisabled` case |
| `POST /tenants/acme` | `409 Conflict` — the mapping for the `TenantError` error type |
| `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug |

```bash
dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215
```

## Related

- [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) — validation-carrying failures rendered as
`HttpValidationProblemDetails`. This package deliberately knows nothing about ZodSharp.
- [Getting Started](Getting-Started.md) — the end-to-end walkthrough.
92 changes: 92 additions & 0 deletions docs/wiki/Combinators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Combinators

The combinators fold a result into a value or chain another operation, without ever inspecting a state that is
not there. All of them throw `InvalidOperationException` (`"The result is uninitialized."`) for `default`; the
throwing input check for a `null` delegate is `ArgumentNullException`.

## Transforming

| Member | On success | On failure | On `default` |
| --- | --- | --- | --- |
| `Match(success, failure)` | `success(value)` | `failure(error)` | throws |
| `Map(map)` | `Result<TResult, TError>.Success(map(value))` | the existing error, unchanged | throws |
| `Bind(bind)` | the result `bind` returns | the existing error, unchanged | throws |
| `MapError(map)` | the value, unchanged | `Result<TValue, TNewError>.Failure(map(error))` | throws |

```csharp
Result<TenantName, TenantError> name = GetTenant(tenantId).Map(tenant => tenant.Name);

Result<Tenant, TenantError> created = GetTenant(tenantId)
.Bind(tenant => store.CreateTenant(new TenantId("newco"), tenant.Name));

Result<Tenant, string> renamed = GetTenant(tenantId)
.MapError(error => error.ToString());
```

`Map` and `Bind` preserve the error type; `MapError` preserves the value. `Bind` is the operation to reach for
when the next step can fail with the **same** error type — otherwise `MapError` first or a `Match` is clearer.

## Constraining a success

`Ensure(predicate, errorFactory)` turns a successful value that fails the predicate into a failure:

```csharp
Result<Tenant, TenantError> enabled = GetTenant(tenantId)
.Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id));
```

The `errorFactory` is invoked only when the predicate fails, so a satisfied constraint allocates no error. A
failure and its error pass through unchanged.

## Probing

`TryGetValue` and `TryGetError` never throw — not even for `default`:

```csharp
if (result.TryGetValue(out Tenant? tenant))
Console.WriteLine(tenant.Name);

if (result.TryGetError(out TenantError error))
Console.WriteLine(error);
```

## Fallbacks and alternatives

| Member | Purpose |
| --- | --- |
| `GetValueOrDefault()` | The successful value, or `default` |
| `GetValueOrDefault(TValue fallback)` | The successful value, or `fallback` |
| `GetValueOrDefault(Func<TValue> fallbackFactory)` | The successful value, or a lazily produced fallback |
| `GetErrorOrDefault(TError fallback)` | The error, or `fallback` |
| `OrElse(Result<TValue, TError> fallback)` | This result when successful, otherwise `fallback` |

Each of these still throws for `default`, because an uninitialized result is a bug rather than an ordinary
failure. Use them after a state has been established (or after probing).

## Side effects

| Member | Purpose |
| --- | --- |
| `Switch(Action<TValue> success, Action<TError> failure)` | Invoke the matching action and discard the value |
| `Tap(Action<TValue> success)` | Observe a success and return the result unchanged |
| `TapError(Action<TError> failure)` | Observe a failure and return the result unchanged |

`Tap` and `TapError` leave the result untouched, which keeps them usable in the middle of a chain.

## Asynchronous composition

```csharp
Task<Result<TenantDto, TenantError>> dto = GetTenantAsync(tenantId)
.MapAsync(tenant => _mapper.MapAsync(tenant));

Task<Result<Tenant, TenantError>> saved = GetTenantAsync(tenantId)
.BindAsync(tenant => _store.SaveAsync(tenant));
```

`MapAsync` and `BindAsync` behave exactly like their synchronous counterparts, and both throw for `default`.
They are extension methods in `ResultExtensions`, not members on the result type.

## Related

- [Core Concepts](Core-Concepts.md) — the state contract these operations obey.
- [Union Errors](Union-Errors.md) — producing the failure value a combinator carries.
Loading
Loading