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: 8 additions & 1 deletion .csharpierignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# C# 15 union declarations are a preview language feature that is not yet parseable by CSharpier, so files

# that declare unions are excluded rather than reported as formatting failures. CSharpier reports
# "was not formatted due to syntax errors" for these files; every other file is formatted normally.

# "was not formatted due to syntax errors" for these files; every other file is formatted normally

src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs
src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs
src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs
src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs
src/src/Examples.Basic/TenantError.cs
src/src/Examples.Zod/TenantError.cs
src/src/Examples.AspNetCore/TenantError.cs
src/src/Examples.AspNetCore.Zod/TenantError.cs
44 changes: 42 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ makes C# 15 union error cases ergonomic, and the ZodSharp and ASP.NET Core integ
| `src/src/ZodSharp.AspNetCore` | Validation-problem mapping for validation-carrying failures |
| `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 |
| `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 @@ -77,6 +78,13 @@ including the diagnostics table, build properties and activation rules.
reports the compilation-wide rules (`RSG1005`, `RSG1006`). Never report the same rule from both hosts, and
keep `DiagnosticLibrary.IsBlocking` as the single blocking policy.
- New or changed rules require an `AnalyzerReleases.Unshipped.md` entry (the compiler's RS2008 rule catalogue).
- **One suppressor.** `Suppressors/UnionEqualityDiagnosticSuppressor.cs` is the only programmatic suppression in
this repository: it suppresses `CA1815` and only `CA1815`, and only on a declaration that is both a union
(`ITypeSymbol.IsUnion`) and opted in with `[GenerateResult]`. Keep it that narrow — a union without the
attribute, a union's case types and every other value type must keep the warning — and never let it report
diagnostics, because a suppressor is not a rule host. Suppression descriptors are not release-tracked, so
`RSG2000` (the suppression id a consumer adds to `NoWarn` to turn the suppression off) must not appear in
`AnalyzerReleases.Unshipped.md`, which lists reported rules only.
- **Keep the pipeline value-equatable.** Do not let `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`,
`SyntaxNode`, `SyntaxTree` or `Location` reach cached models; convert them during discovery
(`ResultUnionModel`, `ResultUnionCaseModel`, `ResultSourceLocation`) and use `EquatableArray<T>`.
Expand Down Expand Up @@ -114,6 +122,14 @@ including the diagnostics table, build properties and activation rules.
- Keep the failure resolution order in `DefaultResultsHttpMapper`: the mapping for the error **case** type,
then the mapping for the **error** type (which covers every case without its own mapping), then the
registered fallbacks in order, then the unmapped-failure response.
- **One fallback stage, one ordered list.** `ResultsHttpOptions.AddFallback(...)` delegates and
`AddFailureMapper<TMapper>()` failure mappers append to the *same* list, in the order they are called, and an
entry defers by returning `null`. `IResultsFailureMapper` (with `ResultsFailureContext`, which carries the case,
the error and the request) is the only shape-based hook: keep it the way in for rules keyed by a *value* rather
than a type, keep it out of the success path, and never let it become a catch-all that answers every failure —
that hides exactly the mapping gaps the unmapped-failure response exists to expose. A mapper is resolved from
the request's services on first use, so an unregistered mapper must fail with an `InvalidOperationException`
naming the registration that is missing.
- An unmapped failure is a host mapping gap, not a domain outcome: respond with `UnmappedStatusCode` (`500`)
and a `ProblemDetails` carrying the `errorType` extension, and log an error. `ThrowOnUnmappedFailure` exists
for development and must keep throwing `InvalidOperationException`.
Expand All @@ -123,10 +139,16 @@ including the diagnostics table, build properties and activation rules.
`ResultsEndpointFilter`, so a host can register its own mapper first and replace the defaults.
- `WithResultsHttp()` exists on both `RouteHandlerBuilder` and `RouteGroupBuilder`; keep the endpoint filter
non-invasive, so a handler that returns something other than an `IResultValue` is left untouched.
- `Purview.Results.ZodSharp.AspNetCore` registers its mapping as a `ResultsHttpOptions.AddFallback`, so any
case- or error-type mapping the host registered for a specific error always wins. It must reuse the ZodSharp
- `Purview.Results.ZodSharp.AspNetCore` registers its mapping as a `ResultsHttpOptions` failure mapper
(`ZodResultsFailureMapper`), so any case- or error-type mapping the host registered for a specific error always
wins, and so does a host failure mapper registered before `AddResultsZodSharpHttp`. It must reuse the ZodSharp
problem mapper (`ZodValidationProblems.ToProblem`) rather than reimplementing error-to-problem mapping, so a
result-carried validation failure and a thrown `ZodException` produce identical responses.
- Keep the ZodSharp code/category rules' precedence structural: **code** rules are consulted before **category**
rules (each in registration order), then the default validation problem, so a rule can only narrow what the host
already gets. A rule matches when *any* of the failure's errors carries its code or category — a rule a schema
can silently never reach is exactly the kind of gap this repository surfaces rather than hides — and a factory
returns `null` to decline, with matching continuing. Factories see the failure's whole error set.

## Packaging rules

Expand Down Expand Up @@ -193,6 +215,24 @@ including the diagnostics table, build properties and activation rules.
the fix through an `AdhocWorkspace`, and recompiles the rewrite so a broken fix cannot pass. A `Document` is an
immutable snapshot, so re-resolve it from the workspace's current solution after adding documents.

## Examples

`src/examples` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs
document: `Examples.Basic` (the result type and the generated helpers), `Examples.Zod`
(`Purview.Results.ZodSharp`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`) and `Examples.AspNetCore.Zod`
(`Purview.Results.ZodSharp.AspNetCore`).

- Examples are **documentation that compiles**: keep them non-packable (never declare
`<IsPackable>true</IsPackable>`), keep them out of test discovery (no `*Tests` suffix, and they are not under
`src/tests`), and keep each one's references equal to exactly the aspect it demonstrates.
- Each example declares its own local union rather than sharing a domain project, matching how the test
projects declare `HttpTestError`/`ValidationTestError`. Anything that declares a union must be added to
`.csharpierignore`.
- The generator is referenced analyzer-style (`OutputItemType="Analyzer"`, `ReferenceOutputAssembly="false"`,
`PrivateAssets="all"`), exactly as the test projects reference it.
- When public behaviour changes, update the example that demonstrates it, the example's snippet in the root
`README.md`, and the matching `Sdk/README.md` pointer in the same commit.

## Documentation rules

- Every public member carries XML documentation. Packable projects generate documentation files, so treat
Expand Down
20 changes: 20 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ pack publish_folder=artifacts_folder *args:
echo " Current version is {{ BLUE }}{{ current_version }}{{ NORMAL }}"
dotnet pack {{ solution }} -c {{ build_configuration }} -o {{ publish_folder }} {{ args }}

# Run the Basic example (result states, combinators and the generated AsFailure helpers)
[group('Examples')]
example-basic *args:
dotnet run --project src/examples/Examples.Basic {{ args }}

# Run the ZodSharp example (a validation outcome flowing through the result pipeline)
[group('Examples')]
example-zod *args:
dotnet run --project src/examples/Examples.Zod {{ args }}

# Run the ASP.NET Core example (result-to-response mapping), listening on http://localhost:5215
[group('Examples')]
example-aspnetcore *args:
dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 {{ args }}

# Run the ASP.NET Core + ZodSharp example (validation problems from result failures), listening on http://localhost:5216
[group('Examples')]
example-aspnetcore-zod *args:
dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 {{ args }}

# Open the solution in Visual Studio/ Registered application
[group('Utilities')]
vs:
Expand Down
149 changes: 146 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Purview Results

[![NuGet version](https://img.shields.io/nuget/v/Purview.Results.svg)](https://www.nuget.org/packages/Purview.Results)
[![Release](https://github.com/purview-dev/results/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/results/actions/workflows/release.yml)

Purview result types for .NET — a small, dependency-light `Result<TValue, TError>` type, C# 15 union ergonomics
Expand All @@ -9,7 +10,7 @@ Exceptional circumstances still throw; expected outcomes are values.
## Packages

| Package | Purpose | Targets |
|---|---|---|
| --- | --- | --- |
| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result<TValue, TError>` and the `Result` factories. No dependencies. | `net11.0` |
| [`Purview.Results.SourceGenerator`](src/src/SourceGenerator/Sdk/README.md) | Generates `AsFailure<TValue>()` helpers for `[GenerateResult]` unions. | `netstandard2.0` |
| [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult<T>` values into results. | `net11.0` |
Expand Down Expand Up @@ -47,7 +48,9 @@ public readonly record struct TenantAlreadyExists(TenantId TenantId);
The generator cannot make a bare case value convert implicitly — C# forbids operators in a static class,
conversion operators in extension members, and more than one user-defined conversion per sequence — so the
per-case helper is the ergonomics the language allows. The generator package also ships a code fix for the
IDE, and returning the union itself (`(TenantError)new TenantNotFound(id)`) is the only helper-free form; see
IDE and a diagnostic suppressor that answers `CA1815` for opted-in unions, so a `[GenerateResult]` union needs
no `#pragma warning disable CA1815`, and returning the union itself
(`(TenantError)new TenantNotFound(id)`) is the only helper-free form; see
the [generator package README](src/src/SourceGenerator/Sdk/README.md) for the compiler evidence.

Expose the result over HTTP with the ASP.NET Core package, which maps each error case to a response:
Expand All @@ -61,6 +64,146 @@ builder.Services.AddResultsHttp(options => options
app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp();
```

## Examples

Every example is a runnable, non-packable project under [`src/examples`](src/examples), built on the same
Tenancy domain the Quick start uses, so the one `TenantError` union drives all four integration aspects.

| Example | Packages | Demonstrates |
| --- | --- | --- |
| [`Examples.Basic`](src/examples/Examples.Basic) | `Purview.Results`, `Purview.Results.SourceGenerator` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, and the generated `AsFailure<TValue>()` helper |
| [`Examples.Zod`](src/examples/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s |
| [`Examples.AspNetCore`](src/examples/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, including the mapping-gap and uninitialized-result paths |
| [`Examples.AspNetCore.Zod`](src/examples/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback |

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

### Basic

`Result<Tenant, TenantError>` holds one of three states — `Uninitialized` (the `default` value), `Success` or
`Failure` — and every expected outcome is read from the value rather than caught:

```csharp
Result<Tenant, TenantError> GetTenant(TenantId tenantId) =>
_tenants.TryGetValue(tenantId, out var tenant)
? Result<Tenant, TenantError>.Success(tenant)
: new TenantNotFound(tenantId).AsFailure<Tenant>();

var loaded = GetTenant(tenantId);

loaded.Match(tenant => $"loaded '{tenant.Name}'", error => $"could not load the tenant: {error}");
loaded.Map(tenant => tenant.Name);
loaded.Bind(tenant => store.CreateTenant(new TenantId("newco"), tenant.Name));
loaded.Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id));
loaded.TryGetError(out var error); // probing never throws, even for `default`
loaded.Value; // throws InvalidOperationException unless the result is a success
```

### Zod

ZodSharp validation never throws: `Validate` returns a `ValidationResult<TenantInput>` carrying the validated
value on success and every `ValidationError` on failure. `ToResult` turns that into an ordinary result whose
error is one of the union's cases:

```csharp
[ZodSchema]
public sealed partial record TenantInput
{
[Required]
public string? TenantId { get; init; }

[Required]
public string? Name { get; init; }
}

Result<Tenant, TenantError> Register(TenantInput input) =>
TenantInputSchema
.Validate(input)
.ToResult<TenantInput, TenantError>(errors => new TenantInputInvalid(input, errors))
.Bind(RegisterValidated);
```

When the value the method succeeds with is not the validated value, the generated helper produces the failure
instead, because the validated value cannot be carried forward:

```csharp
var validation = TenantInputSchema.Validate(input);

if (!validation.IsSuccess)
return new TenantInputInvalid(input, validation.Errors).AsFailure<Tenant>();

return RegisterValidated(validation.Value);
```

### ASP.NET Core

The host decides what each error case looks like on the wire. A mapping registered for the **case** type wins;
the mapping registered for the **error** type covers every case without one:

```csharp
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(error => TypedResults.NotFound(new { error = nameof(TenantNotFound), tenantId = error.TenantId.Value }))
.Map<TenantDisabled>(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: "The tenant is disabled."))
.Map<TenantError>(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "The tenant already exists."))
);

app.MapGet("/tenants/{id}", (string id) => store.GetTenant(new TenantId(id))).WithResultsHttp();
```

Running `Examples.AspNetCore` answers as follows:

| 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 |

**Case** and **error** mappings are keyed by type. When the answer depends on the *value* a failure carries — a
validation code, a category, a field — add an `IResultsFailureMapper` instead, in the same ordered list:

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

### ASP.NET Core + Zod

Register the validation mapping last, so any mapping or failure mapper the host declared earlier always wins, and
reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping. Per-code and per-category
rules answer particular validation failures with a response of their own:

```csharp
builder.Services.AddZodSharpProblemDetails();
builder.Services.AddResultsHttp(options => options
.Map<TenantAlreadyExists>(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict))
);
builder.Services.AddResultsZodSharpHttp(options => options
.MapCode("tenant_id_matches_name", StatusCodes.Status422UnprocessableEntity)
);
```

A `TenantInputInvalid` failure implements `IValidationErrorCarrier`, so it becomes a validation problem without
the host mapping it — unless a rule answers one of its codes or categories:

```json
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "TenantId": ["Required field 'TenantId' is null"] },
"issues": [{ "code": "missing_field", "path": ["TenantId"], "message": "Required field 'TenantId' is null" }],
"traceId": "0HNOUQVQNF7CV:00000001"
}
```

## Requirements

- **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets
Expand All @@ -80,6 +223,7 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp();
| `src/src/ZodSharp.AspNetCore` | `HttpValidationProblemDetails` mapping for validation-carrying failures |
| `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 |
| `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 @@ -118,4 +262,3 @@ to `main` runs the shared `Purview.Build` release pipeline, which packs, publish
## License

MIT — see [LICENSE.md](LICENSE.md).

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
"license": "MIT",
"private": true,
"keywords": [],
"homepage": "https://github.com/purview-dev/results#readme",
"homepage": "https://purview.dev/projects/results/",
"bugs": {
"url": "https://github.com/purview-dev/results/issues"
},
"author": "Kieron Lanning",
"author": {
"name": "Kieron Lanning",
"url": "https://kieronlanning.dev/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/purview-dev/results.git"
Expand Down
Loading
Loading