From 733dbe05fd6154da66688ef7394e16fab9733c58 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 10:34:09 +0100 Subject: [PATCH 1/5] docs: added docs ready for deployment --- package.json | 7 +++++-- src/Directory.Build.props | 13 ++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8379825..4e06052 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4d2a24d..2c6086b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -25,14 +25,21 @@ $(CurrentYear). Each packable project opts in with true in its own project file, because the SDK reads that declaration from the project file text. - PackageProjectUrl points at the repository: purview.dev does not publish a `results` project - page yet, so a purview.dev link would be a dead end. + PurviewProjectUrl/PurviewDocsUrl are the site's project and documentation pages for this + repository, matching the other purview-dev repos; PackageProjectUrl points at the project + page rather than the repository. --> + + https://purview.dev/ + $(PurviewHomepage)projects/results/ + $(PurviewHomepage)docs/results/ + + Kieron Lanning Purview-Dev Copyright © $(CurrentYear) Purview-Dev - $(RepositoryUrl) + $(PurviewProjectUrl) git true purview-logo-light.png From de70c541d62f67d27fce1d7d5e4e2361db8bbda7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 10:57:18 +0100 Subject: [PATCH 2/5] feat: added samples --- .csharpierignore | 4 + AGENTS.md | 19 +++ Justfile | 20 +++ README.md | 128 ++++++++++++++++++ src/Results.slnx | 10 ++ .../Examples.AspNetCore.Zod.csproj | 52 +++++++ .../Examples.AspNetCore.Zod/Program.cs | 51 +++++++ .../Examples.AspNetCore.Zod/Tenant.cs | 15 ++ .../Examples.AspNetCore.Zod/TenantError.cs | 38 ++++++ .../Examples.AspNetCore.Zod/TenantInput.cs | 44 ++++++ .../TenantRegistrationService.cs | 39 ++++++ .../Examples.AspNetCore.csproj | 33 +++++ src/examples/Examples.AspNetCore/Program.cs | 49 +++++++ src/examples/Examples.AspNetCore/Tenant.cs | 15 ++ .../Examples.AspNetCore/TenantError.cs | 34 +++++ .../Examples.AspNetCore/TenantStore.cs | 54 ++++++++ .../Examples.Basic/Examples.Basic.csproj | 28 ++++ src/examples/Examples.Basic/Program.cs | 113 ++++++++++++++++ src/examples/Examples.Basic/Tenant.cs | 15 ++ src/examples/Examples.Basic/TenantError.cs | 35 +++++ src/examples/Examples.Basic/TenantStore.cs | 44 ++++++ src/examples/Examples.Zod/Examples.Zod.csproj | 41 ++++++ src/examples/Examples.Zod/Program.cs | 97 +++++++++++++ src/examples/Examples.Zod/Tenant.cs | 15 ++ src/examples/Examples.Zod/TenantError.cs | 54 ++++++++ src/examples/Examples.Zod/TenantInput.cs | 50 +++++++ .../Examples.Zod/TenantRegistrationService.cs | 82 +++++++++++ src/src/AspNetCore/Sdk/README.md | 14 ++ src/src/Results/Sdk/README.md | 13 ++ src/src/SourceGenerator/Sdk/README.md | 13 ++ src/src/ZodSharp.AspNetCore/Sdk/README.md | 13 ++ src/src/ZodSharp/Sdk/README.md | 13 ++ 32 files changed, 1245 insertions(+) create mode 100644 src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj create mode 100644 src/examples/Examples.AspNetCore.Zod/Program.cs create mode 100644 src/examples/Examples.AspNetCore.Zod/Tenant.cs create mode 100644 src/examples/Examples.AspNetCore.Zod/TenantError.cs create mode 100644 src/examples/Examples.AspNetCore.Zod/TenantInput.cs create mode 100644 src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs create mode 100644 src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj create mode 100644 src/examples/Examples.AspNetCore/Program.cs create mode 100644 src/examples/Examples.AspNetCore/Tenant.cs create mode 100644 src/examples/Examples.AspNetCore/TenantError.cs create mode 100644 src/examples/Examples.AspNetCore/TenantStore.cs create mode 100644 src/examples/Examples.Basic/Examples.Basic.csproj create mode 100644 src/examples/Examples.Basic/Program.cs create mode 100644 src/examples/Examples.Basic/Tenant.cs create mode 100644 src/examples/Examples.Basic/TenantError.cs create mode 100644 src/examples/Examples.Basic/TenantStore.cs create mode 100644 src/examples/Examples.Zod/Examples.Zod.csproj create mode 100644 src/examples/Examples.Zod/Program.cs create mode 100644 src/examples/Examples.Zod/Tenant.cs create mode 100644 src/examples/Examples.Zod/TenantError.cs create mode 100644 src/examples/Examples.Zod/TenantInput.cs create mode 100644 src/examples/Examples.Zod/TenantRegistrationService.cs diff --git a/.csharpierignore b/.csharpierignore index d67d360..db9f79f 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -5,3 +5,7 @@ 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/examples/Examples.Basic/TenantError.cs +src/examples/Examples.Zod/TenantError.cs +src/examples/Examples.AspNetCore/TenantError.cs +src/examples/Examples.AspNetCore.Zod/TenantError.cs diff --git a/AGENTS.md b/AGENTS.md index d4552c8..e43870b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//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 | @@ -193,6 +194,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 + `true`), 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 diff --git a/Justfile b/Justfile index 8b2b6b8..2cd891f 100644 --- a/Justfile +++ b/Justfile @@ -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: diff --git a/README.md b/README.md index e9e6627..aba676e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,133 @@ 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()` 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` 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 GetTenant(TenantId tenantId) => + _tenants.TryGetValue(tenantId, out var tenant) + ? Result.Success(tenant) + : new TenantNotFound(tenantId).AsFailure(); + +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` 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 Register(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToResult(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(); + +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(error => TypedResults.NotFound(new { error = nameof(TenantNotFound), tenantId = error.TenantId.Value })) + .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: "The tenant is disabled.")) + .Map(_ => 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 | + +### ASP.NET Core + Zod + +Register the validation fallback last, so any mapping the host declared for a specific case or error always +wins, and reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping: + +```csharp +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(options => options + .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) +); +builder.Services.AddResultsZodSharpHttp(); +``` + +A `TenantInputInvalid` failure implements `IValidationErrorCarrier`, so it becomes a validation problem without +the host mapping it: + +```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 @@ -80,6 +207,7 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); | `src/src/ZodSharp.AspNetCore` | `HttpValidationProblemDetails` mapping for validation-carrying failures | | `src/src//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 | diff --git a/src/Results.slnx b/src/Results.slnx index d6e71dc..a4a7dd3 100644 --- a/src/Results.slnx +++ b/src/Results.slnx @@ -18,6 +18,16 @@ + + + + + + + diff --git a/src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj b/src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj new file mode 100644 index 0000000..d2f62dd --- /dev/null +++ b/src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj @@ -0,0 +1,52 @@ + + + + Exe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/examples/Examples.AspNetCore.Zod/Program.cs b/src/examples/Examples.AspNetCore.Zod/Program.cs new file mode 100644 index 0000000..bb6ceaf --- /dev/null +++ b/src/examples/Examples.AspNetCore.Zod/Program.cs @@ -0,0 +1,51 @@ +// Demonstrates Purview.Results.ZodSharp.AspNetCore: a result failure that carries ZodSharp validation errors is +// rendered as an ASP.NET Core HttpValidationProblemDetails response, produced by the same mapper the ZodSharp +// exception handler uses, so a validation failure carried by a result and the same failure thrown as a +// ZodException produce identical responses. +// +// dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +// +// curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"tenantId\":\"newco\",\"name\":\"Newco\"}" -> 200 OK +// curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"name\":\"Nameless\"}" -> 400 validation problem +// curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"tenantId\":\"acme\",\"name\":\"Acme\"}" -> 409 Conflict +// +// The 409 shows the precedence: the mapping registered for a specific case always wins over the validation +// fallback, and a mapping registered for the error type would still win over the fallback too. + +var builder = WebApplication.CreateBuilder(args); + +// The problem mapper, exception handler and options the ZodSharp packages share. +builder.Services.AddZodSharpProblemDetails(); + +builder.Services.AddResultsHttp(options => + options.Map(error => + TypedResults.Problem( + statusCode: StatusCodes.Status409Conflict, + title: "The tenant already exists.", + detail: $"Tenant '{error.TenantId.Value}' already exists." + ) + ) +); + +// Registered as a fallback, so the case mapping above still wins and a failure that carries no validation +// errors still takes the host's unmapped-failure path. +builder.Services.AddResultsZodSharpHttp(); + +var app = builder.Build(); + +TenantRegistrationService service = new(); + +app.MapPost( + "/tenants", + (TenantRegistrationRequest request) => service.Register(TenantInput.Create(request.TenantId, request.Name)) + ) + .WithResultsHttp(); + +app.Run(); + +/// +/// The JSON body the tenant registration endpoint accepts. +/// +/// The identifier of the new tenant. +/// The display name of the new tenant. +sealed record TenantRegistrationRequest(string? TenantId, string? Name); diff --git a/src/examples/Examples.AspNetCore.Zod/Tenant.cs b/src/examples/Examples.AspNetCore.Zod/Tenant.cs new file mode 100644 index 0000000..9b45aa3 --- /dev/null +++ b/src/examples/Examples.AspNetCore.Zod/Tenant.cs @@ -0,0 +1,15 @@ +namespace Purview.Results.Examples.AspNetCore.Zod; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +public readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.AspNetCore.Zod/TenantError.cs b/src/examples/Examples.AspNetCore.Zod/TenantError.cs new file mode 100644 index 0000000..2effa78 --- /dev/null +++ b/src/examples/Examples.AspNetCore.Zod/TenantError.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; + +using Purview.Results.SourceGeneration; +using Purview.Results.ZodSharp; + +using ZodSharp.Core; + +namespace Purview.Results.Examples.AspNetCore.Zod; + +// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. +#pragma warning disable CA1815 +/// +/// The expected failures of the tenant registration operation. +/// +/// +/// Only the case that carries validation errors implements , so only that +/// case is rendered as a validation problem by the fallback; a case with its own mapping still wins. +/// +[GenerateResult] +public readonly union TenantError(TenantAlreadyExists, TenantInputInvalid); +#pragma warning restore CA1815 + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +public readonly record struct TenantAlreadyExists(TenantId TenantId); + +/// +/// The supplied input did not satisfy its schema. +/// +/// The rejected input. +/// The validation errors reported by TenantInputSchema. +public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) + : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} diff --git a/src/examples/Examples.AspNetCore.Zod/TenantInput.cs b/src/examples/Examples.AspNetCore.Zod/TenantInput.cs new file mode 100644 index 0000000..ef21367 --- /dev/null +++ b/src/examples/Examples.AspNetCore.Zod/TenantInput.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Schemas; + +namespace Purview.Results.Examples.AspNetCore.Zod; + +/// +/// The raw input a caller registers a tenant with. +/// +/// +/// [ZodSchema] makes ZodSharp's generator emit TenantInputSchema, whose Validate method +/// returns a ValidationResult<TenantInput> instead of throwing. +/// +[ZodSchema] +public sealed partial record TenantInput +{ + /// The identifier of the new tenant. + [Required] + public string? TenantId { get; init; } + + /// The display name of the new tenant. + [Required] + public string? Name { get; init; } + + /// + /// Creates an input. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The input. + public static TenantInput Create(string? tenantId, string? name) => new() { TenantId = tenantId, Name = name }; + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.TenantId is not null && context.Value.TenantId == context.Value.Name) + { + context.AddIssue( + "tenant_id_matches_name", + "The tenant id must differ from the display name.", + [nameof(TenantId)] + ); + } + } +} diff --git a/src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs b/src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs new file mode 100644 index 0000000..5b38bff --- /dev/null +++ b/src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs @@ -0,0 +1,39 @@ +using Purview.Results.ZodSharp; + +namespace Purview.Results.Examples.AspNetCore.Zod; + +/// +/// Registers tenants, validating every input with ZodSharp before it is used. +/// +public sealed class TenantRegistrationService +{ + readonly Dictionary _tenants = new() + { + [new TenantId("acme")] = new Tenant(new TenantId("acme"), "Acme", Enabled: true), + }; + + /// + /// Registers a tenant from raw input, validating it first. + /// + /// The input to validate and register. + /// The registered tenant, or a describing why it was rejected. + public Result Register(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(input, errors)) + .Bind(RegisterValidated); + + Result RegisterValidated(TenantInput input) + { + // Validation guarantees both members are present, so the non-null assertion is safe here. + TenantId tenantId = new(input.TenantId!); + + if (_tenants.ContainsKey(tenantId)) + return new TenantAlreadyExists(tenantId).AsFailure(); + + Tenant tenant = new(tenantId, input.Name!, Enabled: true); + _tenants[tenantId] = tenant; + + return Result.Success(tenant); + } +} diff --git a/src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj b/src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj new file mode 100644 index 0000000..ce64802 --- /dev/null +++ b/src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj @@ -0,0 +1,33 @@ + + + + Exe + + + + + + + + + + + + + + + + + diff --git a/src/examples/Examples.AspNetCore/Program.cs b/src/examples/Examples.AspNetCore/Program.cs new file mode 100644 index 0000000..c90df12 --- /dev/null +++ b/src/examples/Examples.AspNetCore/Program.cs @@ -0,0 +1,49 @@ +// Demonstrates Purview.Results.AspNetCore: an endpoint returns Result and the host decides +// what each error case looks like on the wire. +// +// dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +// +// curl -i http://localhost:5215/tenants/acme -> 200 OK +// curl -i http://localhost:5215/tenants/initech -> 404 Not Found (case mapping) +// curl -i http://localhost:5215/tenants/globex/usage -> 403 Forbidden (case mapping) +// curl -i -X POST http://localhost:5215/tenants/newco -> 200 OK +// curl -i -X POST http://localhost:5215/tenants/acme -> 409 Conflict (error mapping) +// curl -i http://localhost:5215/tenants/broken -> 500 Internal Server Error +// +// The last one is deliberate: an endpoint returning `default` is a host bug, so it takes the unmapped-failure +// path and is logged rather than being coerced into a success or a failure. + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddResultsHttp(options => + options + // A mapping for the case type wins over the mapping for the error type. + .Map(error => + TypedResults.NotFound(new { error = nameof(TenantNotFound), tenantId = error.TenantId.Value }) + ) + .Map(error => + TypedResults.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "The tenant is disabled.", + detail: $"Tenant '{error.TenantId.Value}' is disabled." + ) + ) + // The mapping for the error type covers every case that has no mapping of its own. + .Map(_ => + TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "The tenant already exists.") + ) +); + +var app = builder.Build(); + +InMemoryTenantStore store = new(); + +app.MapGet("/tenants/{id}", (string id) => store.GetTenant(new TenantId(id))).WithResultsHttp(); + +app.MapGet("/tenants/{id}/usage", (string id) => store.GetEnabledTenant(new TenantId(id))).WithResultsHttp(); + +app.MapPost("/tenants/{id}", (string id) => store.CreateTenant(new TenantId(id), id)).WithResultsHttp(); + +app.MapGet("/tenants/broken", () => default(Result)).WithResultsHttp(); + +app.Run(); diff --git a/src/examples/Examples.AspNetCore/Tenant.cs b/src/examples/Examples.AspNetCore/Tenant.cs new file mode 100644 index 0000000..364e561 --- /dev/null +++ b/src/examples/Examples.AspNetCore/Tenant.cs @@ -0,0 +1,15 @@ +namespace Purview.Results.Examples.AspNetCore; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +public readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.AspNetCore/TenantError.cs b/src/examples/Examples.AspNetCore/TenantError.cs new file mode 100644 index 0000000..732059c --- /dev/null +++ b/src/examples/Examples.AspNetCore/TenantError.cs @@ -0,0 +1,34 @@ +using Purview.Results.SourceGeneration; + +namespace Purview.Results.Examples.AspNetCore; + +// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. +#pragma warning disable CA1815 +/// +/// The expected failures of a tenant operation. +/// +/// +/// The mapping registered for a case type handles that case, and the mapping registered for the union handles +/// every case without a mapping of its own. +/// +[GenerateResult] +public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); +#pragma warning restore CA1815 + +/// +/// The requested tenant does not exist. +/// +/// The identifier of the tenant. +public readonly record struct TenantNotFound(TenantId TenantId); + +/// +/// The requested tenant exists but is disabled. +/// +/// The identifier of the tenant. +public readonly record struct TenantDisabled(TenantId TenantId); + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +public readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/examples/Examples.AspNetCore/TenantStore.cs b/src/examples/Examples.AspNetCore/TenantStore.cs new file mode 100644 index 0000000..0d1cf4d --- /dev/null +++ b/src/examples/Examples.AspNetCore/TenantStore.cs @@ -0,0 +1,54 @@ +namespace Purview.Results.Examples.AspNetCore; + +/// +/// An in-memory tenant store, standing in for a database. +/// +/// +/// Every operation returns Result<Tenant, TenantError>: an expected failure such as a missing +/// tenant is a value, and only exceptional circumstances would throw. +/// +public sealed class InMemoryTenantStore +{ + readonly Dictionary _tenants = new() + { + [new TenantId("acme")] = new Tenant(new TenantId("acme"), "Acme", Enabled: true), + [new TenantId("globex")] = new Tenant(new TenantId("globex"), "Globex", Enabled: false), + }; + + /// + /// Gets the tenant with the supplied identifier. + /// + /// The identifier of the tenant. + /// The tenant, or a failure. + public Result GetTenant(TenantId tenantId) => + _tenants.TryGetValue(tenantId, out var tenant) + ? Result.Success(tenant) + : new TenantNotFound(tenantId).AsFailure(); + + /// + /// Gets the tenant with the supplied identifier, rejecting one that is disabled. + /// + /// The identifier of the tenant. + /// + /// The tenant, a failure, or a failure. + /// + public Result GetEnabledTenant(TenantId tenantId) => + GetTenant(tenantId).Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id)); + + /// + /// Creates a tenant. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The created tenant, or a failure. + public Result CreateTenant(TenantId tenantId, string name) + { + if (_tenants.ContainsKey(tenantId)) + return new TenantAlreadyExists(tenantId).AsFailure(); + + Tenant tenant = new(tenantId, name, Enabled: true); + _tenants[tenantId] = tenant; + + return Result.Success(tenant); + } +} diff --git a/src/examples/Examples.Basic/Examples.Basic.csproj b/src/examples/Examples.Basic/Examples.Basic.csproj new file mode 100644 index 0000000..0a4479d --- /dev/null +++ b/src/examples/Examples.Basic/Examples.Basic.csproj @@ -0,0 +1,28 @@ + + + + Exe + + + + + + + + + + + + diff --git a/src/examples/Examples.Basic/Program.cs b/src/examples/Examples.Basic/Program.cs new file mode 100644 index 0000000..3e5cfbe --- /dev/null +++ b/src/examples/Examples.Basic/Program.cs @@ -0,0 +1,113 @@ +// A guided tour of Purview.Results over the Tenancy domain the package README documents. +// +// dotnet run --project src/examples/Examples.Basic +// +// Every expected outcome below is a value. Nothing throws except the deliberate misuse at the end, which +// demonstrates the throw-on-misuse contract that keeps an uninitialized result a bug rather than a state. + +InMemoryTenantStore store = new(); +TenantId acmeId = new("acme"); +TenantId globexId = new("globex"); +TenantId missingId = new("initech"); + +Heading("1. Success and failure are values"); + +ShowResult("GetTenant(acme)", store.GetTenant(acmeId)); +ShowResult("GetTenant(initech)", store.GetTenant(missingId)); + +Heading("2. Match folds both states into one value"); + +foreach (var id in new[] { acmeId, globexId, missingId }) +{ + var description = store.GetTenant(id).Match(tenant => $"loaded '{tenant.Name}'", DescribeError); + + Console.WriteLine($" {id.Value, -8} -> {description}"); +} + +Heading("3. Map, Bind and MapError transform without unwrapping"); + +ShowResult("Map(name)", store.GetTenant(acmeId).Map(tenant => tenant.Name)); +ShowResult( + "Bind(create 'newco')", + store.GetTenant(acmeId).Bind(tenant => store.CreateTenant(new TenantId("newco"), tenant.Name)) +); + +// MapError widens the error type, so the mapped result is printed as-is rather than through ShowResult. +Show( + "MapError(to message)", + store.GetTenant(missingId).MapError(error => $"the tenant operation failed: {DescribeError(error)}") +); + +Heading("4. Ensure turns a success that breaks a rule into a failure"); + +ShowResult( + "Ensure(tenant is enabled)", + store.GetTenant(globexId).Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id)) +); + +Heading("5. Probing never throws, so even an uninitialized result can be inspected"); + +var uninitialized = default(Result); + +Show("default", uninitialized); +Show("IsInitialized", uninitialized.IsInitialized); +Show("TryGetValue", uninitialized.TryGetValue(out _)); +Show("TryGetError", uninitialized.TryGetError(out _)); + +Heading("6. Success and failure also come from factories and implicit conversions"); + +// The declared type is what lets the implicit conversion apply, so these two cannot use `var`. +Result fromValue = store.GetTenant(acmeId).Value; +Result fromError = (TenantError)new TenantAlreadyExists(acmeId); +var fromFactory = Result.Failure(new TenantDisabled(acmeId)); + +ShowResult("implicit value", fromValue); +ShowResult("implicit error", fromError); +ShowResult("error factory", fromFactory); + +Heading("7. The generated AsFailure() helper is what makes union cases ergonomic"); + +// `return new TenantNotFound(id);` does not compile (CS0029): the case converts to the union and the union +// converts to the result, but C# never composes two user-defined conversions. The generator emits one helper +// per case of every [GenerateResult] union, which is the ergonomics the language allows. +var helper = new TenantNotFound(missingId).AsFailure(); + +ShowResult("AsFailure()", helper); + +Heading("8. Misuse throws: an uninitialized result is a bug, not a state"); + +try +{ + Console.WriteLine($" uninitialized.Value -> {uninitialized.Value}"); +} +catch (InvalidOperationException exception) +{ + Console.WriteLine($" InvalidOperationException: {exception.Message}"); +} + +static void Heading(string title) +{ + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); +} + +static void Show(string label, object? value) => Console.WriteLine($" {label, -28} -> {value}"); + +static void ShowResult(string label, Result result) +{ + // Matching on the result gives a readable failure: a union's own ToString() is the union's type name, + // not the active case, so DescribeError switches on the case types instead. + var text = result.Match(value => $"Success({value})", DescribeError); + + Console.WriteLine($" {label, -28} -> {text}"); +} + +static string DescribeError(TenantError error) => + error switch + { + TenantNotFound notFound => $"TenantNotFound({notFound.TenantId.Value})", + TenantDisabled disabled => $"TenantDisabled({disabled.TenantId.Value})", + TenantAlreadyExists exists => $"TenantAlreadyExists({exists.TenantId.Value})", + _ => nameof(TenantError), + }; diff --git a/src/examples/Examples.Basic/Tenant.cs b/src/examples/Examples.Basic/Tenant.cs new file mode 100644 index 0000000..6c01865 --- /dev/null +++ b/src/examples/Examples.Basic/Tenant.cs @@ -0,0 +1,15 @@ +namespace Purview.Results.Examples.Basic; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +public readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.Basic/TenantError.cs b/src/examples/Examples.Basic/TenantError.cs new file mode 100644 index 0000000..6bacc56 --- /dev/null +++ b/src/examples/Examples.Basic/TenantError.cs @@ -0,0 +1,35 @@ +using Purview.Results.SourceGeneration; + +namespace Purview.Results.Examples.Basic; + +// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. +#pragma warning disable CA1815 +/// +/// The expected failures of a tenant operation. +/// +/// +/// The union itself is the error type of Result<Tenant, TenantError>. A bare case value cannot +/// convert to the result because C# never composes two user-defined conversions, so the generator emits one +/// AsFailure<TValue>() helper per case of every [GenerateResult] union. +/// +[GenerateResult] +public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); +#pragma warning restore CA1815 + +/// +/// The requested tenant does not exist. +/// +/// The identifier of the tenant. +public readonly record struct TenantNotFound(TenantId TenantId); + +/// +/// The requested tenant exists but is disabled. +/// +/// The identifier of the tenant. +public readonly record struct TenantDisabled(TenantId TenantId); + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +public readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/examples/Examples.Basic/TenantStore.cs b/src/examples/Examples.Basic/TenantStore.cs new file mode 100644 index 0000000..6a6a84f --- /dev/null +++ b/src/examples/Examples.Basic/TenantStore.cs @@ -0,0 +1,44 @@ +namespace Purview.Results.Examples.Basic; + +/// +/// An in-memory tenant store, standing in for a database. +/// +/// +/// Every operation returns Result<Tenant, TenantError>: an expected failure such as a missing +/// tenant is a value, and only exceptional circumstances would throw. +/// +public sealed class InMemoryTenantStore +{ + readonly Dictionary _tenants = new() + { + [new TenantId("acme")] = new Tenant(new TenantId("acme"), "Acme", Enabled: true), + [new TenantId("globex")] = new Tenant(new TenantId("globex"), "Globex", Enabled: false), + }; + + /// + /// Gets the tenant with the supplied identifier. + /// + /// The identifier of the tenant. + /// The tenant, or a failure. + public Result GetTenant(TenantId tenantId) => + _tenants.TryGetValue(tenantId, out var tenant) + ? Result.Success(tenant) + : new TenantNotFound(tenantId).AsFailure(); + + /// + /// Creates a tenant. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The created tenant, or a failure. + public Result CreateTenant(TenantId tenantId, string name) + { + if (_tenants.ContainsKey(tenantId)) + return new TenantAlreadyExists(tenantId).AsFailure(); + + Tenant tenant = new(tenantId, name, Enabled: true); + _tenants[tenantId] = tenant; + + return Result.Success(tenant); + } +} diff --git a/src/examples/Examples.Zod/Examples.Zod.csproj b/src/examples/Examples.Zod/Examples.Zod.csproj new file mode 100644 index 0000000..b5385d3 --- /dev/null +++ b/src/examples/Examples.Zod/Examples.Zod.csproj @@ -0,0 +1,41 @@ + + + + Exe + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/examples/Examples.Zod/Program.cs b/src/examples/Examples.Zod/Program.cs new file mode 100644 index 0000000..0445943 --- /dev/null +++ b/src/examples/Examples.Zod/Program.cs @@ -0,0 +1,97 @@ +// Demonstrates Purview.Results.ZodSharp: a ZodSharp validation outcome flows through the same result pipeline +// as every other expected outcome, as a value instead of an exception. +// +// dotnet run --project src/examples/Examples.Zod + +TenantRegistrationService service = new(); + +Heading("1. Valid input succeeds, carrying the value the method produces"); + +ShowResult("Register('acme-2', 'Acme Two')", service.Register("acme-2", "Acme Two")); +ShowResult("Find(acme)", service.Find(new TenantId("acme"))); + +Heading("2. Input rejected by its schema becomes a union case carrying every validation error"); + +var rejected = service.Register(null, "Nameless"); + +ShowResult("Register(null, 'Nameless')", rejected); +PrintErrors(rejected); + +Heading("3. The refinement hook reports a cross-member invariant as a stable code"); + +var conflicting = service.Register("same", "same"); + +ShowResult("Register('same', 'same')", conflicting); +PrintErrors(conflicting); + +Heading("4. A tenant that already exists fails through exactly the same pipeline"); + +ShowResult("Register('acme', 'Acme Copy')", service.Register("acme", "Acme Copy")); + +Heading("5. A failure short-circuits composition, so nothing downstream runs"); + +var composed = service + .Register(null, "Nameless") + .Map(tenant => + { + Console.WriteLine(" (Map ran: it must not have)"); + return tenant.Name; + }) + .Tap(name => Console.WriteLine(" (Tap ran: it must not have)")); + +var composedText = composed.Match(name => $"Success({name})", DescribeError); + +Show("Register(...).Map(name)", composedText); + +Heading("6. Probing the failure is how a caller inspects which case it holds"); + +if (rejected.TryGetError(out var error)) +{ + Show("TryGetError", DescribeError(error)); + + // A union error is switched on by case type; its own ToString() is only the union's type name. + if (error is TenantInputInvalid invalid) + { + Show("case is TenantInputInvalid", $"{invalid.Errors.Length} validation error(s)"); + Show("the rejected input", invalid.Input); + } +} + +static void Heading(string title) +{ + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); +} + +static void Show(string label, object? value) => Console.WriteLine($" {label, -34} -> {value}"); + +static void ShowResult(string label, Result result) +{ + var text = result.Match(value => $"Success({value})", DescribeError); + + Console.WriteLine($" {label, -34} -> {text}"); +} + +static string DescribeError(TenantError error) => + error switch + { + TenantInputInvalid invalid => $"TenantInputInvalid({invalid.Errors.Length} error(s))", + TenantNotFound notFound => $"TenantNotFound({notFound.TenantId.Value})", + TenantDisabled disabled => $"TenantDisabled({disabled.TenantId.Value})", + TenantAlreadyExists exists => $"TenantAlreadyExists({exists.TenantId.Value})", + _ => nameof(TenantError), + }; + +static void PrintErrors(Result result) +{ + if (!result.TryGetError(out var error) || error is not TenantInputInvalid invalid) + return; + + foreach (var validationError in invalid.Errors) + { + var path = string.Join(".", validationError.Path); + + Console.WriteLine($" {path}: {validationError.Message} [{validationError.Code}]"); + } +} diff --git a/src/examples/Examples.Zod/Tenant.cs b/src/examples/Examples.Zod/Tenant.cs new file mode 100644 index 0000000..b35167d --- /dev/null +++ b/src/examples/Examples.Zod/Tenant.cs @@ -0,0 +1,15 @@ +namespace Purview.Results.Examples.Zod; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +public readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.Zod/TenantError.cs b/src/examples/Examples.Zod/TenantError.cs new file mode 100644 index 0000000..726ddc0 --- /dev/null +++ b/src/examples/Examples.Zod/TenantError.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; + +using Purview.Results.SourceGeneration; +using Purview.Results.ZodSharp; + +using ZodSharp.Core; + +namespace Purview.Results.Examples.Zod; + +// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. +#pragma warning disable CA1815 +/// +/// The expected failures of the tenant registration operation. +/// +/// +/// The failure that carries ZodSharp validation errors is a union case like any other, so a rejected input +/// flows through the same result pipeline as a missing or disabled tenant. +/// +[GenerateResult] +public readonly union TenantError(TenantInputInvalid, TenantNotFound, TenantDisabled, TenantAlreadyExists); +#pragma warning restore CA1815 + +/// +/// The supplied input did not satisfy its schema. +/// +/// The rejected input. +/// The validation errors reported by TenantInputSchema. +/// +/// Implementing lets an HTTP layer turn the failure into a validation +/// problem without knowing the error type. +/// +public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) + : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +/// +/// The requested tenant does not exist. +/// +/// The identifier of the tenant. +public readonly record struct TenantNotFound(TenantId TenantId); + +/// +/// The requested tenant exists but is disabled. +/// +/// The identifier of the tenant. +public readonly record struct TenantDisabled(TenantId TenantId); + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +public readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/examples/Examples.Zod/TenantInput.cs b/src/examples/Examples.Zod/TenantInput.cs new file mode 100644 index 0000000..fbce24f --- /dev/null +++ b/src/examples/Examples.Zod/TenantInput.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Schemas; + +namespace Purview.Results.Examples.Zod; + +/// +/// The raw input a caller registers a tenant with. +/// +/// +/// +/// [ZodSchema] makes ZodSharp's generator emit TenantInputSchema, whose Validate method +/// returns a ValidationResult<TenantInput> instead of throwing. +/// +/// +/// The refinement hook declares a cross-member invariant, so a rejection reports a stable error code that an +/// HTTP layer can address. +/// +/// +[ZodSchema] +public sealed partial record TenantInput +{ + /// The identifier of the new tenant. + [Required] + public string? TenantId { get; init; } + + /// The display name of the new tenant. + [Required] + public string? Name { get; init; } + + /// + /// Creates an input. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The input. + public static TenantInput Create(string? tenantId, string? name) => new() { TenantId = tenantId, Name = name }; + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.TenantId is not null && context.Value.TenantId == context.Value.Name) + { + context.AddIssue( + "tenant_id_matches_name", + "The tenant id must differ from the display name.", + [nameof(TenantId)] + ); + } + } +} diff --git a/src/examples/Examples.Zod/TenantRegistrationService.cs b/src/examples/Examples.Zod/TenantRegistrationService.cs new file mode 100644 index 0000000..c65f64d --- /dev/null +++ b/src/examples/Examples.Zod/TenantRegistrationService.cs @@ -0,0 +1,82 @@ +using Purview.Results.ZodSharp; + +namespace Purview.Results.Examples.Zod; + +/// +/// Registers tenants, validating every input with ZodSharp before it is used. +/// +/// +/// +/// Validate never throws: it returns a ValidationResult<TenantInput> carrying the input on +/// success and every ValidationError on failure. ToResult translates that into a result whose +/// error type this method names explicitly, because a union case does not carry the union that contains it. +/// +/// +/// Throwing stays reserved for exceptional circumstances — a lost database connection, for example. A rejected +/// input is an expected outcome, so it is a value. +/// +/// +public sealed class TenantRegistrationService +{ + readonly Dictionary _tenants = new() + { + [new TenantId("acme")] = new Tenant(new TenantId("acme"), "Acme", Enabled: true), + }; + + /// + /// Registers a tenant from raw input, validating it first. + /// + /// The input to validate and register. + /// The registered tenant, or a describing why it was rejected. + public Result Register(TenantInput input) => + // The validated value is the value this method succeeds with, so the adapter maps the failure into the + // union case directly and Bind chains the registration that can fail again. + TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(input, errors)) + .Bind(RegisterValidated); + + /// + /// Registers a tenant from raw parts, using the generated helper because the validated value is not the + /// value this method succeeds with. + /// + /// The identifier of the new tenant. + /// The display name of the new tenant. + /// The registered tenant, or a describing why it was rejected. + public Result Register(string? tenantId, string? name) + { + var input = TenantInput.Create(tenantId, name); + var validation = TenantInputSchema.Validate(input); + + // The guard idiom: the method succeeds with a Tenant, so the failure is produced by the helper the + // generator emits for the case rather than by the ToResult adapter. + if (!validation.IsSuccess) + return new TenantInputInvalid(input, validation.Errors).AsFailure(); + + return RegisterValidated(validation.Value); + } + + /// + /// Finds a tenant. + /// + /// The identifier of the tenant. + /// The tenant, or a failure. + public Result Find(TenantId tenantId) => + _tenants.TryGetValue(tenantId, out var tenant) + ? Result.Success(tenant) + : new TenantNotFound(tenantId).AsFailure(); + + Result RegisterValidated(TenantInput input) + { + // Validation guarantees both members are present, so the non-null assertion is safe here. + TenantId tenantId = new(input.TenantId!); + + if (_tenants.ContainsKey(tenantId)) + return new TenantAlreadyExists(tenantId).AsFailure(); + + Tenant tenant = new(tenantId, input.Name!, Enabled: true); + _tenants[tenantId] = tenant; + + return Result.Success(tenant); + } +} diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 2652e22..5a80605 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -89,6 +89,20 @@ app.MapGet("/tenants/{id:int}", (int id, HttpContext context) => `DefaultResultsHttpMapper` via `TryAddSingleton`, so a host can register its own implementation first to replace the defaults entirely. +## Examples + +[`src/examples/Examples.AspNetCore`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.AspNetCore) +is a runnable minimal-API example that maps the `TenantNotFound` case to `404`, the `TenantDisabled` case to +`403` and the `TenantError` error type to `409`, and shows the response an endpoint that returns `default` +receives. + +```bash +dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 +``` + +The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and +ASP.NET Core + Zod examples too. + ## Related packages Validation failures that carry ZodSharp errors are rendered as `HttpValidationProblemDetails` by diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index 85c5880..f511514 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -90,6 +90,19 @@ case → union conversion so only the library's union → result conversion rema Result GetTenant(TenantId tenantId) => (TenantError)new TenantNotFound(tenantId); ``` +## Examples + +[`src/examples/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Basic) is a +runnable console example over the Tenant* domain this README documents: the three result states, the combinators, +probing, the throw-on-misuse contract and the generated `AsFailure()` helper. + +```bash +dotnet run --project src/examples/Examples.Basic +``` + +The [repository README](https://github.com/purview-dev/results#examples) lists the ZodSharp, ASP.NET Core and +ASP.NET Core + Zod examples too. + ## Related packages | Package | Purpose | diff --git a/src/src/SourceGenerator/Sdk/README.md b/src/src/SourceGenerator/Sdk/README.md index 3a86f5b..299d42f 100644 --- a/src/src/SourceGenerator/Sdk/README.md +++ b/src/src/SourceGenerator/Sdk/README.md @@ -206,6 +206,19 @@ diagnostic, so the harness builds the compilation itself, runs the generator (th helpers must exist for the rewrite to bind), applies the fix through an `AdhocWorkspace`, and recompiles the rewritten source to prove it compiles. +## Examples + +[`src/examples/Examples.Basic`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Basic) +declares a `[GenerateResult]` union over the Tenant* domain the README uses and calls the helper this generator +emits for each of its cases: + +```csharp +Result result = new TenantNotFound(tenantId).AsFailure(); +``` + +The [repository README](https://github.com/purview-dev/results#examples) covers the ZodSharp, ASP.NET Core and +ASP.NET Core + Zod examples as well. + ## Agent skills This package ships the `purview-results-union-errors` agent skill, the `purview-results-union-author` agent and diff --git a/src/src/ZodSharp.AspNetCore/Sdk/README.md b/src/src/ZodSharp.AspNetCore/Sdk/README.md index a5f860b..d50b5fd 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/README.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/README.md @@ -47,6 +47,19 @@ The status code resolved by `ZodProblemDetailsOptions.StatusCodeSelector` wins; `defaultStatusCode`) is only used when the resolved error type does not define one. The trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). +## Examples + +[`src/examples/Examples.AspNetCore.Zod`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.AspNetCore.Zod) +is a runnable minimal-API example where a `TenantInputInvalid` failure carrying ZodSharp errors becomes a `400` +validation problem, while the host's own `TenantAlreadyExists` mapping still returns `409`. + +```bash +dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 +``` + +The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and +ASP.NET Core examples too. + ## Related packages | Package | Purpose | diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 382f6ab..fe2c3bc 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -56,6 +56,19 @@ A factory returning a result rather than an error is deliberately **not** offere `Result` the compiler prefers a `Func<..., TError>` parameter and would silently nest the results. Naming the error type explicitly keeps the intent unambiguous. +## Examples + +[`src/examples/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/examples/Examples.Zod) is a +runnable console example that validates a `[ZodSchema] TenantInput` and turns the outcome into a +`Result`, with the rejection carrying its reported `ValidationError`s. + +```bash +dotnet run --project src/examples/Examples.Zod +``` + +The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ASP.NET Core and +ASP.NET Core + Zod examples too. + ## Related packages | Package | Purpose | From 38c498983d27346d122a34a6ee42f98ad4777c28 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 11:36:10 +0100 Subject: [PATCH 3/5] docs: added samples --- AGENTS.md | 25 +- README.md | 25 +- src/Results.slnx | 14 +- .../AspNetCore/DefaultResultsHttpMapper.cs | 7 +- src/src/AspNetCore/IResultsFailureMapper.cs | 51 +++ src/src/AspNetCore/ResultsFailureContext.cs | 22 + src/src/AspNetCore/ResultsHttpOptions.cs | 47 ++- .../purview-results-http-mapping/SKILL.md | 34 +- src/src/AspNetCore/Sdk/README.md | 41 +- .../Examples.AspNetCore.Zod.csproj | 10 +- .../Examples.AspNetCore.Zod/Program.cs | 15 +- .../Properties/launchSettings.json | 12 + .../Examples.AspNetCore.Zod/Tenant.cs | 4 +- .../Examples.AspNetCore.Zod/TenantError.cs | 13 +- .../Examples.AspNetCore.Zod/TenantInput.cs | 2 +- .../TenantRegistrationService.cs | 2 +- .../Examples.AspNetCore.csproj | 6 +- .../Examples.AspNetCore/Program.cs | 0 .../Properties/launchSettings.json | 12 + .../Examples.AspNetCore/Tenant.cs | 4 +- .../Examples.AspNetCore/TenantError.cs | 11 +- .../Examples.AspNetCore/TenantStore.cs | 2 +- .../Examples.Basic/Examples.Basic.csproj | 4 +- .../Examples.Basic/Program.cs | 0 .../Examples.Basic/Tenant.cs | 4 +- .../Examples.Basic/TenantError.cs | 11 +- .../Examples.Basic/TenantStore.cs | 2 +- .../Examples.Zod/Examples.Zod.csproj | 6 +- src/{examples => src}/Examples.Zod/Program.cs | 0 src/{examples => src}/Examples.Zod/Tenant.cs | 4 +- .../Examples.Zod/TenantError.cs | 17 +- .../Examples.Zod/TenantInput.cs | 2 +- .../Examples.Zod/TenantRegistrationService.cs | 3 +- .../purview-results-union-errors/SKILL.md | 14 + src/src/SourceGenerator/Sdk/README.md | 46 +++ .../UnionEqualityDiagnosticSuppressor.cs | 104 +++++ ...SharpResultsServiceCollectionExtensions.cs | 39 +- .../SKILL.md | 39 +- src/src/ZodSharp.AspNetCore/Sdk/README.md | 51 ++- .../ZodResultsFailureMapper.cs | 92 +++++ .../ZodResultsHttpOptions.cs | 195 +++++++++ .../ResultsFailureMapperTests.cs | 225 ++++++++++ .../ResultsHttpTestUnions.cs | 2 +- .../ResultExtensionsTests.cs | 4 +- .../GeneratedTestUnions.cs | 8 +- .../UnionEqualityDiagnosticSuppressorTests.cs | 225 ++++++++++ .../ValidationTestUnions.cs | 2 +- .../ZodResultsFailureMapperTests.cs | 387 ++++++++++++++++++ .../ValidationTestUnions.cs | 2 +- 49 files changed, 1718 insertions(+), 129 deletions(-) create mode 100644 src/src/AspNetCore/IResultsFailureMapper.cs create mode 100644 src/src/AspNetCore/ResultsFailureContext.cs rename src/{examples => src}/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj (80%) rename src/{examples => src}/Examples.AspNetCore.Zod/Program.cs (68%) create mode 100644 src/src/Examples.AspNetCore.Zod/Properties/launchSettings.json rename src/{examples => src}/Examples.AspNetCore.Zod/Tenant.cs (75%) rename src/{examples => src}/Examples.AspNetCore.Zod/TenantError.cs (71%) rename src/{examples => src}/Examples.AspNetCore.Zod/TenantInput.cs (96%) rename src/{examples => src}/Examples.AspNetCore.Zod/TenantRegistrationService.cs (96%) rename src/{examples => src}/Examples.AspNetCore/Examples.AspNetCore.csproj (82%) rename src/{examples => src}/Examples.AspNetCore/Program.cs (100%) create mode 100644 src/src/Examples.AspNetCore/Properties/launchSettings.json rename src/{examples => src}/Examples.AspNetCore/Tenant.cs (74%) rename src/{examples => src}/Examples.AspNetCore/TenantError.cs (64%) rename src/{examples => src}/Examples.AspNetCore/TenantStore.cs (98%) rename src/{examples => src}/Examples.Basic/Examples.Basic.csproj (86%) rename src/{examples => src}/Examples.Basic/Program.cs (100%) rename src/{examples => src}/Examples.Basic/Tenant.cs (74%) rename src/{examples => src}/Examples.Basic/TenantError.cs (68%) rename src/{examples => src}/Examples.Basic/TenantStore.cs (97%) rename src/{examples => src}/Examples.Zod/Examples.Zod.csproj (85%) rename src/{examples => src}/Examples.Zod/Program.cs (100%) rename src/{examples => src}/Examples.Zod/Tenant.cs (74%) rename src/{examples => src}/Examples.Zod/TenantError.cs (71%) rename src/{examples => src}/Examples.Zod/TenantInput.cs (97%) rename src/{examples => src}/Examples.Zod/TenantRegistrationService.cs (95%) create mode 100644 src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs create mode 100644 src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs create mode 100644 src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs create mode 100644 src/tests/AspNetCore.UnitTests/ResultsFailureMapperTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs create mode 100644 src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs diff --git a/AGENTS.md b/AGENTS.md index e43870b..c6678d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,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`. @@ -115,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()` 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`. @@ -124,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 diff --git a/README.md b/README.md index aba676e..6939cb0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,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: @@ -162,21 +164,34 @@ Running `Examples.AspNetCore` answers as follows: | `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(); +builder.Services.AddResultsHttp(options => options + .Map(_ => TypedResults.NotFound()) + .AddFailureMapper()); +``` + ### ASP.NET Core + Zod -Register the validation fallback last, so any mapping the host declared for a specific case or error always -wins, and reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping: +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(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) ); -builder.Services.AddResultsZodSharpHttp(); +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: +the host mapping it — unless a rule answers one of its codes or categories: ```json { diff --git a/src/Results.slnx b/src/Results.slnx index a4a7dd3..a27dd9e 100644 --- a/src/Results.slnx +++ b/src/Results.slnx @@ -18,15 +18,11 @@ - - - - - - + + + + + diff --git a/src/src/AspNetCore/DefaultResultsHttpMapper.cs b/src/src/AspNetCore/DefaultResultsHttpMapper.cs index b08f2f5..2fa0472 100644 --- a/src/src/AspNetCore/DefaultResultsHttpMapper.cs +++ b/src/src/AspNetCore/DefaultResultsHttpMapper.cs @@ -75,9 +75,12 @@ IResult Failure(IResultValue result, HttpContext context) if (errorType is not null && errorType != caseType && _options.TryGetMapper(errorType, out var errorMapper)) return errorMapper(error!, context); - // Fallbacks see the case, so per-error-type behaviour does not have to unwrap a union itself. + // Fallbacks see the case, so per-error-type behaviour does not have to unwrap a union itself; the context + // also carries the error, so a shape-based mapper can look at the union as a whole. + ResultsFailureContext failure = new(caseValue, error, context); + foreach (var fallback in _options.Fallbacks) - if (fallback(caseValue, context) is { } mapped) + if (fallback(failure) is { } mapped) return mapped; var unmappedType = caseType ?? errorType; diff --git a/src/src/AspNetCore/IResultsFailureMapper.cs b/src/src/AspNetCore/IResultsFailureMapper.cs new file mode 100644 index 0000000..03ac486 --- /dev/null +++ b/src/src/AspNetCore/IResultsFailureMapper.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; + +namespace Purview.Results.AspNetCore; + +/// +/// Maps a failure onto a response by looking at the failure's shape rather than at its type. +/// +/// +/// +/// A mapping registered on is keyed by type, which covers the cases a host can +/// name. A failure mapper is the extension point for the policies a type cannot express: rendering some of a +/// validation failure's error codes differently, giving one category of errors its own status, or any rule that +/// has to inspect the value the failure carries. +/// +/// +/// Mappers run in the fallback stage of the failure resolution order, so a mapping registered for the case type +/// or for the error type always wins. Return to defer to the next fallback; the last +/// fallback that returns a response wins, and a failure that no mapping, fallback or mapper handles is the +/// unmapped-failure response (a logged 500 by default). +/// +/// +/// A mapper is not a catch-all: answering every failure here — with a generic problem or with +/// 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 answer +/// for the rest. +/// +/// +/// Register a mapper with , which resolves it from +/// dependency injection the first time it is needed, so it may take its own dependencies in its constructor. +/// +/// +/// +/// +/// public sealed class ValidationCodeMapper(IOptions<ZodResultsHttpOptions> codes) : IResultsFailureMapper +/// { +/// public IResult? Map(ResultsFailureContext context) => +/// context.Case is IValidationErrorCarrier carrier && carrier.ValidationErrors.Any(e => e.Code == "not_found") +/// ? TypedResults.NotFound() +/// : null; +/// } +/// +/// +public interface IResultsFailureMapper +{ + /// + /// Maps the failure onto a response. + /// + /// The failure to map and the request it belongs to. + /// The response, or to defer to the next fallback. + IResult? Map(ResultsFailureContext context); +} diff --git a/src/src/AspNetCore/ResultsFailureContext.cs b/src/src/AspNetCore/ResultsFailureContext.cs new file mode 100644 index 0000000..5f3ae52 --- /dev/null +++ b/src/src/AspNetCore/ResultsFailureContext.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace Purview.Results.AspNetCore; + +/// +/// The failure a mapper is asked to turn into a response. +/// +/// +/// +/// is the failure's case value: the active case of a union error, or the error +/// itself when the error is not a union. is the error value the result carries, so a mapper +/// that has to look at the union as a whole can, while one that only cares about the payload does not have to +/// unwrap it. +/// +/// +/// A context is only built for a failure; a successful result never reaches a mapper that maps failures. +/// +/// +/// The union case, or the error itself when the error is not a union. +/// The error value the result carries. +/// The current request. +public readonly record struct ResultsFailureContext(object? Case, object? Error, HttpContext HttpContext); diff --git a/src/src/AspNetCore/ResultsHttpOptions.cs b/src/src/AspNetCore/ResultsHttpOptions.cs index 6b4ac1d..570cc4a 100644 --- a/src/src/AspNetCore/ResultsHttpOptions.cs +++ b/src/src/AspNetCore/ResultsHttpOptions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Purview.Results.AspNetCore; @@ -10,11 +11,16 @@ namespace Purview.Results.AspNetCore; /// so Map<TenantNotFound>(...) handles that case, while Map<TenantError>(...) handles every /// case that has no mapping of its own. A non-union error type is keyed by the error type itself. Registering the /// same type twice replaces the earlier mapping. +/// +/// A failure that no mapping handled reaches the fallback stage, which is the single ordered list +/// and append to: the order they are called in +/// is the order they are consulted in, and a failure no fallback answers produces the unmapped-failure response. +/// /// public sealed class ResultsHttpOptions { readonly Dictionary> _mappers = []; - readonly List> _fallbacks = []; + readonly List> _fallbacks = []; /// /// Gets or sets the status code used when a result succeeded. Defaults to 200 OK. @@ -59,7 +65,7 @@ public sealed class ResultsHttpOptions /// /// Gets the fallbacks consulted, in order, when a failure has no mapping. /// - public IReadOnlyList> Fallbacks => _fallbacks; + public IReadOnlyList> Fallbacks => _fallbacks; /// /// Maps a case (or the error itself, for a non-union error type) onto a response. @@ -102,11 +108,46 @@ public ResultsHttpOptions AddFallback(Func fallb { ArgumentNullException.ThrowIfNull(fallback); - _fallbacks.Add(fallback); + // A fallback sees the case, so per-error-type behaviour does not have to unwrap a union itself. + _fallbacks.Add(context => fallback(context.Case, context.HttpContext)); + + return this; + } + + /// + /// Adds a failure mapper that is consulted, in registration order, alongside the fallbacks when a failure has + /// no mapping. + /// + /// The mapper type, registered in dependency injection. + /// The options instance for chaining. + /// + /// The 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 — for example + /// services.AddSingleton<MyMapper>(), or against and name that + /// interface as . + /// + /// + /// Thrown when a failure reaches the mapper and it is not registered in dependency injection. + /// + public ResultsHttpOptions AddFailureMapper() + where TMapper : class, IResultsFailureMapper + { + _fallbacks.Add(context => ResolveMapper(context.HttpContext.RequestServices).Map(context)); return this; } internal bool TryGetMapper(Type caseType, out Func mapper) => _mappers.TryGetValue(caseType, out mapper!); + + /// + /// Resolves a declared failure mapper, reporting the registration the host is missing rather than the + /// dependency injection container's own message. + /// + static TMapper ResolveMapper(IServiceProvider services) + where TMapper : class, IResultsFailureMapper => + services.GetService() + ?? throw new InvalidOperationException( + $"The failure mapper '{typeof(TMapper).FullName}' is declared in ResultsHttpOptions but is not registered in dependency injection. Register it, for example services.AddSingleton<{typeof(TMapper).Name}>()." + ); } diff --git a/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md b/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md index f9c80d5..af3dd13 100644 --- a/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md +++ b/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-http-mapping -description: "Use when an ASP.NET Core endpoint returns Purview.Results values — registering AddResultsHttp, mapping error cases to status codes with Map, using fallbacks, correcting unmapped-failure 500s, or converting a result with ToHttpResult." +description: "Use when an ASP.NET Core endpoint returns Purview.Results values — registering AddResultsHttp, mapping error cases to status codes with Map, answering a failure by its shape with an IResultsFailureMapper, using fallbacks, correcting unmapped-failure 500s, or converting a result with ToHttpResult." --- # Mapping results onto ASP.NET Core responses @@ -47,12 +47,42 @@ not a union), then: 1. the mapping registered for the **case** type — `Map(…)`; 2. the mapping registered for the **error** type — `Map(…)`, which covers every case without its own mapping; -3. each registered **fallback**, in order; a fallback returns `null` to defer to the next one; +3. the **fallback stage**, in registration order — `AddFallback(...)` delegates and + `AddFailureMapper()` mappers share this one list, so whichever was registered first is consulted + first; returning `null` defers to the next entry; 4. the **unmapped-failure** response. Registering the same type twice replaces the earlier mapping. Fallbacks receive the *case* value, so a fallback never has to unwrap the union itself. +## Shape-based rules + +`Map` is keyed by type, which cannot see the *value* a failure carries. When the answer depends on the +payload — a validation error code, a category, a field — register a failure mapper instead: + +```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 to the case mappings and the other fallbacks +} + +builder.Services.AddSingleton(); +builder.Services.AddResultsHttp(options => options.AddFailureMapper()); +``` + +- `ResultsFailureContext` carries `Case` (the active case, or the error itself for a non-union error), `Error` + (the union or error value as a whole) and `HttpContext`. +- The mapper is resolved from the request's services, so it may take dependencies in its constructor; register + it before the first request or the failure throws an `InvalidOperationException` naming what is missing. +- A mapper shares the fallback list with `AddFallback`, so **registering it earlier makes it win** — that is how + a host answers some validation codes before a package's validation mapping (see + `purview-results-zodsharp-problems`). +- **A mapper is not a catch-all.** Answering every failure with a generic problem hides the mapping gaps the + unmapped-failure `500` exists to expose. Answer the shape you can name and return `null` for the rest. + ## Options | Option | Default | Notes | diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 5a80605..c5ae0f7 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -48,7 +48,9 @@ for a non-union error), then mapped in this order: 1. a mapping registered for the case type — `Map(...)` 2. a mapping registered for the error type — `Map(...)`, which handles every case without its own mapping -3. each fallback in registration order; a fallback returns `null` to defer to the next one +3. the fallback stage, in registration order: the `AddFallback(...)` delegates and the + `AddFailureMapper()` mappers share one list, and whatever is registered first is consulted first; 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 @@ -67,10 +69,42 @@ endpoint returning `default` is a host bug rather than a domain outcome. | `IncludeTraceId` | `true` | Whether problem responses this package writes itself carry the request trace identifier | | `Map(Func)` | — | Maps a case (or the error itself) to a response | | `Map(Func)` | — | Same, with access to the request | -| `AddFallback(Func)` | — | Consulted in order for unmapped failures | +| `AddFallback(Func)` | — | Consulted in order for unmapped failures, with the case value | +| `AddFailureMapper()` | — | 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(...)` | +| The **value** the failure carries — a validation code, a category, a field | `IResultsFailureMapper` via `AddFailureMapper()` | +| A quick inline rule, with no dependencies | `AddFallback((error, context) => ...)` | +| To replace the whole pipeline | Your own `IResultsHttpMapper` (see [Extensibility](#extensibility)) | + +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(); +builder.Services.AddResultsHttp(options => options + .Map(_ => TypedResults.NotFound()) + .AddFailureMapper()); +``` + +The mapper is resolved from the request's services the first time it is needed, so it may take its own +dependencies in its constructor. Register it (`AddSingleton()`, or against `IResultsFailureMapper` and +name that interface as `TMapper`) before the first request; a failure that reaches an unregistered mapper throws +an `InvalidOperationException` naming the registration that is missing. + ## Converting a result by hand When a filter is not appropriate, convert explicitly: @@ -87,7 +121,8 @@ app.MapGet("/tenants/{id:int}", (int id, HttpContext context) => `IResultsHttpMapper` is registered with `AddResultsHttp` as `DefaultResultsHttpMapper` via `TryAddSingleton`, so a host can register its own implementation first to replace -the defaults entirely. +the defaults entirely. A host that replaces it also bypasses `ResultsHttpOptions` — including the failure mappers +— so prefer the extension points above unless the pipeline itself has to change. ## Examples diff --git a/src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj b/src/src/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj similarity index 80% rename from src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj rename to src/src/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj index d2f62dd..78a0a29 100644 --- a/src/examples/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj +++ b/src/src/Examples.AspNetCore.Zod/Examples.AspNetCore.Zod.csproj @@ -9,22 +9,22 @@ - + - + - + - + @@ -43,7 +43,7 @@ dependency, and without flowing to anything that references this project. --> 200 OK // curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"name\":\"Nameless\"}" -> 400 validation problem +// curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"tenantId\":\"same\",\"name\":\"same\"}" -> 422 Unprocessable Entity (code rule) // curl -i -X POST http://localhost:5216/tenants -H "Content-Type: application/json" -d "{\"tenantId\":\"acme\",\"name\":\"Acme\"}" -> 409 Conflict // +// The 422 shows the code rule: a failure whose errors include `tenant_id_matches_name` is answered by the rule +// registered below, while every other validation failure stays a 400 validation problem. +// // The 409 shows the precedence: the mapping registered for a specific case always wins over the validation -// fallback, and a mapping registered for the error type would still win over the fallback too. +// mapping, and a mapping registered for the error type would still win over it too. var builder = WebApplication.CreateBuilder(args); @@ -27,9 +31,12 @@ ) ); -// Registered as a fallback, so the case mapping above still wins and a failure that carries no validation -// errors still takes the host's unmapped-failure path. -builder.Services.AddResultsZodSharpHttp(); +// Registered as a failure mapper, so the case mapping above still wins and a failure that carries no validation +// errors still takes the host's unmapped-failure path. A code rule answers a failure whose errors include the +// code with a response of its own; every other validation failure stays a 400 validation problem. +builder.Services.AddResultsZodSharpHttp(options => + options.MapCode("tenant_id_matches_name", StatusCodes.Status422UnprocessableEntity) +); var app = builder.Build(); diff --git a/src/src/Examples.AspNetCore.Zod/Properties/launchSettings.json b/src/src/Examples.AspNetCore.Zod/Properties/launchSettings.json new file mode 100644 index 0000000..f4ce4d7 --- /dev/null +++ b/src/src/Examples.AspNetCore.Zod/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Examples.AspNetCore.Zod": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:64367;http://localhost:64369" + } + } +} \ No newline at end of file diff --git a/src/examples/Examples.AspNetCore.Zod/Tenant.cs b/src/src/Examples.AspNetCore.Zod/Tenant.cs similarity index 75% rename from src/examples/Examples.AspNetCore.Zod/Tenant.cs rename to src/src/Examples.AspNetCore.Zod/Tenant.cs index 9b45aa3..99d66a6 100644 --- a/src/examples/Examples.AspNetCore.Zod/Tenant.cs +++ b/src/src/Examples.AspNetCore.Zod/Tenant.cs @@ -4,7 +4,7 @@ namespace Purview.Results.Examples.AspNetCore.Zod; /// The identifier of a tenant. /// /// The identifier value. -public readonly record struct TenantId(string Value); +readonly record struct TenantId(string Value); /// /// A tenant. @@ -12,4 +12,4 @@ namespace Purview.Results.Examples.AspNetCore.Zod; /// The identifier of the tenant. /// The display name of the tenant. /// Whether the tenant may be used. -public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.AspNetCore.Zod/TenantError.cs b/src/src/Examples.AspNetCore.Zod/TenantError.cs similarity index 71% rename from src/examples/Examples.AspNetCore.Zod/TenantError.cs rename to src/src/Examples.AspNetCore.Zod/TenantError.cs index 2effa78..b759d1b 100644 --- a/src/examples/Examples.AspNetCore.Zod/TenantError.cs +++ b/src/src/Examples.AspNetCore.Zod/TenantError.cs @@ -1,14 +1,10 @@ -using System.Collections.Immutable; - using Purview.Results.SourceGeneration; using Purview.Results.ZodSharp; - +using System.Collections.Immutable; using ZodSharp.Core; namespace Purview.Results.Examples.AspNetCore.Zod; -// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. -#pragma warning disable CA1815 /// /// The expected failures of the tenant registration operation. /// @@ -17,21 +13,20 @@ namespace Purview.Results.Examples.AspNetCore.Zod; /// case is rendered as a validation problem by the fallback; a case with its own mapping still wins. /// [GenerateResult] -public readonly union TenantError(TenantAlreadyExists, TenantInputInvalid); -#pragma warning restore CA1815 +readonly union TenantError(TenantAlreadyExists, TenantInputInvalid); /// /// A tenant with the requested identifier already exists. /// /// The identifier of the tenant. -public readonly record struct TenantAlreadyExists(TenantId TenantId); +readonly record struct TenantAlreadyExists(TenantId TenantId); /// /// The supplied input did not satisfy its schema. /// /// The rejected input. /// The validation errors reported by TenantInputSchema. -public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) +readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) : IValidationErrorCarrier { ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; diff --git a/src/examples/Examples.AspNetCore.Zod/TenantInput.cs b/src/src/Examples.AspNetCore.Zod/TenantInput.cs similarity index 96% rename from src/examples/Examples.AspNetCore.Zod/TenantInput.cs rename to src/src/Examples.AspNetCore.Zod/TenantInput.cs index ef21367..faac7ab 100644 --- a/src/examples/Examples.AspNetCore.Zod/TenantInput.cs +++ b/src/src/Examples.AspNetCore.Zod/TenantInput.cs @@ -12,7 +12,7 @@ namespace Purview.Results.Examples.AspNetCore.Zod; /// returns a ValidationResult<TenantInput> instead of throwing. /// [ZodSchema] -public sealed partial record TenantInput +sealed partial record TenantInput { /// The identifier of the new tenant. [Required] diff --git a/src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs b/src/src/Examples.AspNetCore.Zod/TenantRegistrationService.cs similarity index 96% rename from src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs rename to src/src/Examples.AspNetCore.Zod/TenantRegistrationService.cs index 5b38bff..1d39fca 100644 --- a/src/examples/Examples.AspNetCore.Zod/TenantRegistrationService.cs +++ b/src/src/Examples.AspNetCore.Zod/TenantRegistrationService.cs @@ -5,7 +5,7 @@ namespace Purview.Results.Examples.AspNetCore.Zod; /// /// Registers tenants, validating every input with ZodSharp before it is used. /// -public sealed class TenantRegistrationService +sealed class TenantRegistrationService { readonly Dictionary _tenants = new() { diff --git a/src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj b/src/src/Examples.AspNetCore/Examples.AspNetCore.csproj similarity index 82% rename from src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj rename to src/src/Examples.AspNetCore/Examples.AspNetCore.csproj index ce64802..1d75bad 100644 --- a/src/examples/Examples.AspNetCore/Examples.AspNetCore.csproj +++ b/src/src/Examples.AspNetCore/Examples.AspNetCore.csproj @@ -9,12 +9,12 @@ - + - + @@ -24,7 +24,7 @@ dependency, and without flowing to anything that references this project. --> /// The identifier value. -public readonly record struct TenantId(string Value); +readonly record struct TenantId(string Value); /// /// A tenant. @@ -12,4 +12,4 @@ namespace Purview.Results.Examples.AspNetCore; /// The identifier of the tenant. /// The display name of the tenant. /// Whether the tenant may be used. -public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.AspNetCore/TenantError.cs b/src/src/Examples.AspNetCore/TenantError.cs similarity index 64% rename from src/examples/Examples.AspNetCore/TenantError.cs rename to src/src/Examples.AspNetCore/TenantError.cs index 732059c..3ef3d87 100644 --- a/src/examples/Examples.AspNetCore/TenantError.cs +++ b/src/src/Examples.AspNetCore/TenantError.cs @@ -2,8 +2,6 @@ namespace Purview.Results.Examples.AspNetCore; -// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. -#pragma warning disable CA1815 /// /// The expected failures of a tenant operation. /// @@ -12,23 +10,22 @@ namespace Purview.Results.Examples.AspNetCore; /// every case without a mapping of its own. /// [GenerateResult] -public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); -#pragma warning restore CA1815 +readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); /// /// The requested tenant does not exist. /// /// The identifier of the tenant. -public readonly record struct TenantNotFound(TenantId TenantId); +readonly record struct TenantNotFound(TenantId TenantId); /// /// The requested tenant exists but is disabled. /// /// The identifier of the tenant. -public readonly record struct TenantDisabled(TenantId TenantId); +readonly record struct TenantDisabled(TenantId TenantId); /// /// A tenant with the requested identifier already exists. /// /// The identifier of the tenant. -public readonly record struct TenantAlreadyExists(TenantId TenantId); +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/examples/Examples.AspNetCore/TenantStore.cs b/src/src/Examples.AspNetCore/TenantStore.cs similarity index 98% rename from src/examples/Examples.AspNetCore/TenantStore.cs rename to src/src/Examples.AspNetCore/TenantStore.cs index 0d1cf4d..3289c5d 100644 --- a/src/examples/Examples.AspNetCore/TenantStore.cs +++ b/src/src/Examples.AspNetCore/TenantStore.cs @@ -7,7 +7,7 @@ namespace Purview.Results.Examples.AspNetCore; /// Every operation returns Result<Tenant, TenantError>: an expected failure such as a missing /// tenant is a value, and only exceptional circumstances would throw. /// -public sealed class InMemoryTenantStore +sealed class InMemoryTenantStore { readonly Dictionary _tenants = new() { diff --git a/src/examples/Examples.Basic/Examples.Basic.csproj b/src/src/Examples.Basic/Examples.Basic.csproj similarity index 86% rename from src/examples/Examples.Basic/Examples.Basic.csproj rename to src/src/Examples.Basic/Examples.Basic.csproj index 0a4479d..9d573f0 100644 --- a/src/examples/Examples.Basic/Examples.Basic.csproj +++ b/src/src/Examples.Basic/Examples.Basic.csproj @@ -9,7 +9,7 @@ - + @@ -19,7 +19,7 @@ dependency, and without flowing to anything that references this project. --> /// The identifier value. -public readonly record struct TenantId(string Value); +readonly record struct TenantId(string Value); /// /// A tenant. @@ -12,4 +12,4 @@ namespace Purview.Results.Examples.Basic; /// The identifier of the tenant. /// The display name of the tenant. /// Whether the tenant may be used. -public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.Basic/TenantError.cs b/src/src/Examples.Basic/TenantError.cs similarity index 68% rename from src/examples/Examples.Basic/TenantError.cs rename to src/src/Examples.Basic/TenantError.cs index 6bacc56..8eab6fb 100644 --- a/src/examples/Examples.Basic/TenantError.cs +++ b/src/src/Examples.Basic/TenantError.cs @@ -2,8 +2,6 @@ namespace Purview.Results.Examples.Basic; -// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. -#pragma warning disable CA1815 /// /// The expected failures of a tenant operation. /// @@ -13,23 +11,22 @@ namespace Purview.Results.Examples.Basic; /// AsFailure<TValue>() helper per case of every [GenerateResult] union. /// [GenerateResult] -public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); -#pragma warning restore CA1815 +readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); /// /// The requested tenant does not exist. /// /// The identifier of the tenant. -public readonly record struct TenantNotFound(TenantId TenantId); +readonly record struct TenantNotFound(TenantId TenantId); /// /// The requested tenant exists but is disabled. /// /// The identifier of the tenant. -public readonly record struct TenantDisabled(TenantId TenantId); +readonly record struct TenantDisabled(TenantId TenantId); /// /// A tenant with the requested identifier already exists. /// /// The identifier of the tenant. -public readonly record struct TenantAlreadyExists(TenantId TenantId); +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/examples/Examples.Basic/TenantStore.cs b/src/src/Examples.Basic/TenantStore.cs similarity index 97% rename from src/examples/Examples.Basic/TenantStore.cs rename to src/src/Examples.Basic/TenantStore.cs index 6a6a84f..814d1d6 100644 --- a/src/examples/Examples.Basic/TenantStore.cs +++ b/src/src/Examples.Basic/TenantStore.cs @@ -7,7 +7,7 @@ namespace Purview.Results.Examples.Basic; /// Every operation returns Result<Tenant, TenantError>: an expected failure such as a missing /// tenant is a value, and only exceptional circumstances would throw. /// -public sealed class InMemoryTenantStore +sealed class InMemoryTenantStore { readonly Dictionary _tenants = new() { diff --git a/src/examples/Examples.Zod/Examples.Zod.csproj b/src/src/Examples.Zod/Examples.Zod.csproj similarity index 85% rename from src/examples/Examples.Zod/Examples.Zod.csproj rename to src/src/Examples.Zod/Examples.Zod.csproj index b5385d3..f0788a7 100644 --- a/src/examples/Examples.Zod/Examples.Zod.csproj +++ b/src/src/Examples.Zod/Examples.Zod.csproj @@ -9,12 +9,12 @@ - + - + @@ -32,7 +32,7 @@ dependency, and without flowing to anything that references this project. --> /// The identifier value. -public readonly record struct TenantId(string Value); +readonly record struct TenantId(string Value); /// /// A tenant. @@ -12,4 +12,4 @@ namespace Purview.Results.Examples.Zod; /// The identifier of the tenant. /// The display name of the tenant. /// Whether the tenant may be used. -public readonly record struct Tenant(TenantId Id, string Name, bool Enabled); +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/examples/Examples.Zod/TenantError.cs b/src/src/Examples.Zod/TenantError.cs similarity index 71% rename from src/examples/Examples.Zod/TenantError.cs rename to src/src/Examples.Zod/TenantError.cs index 726ddc0..89d04fd 100644 --- a/src/examples/Examples.Zod/TenantError.cs +++ b/src/src/Examples.Zod/TenantError.cs @@ -1,14 +1,10 @@ -using System.Collections.Immutable; - using Purview.Results.SourceGeneration; using Purview.Results.ZodSharp; - +using System.Collections.Immutable; using ZodSharp.Core; namespace Purview.Results.Examples.Zod; -// The union declaration does not synthesise value equality, so CA1815 is not meaningful for it. -#pragma warning disable CA1815 /// /// The expected failures of the tenant registration operation. /// @@ -17,8 +13,7 @@ namespace Purview.Results.Examples.Zod; /// flows through the same result pipeline as a missing or disabled tenant. /// [GenerateResult] -public readonly union TenantError(TenantInputInvalid, TenantNotFound, TenantDisabled, TenantAlreadyExists); -#pragma warning restore CA1815 +readonly union TenantError(TenantInputInvalid, TenantNotFound, TenantDisabled, TenantAlreadyExists); /// /// The supplied input did not satisfy its schema. @@ -29,7 +24,7 @@ namespace Purview.Results.Examples.Zod; /// Implementing lets an HTTP layer turn the failure into a validation /// problem without knowing the error type. /// -public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) +readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) : IValidationErrorCarrier { ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; @@ -39,16 +34,16 @@ public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArr /// The requested tenant does not exist. /// /// The identifier of the tenant. -public readonly record struct TenantNotFound(TenantId TenantId); +readonly record struct TenantNotFound(TenantId TenantId); /// /// The requested tenant exists but is disabled. /// /// The identifier of the tenant. -public readonly record struct TenantDisabled(TenantId TenantId); +readonly record struct TenantDisabled(TenantId TenantId); /// /// A tenant with the requested identifier already exists. /// /// The identifier of the tenant. -public readonly record struct TenantAlreadyExists(TenantId TenantId); +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/examples/Examples.Zod/TenantInput.cs b/src/src/Examples.Zod/TenantInput.cs similarity index 97% rename from src/examples/Examples.Zod/TenantInput.cs rename to src/src/Examples.Zod/TenantInput.cs index fbce24f..72b9855 100644 --- a/src/examples/Examples.Zod/TenantInput.cs +++ b/src/src/Examples.Zod/TenantInput.cs @@ -18,7 +18,7 @@ namespace Purview.Results.Examples.Zod; /// /// [ZodSchema] -public sealed partial record TenantInput +sealed partial record TenantInput { /// The identifier of the new tenant. [Required] diff --git a/src/examples/Examples.Zod/TenantRegistrationService.cs b/src/src/Examples.Zod/TenantRegistrationService.cs similarity index 95% rename from src/examples/Examples.Zod/TenantRegistrationService.cs rename to src/src/Examples.Zod/TenantRegistrationService.cs index c65f64d..67ca2f3 100644 --- a/src/examples/Examples.Zod/TenantRegistrationService.cs +++ b/src/src/Examples.Zod/TenantRegistrationService.cs @@ -16,7 +16,7 @@ namespace Purview.Results.Examples.Zod; /// input is an expected outcome, so it is a value. /// /// -public sealed class TenantRegistrationService +sealed class TenantRegistrationService { readonly Dictionary _tenants = new() { @@ -53,6 +53,7 @@ public Result Register(string? tenantId, string? name) if (!validation.IsSuccess) return new TenantInputInvalid(input, validation.Errors).AsFailure(); + // The validated value is the value this method succeeds with, so the adapter maps the failure into the return RegisterValidated(validation.Value); } diff --git a/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md index 0f5c7e1..b11d454 100644 --- a/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md +++ b/src/src/SourceGenerator/Sdk/.agents/skills/purview-results-union-errors/SKILL.md @@ -92,6 +92,20 @@ The package ships an analyzer and a generator that share one diagnostics library Unsupported by design: `IUnionMembers` member providers (`RSG1007`) and generic unions (`RSG1002`). +## CA1815 needs no pragma + +A union declaration gets no value equality from the compiler, so it raises `CA1815` unless it is silenced by hand. +`[GenerateResult]` unions do not need that: the package ships a diagnostic suppressor that answers `CA1815` for +every union opted in with the attribute, because a result union is read by matching its case type rather than +compared by value. + +- Only the union is covered. A union without `[GenerateResult]`, the union's case types and every other value + type keep the warning, and a case declared as a plain `struct` should still be a `record struct`. +- The suppression is logged as an `Info` diagnostic under the suppression id `RSG2000`, so a build audit can see + exactly what was suppressed. +- `RSG2000` is a suppression id, not a rule: it never appears in the diagnostics table above, and + `$(NoWarn);RSG2000` brings `CA1815` back. + ## Requirements and switches - **.NET 11 SDK or later** with `LangVersion=preview` — union declarations are a preview language feature. diff --git a/src/src/SourceGenerator/Sdk/README.md b/src/src/SourceGenerator/Sdk/README.md index 299d42f..c21842e 100644 --- a/src/src/SourceGenerator/Sdk/README.md +++ b/src/src/SourceGenerator/Sdk/README.md @@ -110,6 +110,9 @@ needs no generated code. - **One analyzer, one generator, one diagnostics library.** `ResultsDiagnosticAnalyzer` raises the per-target rules and the generator shares the same analysis to decide whether generation can continue (see [Diagnostics](#diagnostics)). +- **One suppressor, with one narrow job.** `UnionEqualityDiagnosticSuppressor` answers `CA1815` for opted-in + unions (see [Diagnostic suppressions](#diagnostic-suppressions)). It reports no diagnostics of its own, so it + cannot hide a rule. - **Union membership is decided by the language** (`ITypeSymbol.IsUnion`), never by type names, source text, reflection or the union's runtime value. - **Case types come from the language's own rule**: a union's public single-parameter constructors define its @@ -152,6 +155,43 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + Case-level findings never block the union: the remaining cases are still generated, and the skipped case is reported. +## Diagnostic suppressions + +A union declaration gets no value equality from the compiler, so every union raises `CA1815` ("Override equals +and operator equals on value types") unless it is silenced by hand with a `#pragma warning disable CA1815` or a +`[SuppressMessage]`. A `[GenerateResult]` union is the error type of a result and is read by matching its case +type, not by comparing two unions by value, so the package answers that warning for you: + +| Suppression ID | Suppressed rule | Applies to | +| --- | --- | --- | +| `RSG2000` | `CA1815` | A declaration that is a union **and** is opted in with `[GenerateResult]` | + +The suppression is narrow on purpose: + +- A union without `[GenerateResult]` keeps the warning — this package only speaks for the unions it generates + for. +- The union's **case types** keep the warning, as does every other value type. A case declared as a plain + `struct` still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. +- Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a + suppression id rather than a rule, so `RSG1000`–`RSG1007` remain the only diagnostics this package reports. + +Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an +MSBuild binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited +for what it suppressed. + +**Keeping `CA1815`.** Add `RSG2000` to the compiler's warning suppressions, for example +`$(NoWarn);RSG2000` in the project file (or `/nowarn:RSG2000` on a compiler invocation). The +warning then returns for every union declaration, including the opted-in ones. + +```xml + + $(NoWarn);RSG2000 + +``` + +Declaring equality on the union is the other way to keep `CA1815` satisfied — it is the code the warning asks +for, and a union that has it never raises the warning in the first place, so the suppressor has nothing to do. + ## Build properties | Property | Default | Purpose | @@ -200,6 +240,12 @@ multiple unions, namespaces, accessibility, case kinds, diagnostics, incremental the compiler experiments above, and runtime/integration tests that execute the generated helpers against the real `Result` type. +The suppressor is covered by `UnionEqualityDiagnosticSuppressorTests`. The real `CA1815` comes from the .NET +analyzers the SDK loads, which a unit-test compilation cannot reference, so those tests report the same id at the +same location from a test-only analyzer (`Ca1815ReporterAnalyzer`) and run it beside the shipped suppressor +through `CompilationWithAnalyzers`. The compilation under test is still a real one: it is produced by running the +generator, so the union, the generated attribute and the generated helpers are all present. + The code-fix tests live in `UnionCaseResultCodeFixProviderTests` and use `UnionCodeFixTestHarness`: the framework's code-fix test base is driven by an analyzer's diagnostics, and this fix answers a compiler diagnostic, so the harness builds the compilation itself, runs the generator (the generated attribute and diff --git a/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs b/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs new file mode 100644 index 0000000..e26b65b --- /dev/null +++ b/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Results.SourceGeneration.Diagnostics; + +// The language's union support is a preview feature of C# 15; ITypeSymbol.IsUnion is marked +// [Experimental] until the feature ships, and this suppression deliberately targets it. +#pragma warning disable RSEXPERIMENTAL006 + +namespace Purview.Results.SourceGeneration.Suppressors; + +/// +/// Suppresses CA1815 (override Equals and the equality operators on value types) for the union +/// declarations this component generates for. +/// +/// +/// +/// A [GenerateResult] union is the error type of Result<TValue, TError> and is read by +/// matching its case type, never by comparing two unions by value, so the members CA1815 asks for are +/// not part of its contract. C# does not synthesise value equality for a union declaration either, so every +/// consumer would otherwise have to repeat a #pragma warning disable CA1815 (or a +/// [SuppressMessage]) beside the declaration to keep the build clean. +/// +/// +/// The suppression is deliberately narrow: it applies only where the reported declaration is a union +/// (, the language's own answer, exactly as the shared diagnostics library +/// uses) and is opted in with [GenerateResult]. A union without the attribute, and every +/// non-union value type, keeps the warning. A consumer who wants union equality declared adds the suppression +/// id to the build's warning suppressions (NoWarn), which brings CA1815 back for every union. +/// +/// +/// A suppressor can only suppress non-error, configurable diagnostics, and it never reports a diagnostic of +/// its own, so it cannot hide anything from the diagnostics table in the package README. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnionEqualityDiagnosticSuppressor : DiagnosticSuppressor +{ + /// + /// The suppression of CA1815 on an opted-in union declaration. + /// + /// + /// The id is this component's own namespace of suppression ids (the rules it reports are + /// RSG1000–RSG1007). It is not a rule: it is the id a consumer adds to NoWarn to turn + /// the suppression off, and the id every suppression is logged under for auditing. + /// + public static readonly SuppressionDescriptor Ca1815 = new( + id: "RSG2000", + suppressedDiagnosticId: "CA1815", + justification: "A union declaration opted in with [GenerateResult] is matched by its case type rather than compared by value, and C# does not synthesise value equality for a union declaration, so CA1815 asks for members the union's contract does not need." + ); + + /// + public override ImmutableArray SupportedSuppressions => [Ca1815]; + + /// + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (var diagnostic in context.ReportedDiagnostics) + { + // Only CA1815 is declared as suppressible, so this is the same bucket the descriptor names. + if (!string.Equals(diagnostic.Id, Ca1815.SuppressedDiagnosticId, StringComparison.Ordinal)) + continue; + + if (!IsOptedInUnionDeclaration(context, diagnostic)) + continue; + + context.ReportSuppression(Suppression.Create(Ca1815, diagnostic)); + } + } + + /// + /// Determines whether the reported diagnostic declares an opted-in union. + /// + /// The suppression context, which owns the compilation and semantic models. + /// The reported diagnostic, whose location points at the declaration. + /// when the declaration is a union opted in with [GenerateResult]. + /// + /// CA1815 is reported at the declared type's identifier, so the diagnostic's location is resolved to the + /// nearest enclosing type declaration and then to its symbol. The innermost declaration wins: a union that + /// nests a non-union type does not suppress the nested type's warning. + /// + static bool IsOptedInUnionDeclaration(SuppressionAnalysisContext context, Diagnostic diagnostic) + { + if (diagnostic.Location.SourceTree is not { } tree) + return false; + + var root = tree.GetRoot(context.CancellationToken); + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + + for (var current = node; current is not null; current = current.Parent) + { + if (current is not BaseTypeDeclarationSyntax declaration) + continue; + + return context.GetSemanticModel(tree).GetDeclaredSymbol(declaration, context.CancellationToken) + is INamedTypeSymbol { IsUnion: true } union + && ResultUnionDiagnostics.HasGenerateResultAttribute(union); + } + + return false; + } +} diff --git a/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs b/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs index c9faad6..43eae1b 100644 --- a/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs +++ b/src/src/ZodSharp.AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpResultsServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Purview.Results.AspNetCore; using Purview.Results.ZodSharp; @@ -14,35 +15,41 @@ public static class ZodSharpResultsServiceCollectionExtensions { /// /// Makes a result failure that carries ZodSharp validation errors produce an - /// HttpValidationProblemDetails response. + /// HttpValidationProblemDetails response, with optional rules per validation error code and + /// category. /// + /// + /// Configures which responses particular validation error codes and categories produce. Without it, every + /// failure that carries validation errors is rendered as one validation problem. + /// /// - /// The mapping is registered as a fallback, so a mapping the host registered for a specific error case always - /// wins. Pair it with AddZodSharpProblemDetails() (and AddResultsHttp()) so results and thrown + /// + /// The mapping is registered as a failure mapper, so a mapping the host registered for a specific error case + /// always wins, a mapping registered for the error type wins, and a failure mapper the host registered + /// before this call wins too — that is how a host answers some validation codes itself. + /// + /// + /// Pair it with AddZodSharpProblemDetails() (and AddResultsHttp()) so results and thrown /// exceptions produce identical responses. + /// /// /// The service collection for chaining. /// Thrown when is null. - public IServiceCollection AddResultsZodSharpHttp() + public IServiceCollection AddResultsZodSharpHttp(Action? configure = null) { ArgumentNullException.ThrowIfNull(services); // Registering the options keeps the mapping usable with defaults when a host maps results without the // ZodSharp exception handler; it is a no-op when that handler already registered them. services.AddOptions(); + services.AddOptions(); - services.Configure(options => - options.AddFallback( - (error, context) => - error is IValidationErrorCarrier carrier - ? ZodValidationProblems.ToProblem( - carrier.ValidationErrors, - context.RequestServices.GetRequiredService>().Value, - traceId: options.IncludeTraceId ? context.TraceIdentifier : null - ) - : null - ) - ); + if (configure is not null) + services.Configure(configure); + + services.TryAddSingleton(); + + services.Configure(options => options.AddFailureMapper()); return services; } diff --git a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md b/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md index 5820771..020cbf2 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md @@ -17,13 +17,38 @@ builder.Services.AddResultsZodSharpHttp(); // the bridge: validation-carryi ``` - `AddResultsZodSharpHttp()` registers the ZodSharp problem options (a no-op when the host already did) and adds - the mapping as a **`ResultsHttpOptions.AddFallback`**. -- Because it is a fallback, **a mapping the host registered for a specific error case always wins**, and a - mapping registered for the error type wins too. That is the intended escape hatch: when a case deserves its - own response, register it. + a **failure mapper** (`ZodResultsFailureMapper`) to the fallback stage. +- Because it is a failure mapper, **a mapping the host registered for a specific error case always wins**, a + mapping registered for the error type wins too, and a host failure mapper registered *before* + `AddResultsZodSharpHttp` wins as well. That is the escape hatch: when a case deserves its own response, register + it — and when a validation **code** deserves one, use the rules below. - Pair it with `AddZodSharpProblemDetails()` so a thrown `ZodException` and a result-carried rejection are rendered by the same mapper and therefore produce identical responses. +## Rules per validation error code and category + +Pass a callback to answer particular codes or categories with a response of their own: + +```csharp +builder.Services.AddResultsZodSharpHttp(options => options + .MapCode("tenant_not_found", StatusCodes.Status404NotFound) // validation problem, 404 default + .MapCode("tenant_id_matches_name", (errors, context) => TypedResults.Conflict()) + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) +); +``` + +Matching and precedence: + +1. every matching **code** rule in registration order, +2. then every matching **category** rule in registration order, +3. then the default validation problem — so rules only ever narrow what the host already gets. + +- A rule matches when **any** of the failure's errors carries its code/category, so a registered rule is always + reachable; a factory that wants stricter semantics returns `null` to decline and matching continues. +- A factory receives the failure's **whole error set**, so a rule never hides the other problems the caller has + to fix; the `int statusCode` overloads still render every error. +- A code or category registered twice with different behaviour throws at configuration time. + ## Rendering by hand | Member | Purpose | @@ -52,11 +77,11 @@ MVC problem-details writer. - **Do not reimplement error-to-problem mapping.** Reuse `ZodValidationProblems.ToProblem` so a carried failure and a thrown `ZodException` cannot drift apart in status code, title, `errors` shape or `issues` extension. - **Do not map the whole union to a validation problem** unless every case is a validation failure; map the - carrying case (or rely on the fallback, which already does exactly that). + carrying case (or rely on the mapper, which already does exactly that). - If a case does deserve its own response, still build it from `ToValidationProblem(context)` when the payload is validation data, so clients see one shape. -- The fallback receives the *case* value, so the carrier is the union case that implements the interface — not - the union itself. +- The mapper receives the *case* value, so the carrier is the union case that implements the interface — not + the union itself (`ResultsFailureContext.Error` carries the union when a rule needs it). ## Verifying the pairing diff --git a/src/src/ZodSharp.AspNetCore/Sdk/README.md b/src/src/ZodSharp.AspNetCore/Sdk/README.md index d50b5fd..e93b23e 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/README.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/README.md @@ -24,28 +24,67 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddZodSharpProblemDetails(); builder.Services.AddResultsHttp(); -builder.Services.AddResultsZodSharpHttp(); + +// Every validation-carrying failure becomes one validation problem, except the codes and categories that a +// rule answers with something else. +builder.Services.AddResultsZodSharpHttp(options => options + .MapCode("tenant_not_found", StatusCodes.Status404NotFound) + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) +); var app = builder.Build(); app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)).WithResultsHttp(); ``` -`AddResultsZodSharpHttp` registers the mapping as a **fallback**, so a mapping the host registered for a -specific error case always wins, and a mapping registered for the error type still wins over it. +`AddResultsZodSharpHttp` registers the mapping as a **failure mapper**, so a mapping the host registered for a +specific error case always wins, a mapping registered for the error type wins, and a host failure mapper +registered before it wins too. + +## Answering by validation error code or category + +| Member | Purpose | +| --- | --- | +| `MapCode(string code, int statusCode)` | Renders the validation problem with a different default status | +| `MapCode(string code, Func, HttpContext, IResult?>)` | Renders a response of your own | +| `MapCategory(string category, int statusCode)` | The same, for a category that spans many codes | +| `MapCategory(string category, Func, HttpContext, IResult?>)` | Renders a response of your own | + +A rule applies when **any** of the failure's errors carries its code or category, so a registered code rule is +always reachable whatever else the schema reported. Matching walks the **code** rules first, then the **category** +rules, each in registration order, and finally the default validation problem — a code is narrower than a +category, so it wins however the two were registered. A factory that wants stricter semantics returns `null` to +decline the failure, and matching continues: + +```csharp +options + // A code rule that only answers a failure whose every error is that code. + .MapCode("tenant_not_found", (errors, context) => + errors.All(error => error.Code == "tenant_not_found") + ? TypedResults.NotFound() + : null) + // A category rule that answers the failures the code rule declined. + .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); +``` + +A factory receives the failure's **full error set**, so a rule never hides the other problems the caller has to +fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code or +category registered twice with different behaviour is rejected at configuration time. ## API | Member | Purpose | | --- | --- | -| `AddResultsZodSharpHttp()` | Registers the ZodSharp options and adds the validation-problem fallback to `ResultsHttpOptions` | +| `AddResultsZodSharpHttp(Action? configure = null)` | Registers the ZodSharp options and adds the validation failure mapper to `ResultsHttpOptions` | +| `ZodResultsHttpOptions.MapCode` / `.MapCategory` | The per-code and per-category rules described above | +| `ZodResultsFailureMapper` | The mapper itself, for a host that wants to register or compose it by hand | | `IValidationErrorCarrier.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for the errors the error value carries | | `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | | `ZodValidationProblems.ToProblem(errors, options, defaultStatusCode = 400, traceId = null)` | The underlying mapper, for hosts that resolve the options themselves | The status code resolved by `ZodProblemDetailsOptions.StatusCodeSelector` wins; `statusCode` (or -`defaultStatusCode`) is only used when the resolved error type does not define one. The trace identifier is -included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). +`defaultStatusCode`, or a rule's `statusCode`) is only used when the resolved error type does not define one. The +trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). ## Examples diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs new file mode 100644 index 0000000..eade1d6 --- /dev/null +++ b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs @@ -0,0 +1,92 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Purview.Results.AspNetCore; +using ZodSharp.AspNetCore; +using ZodSharp.Core; + +namespace Purview.Results.ZodSharp.AspNetCore; + +/// +/// Renders a result failure that carries ZodSharp validation errors, honouring the code and category rules +/// registered on . +/// +/// +/// +/// The failure's errors are matched against the registered rules — codes first, then categories, each in +/// registration order — and the first rule that answers wins. A rule with a status code renders the standard +/// validation problem with that default status; a rule with a factory renders whatever the factory returns, and a +/// factory returning declines the failure so matching continues. When no rule answers, the +/// failure is rendered as the standard validation problem, so registering rules only ever narrows what a host +/// already gets. +/// +/// +/// A failure that does not carry validation errors is declined (), so other mappers and +/// fallbacks still apply. Everything is rendered by , the same +/// mapping a thrown ZodException uses, so a rule's response and the exception handler's response agree on +/// the status code, title, detail and issues extension. +/// +/// +public sealed class ZodResultsFailureMapper( + IOptions problemOptions, + IOptions rules, + IOptions results, + ILogger logger +) : IResultsFailureMapper +{ + readonly ZodProblemDetailsOptions _problemOptions = + problemOptions?.Value ?? throw new ArgumentNullException(nameof(problemOptions)); + readonly ZodResultsHttpOptions _rules = rules?.Value ?? throw new ArgumentNullException(nameof(rules)); + readonly ResultsHttpOptions _results = results?.Value ?? throw new ArgumentNullException(nameof(results)); + + /// + public IResult? Map(ResultsFailureContext context) + { + if (context.Case is not IValidationErrorCarrier carrier) + return null; + + var errors = carrier.ValidationErrors; + + foreach (var rule in _rules.Candidates(errors)) + { + // A factory declines a failure by returning null, and matching continues with the next rule. + if (rule.Behaviour.Map is { } map) + { + if (map(errors, context.HttpContext) is { } mapped) + return mapped; + + continue; + } + + return Render(errors, context, rule.Behaviour.StatusCode ?? StatusCodes.Status400BadRequest, rule); + } + + return Render(errors, context, StatusCodes.Status400BadRequest, rule: null); + } + + IResult Render( + ImmutableArray errors, + ResultsFailureContext context, + int statusCode, + ZodErrorRule? rule + ) + { + if (rule is not null && errors.Length > 1 && logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug( + "The validation error rule for '{Rule}' answered a failure carrying {ErrorCount} validation errors with status {StatusCode}.", + rule.Value, + errors.Length, + statusCode + ); + } + + return ZodValidationProblems.ToProblem( + errors, + _problemOptions, + statusCode, + _results.IncludeTraceId ? context.HttpContext.TraceIdentifier : null + ); + } +} diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs new file mode 100644 index 0000000..272389f --- /dev/null +++ b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs @@ -0,0 +1,195 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Http; +using ZodSharp.AspNetCore; +using ZodSharp.Core; + +namespace Purview.Results.ZodSharp.AspNetCore; + +/// +/// Configures how a result failure that carries ZodSharp validation errors is rendered, by validation error +/// code and by category. +/// +/// +/// +/// Without a rule, a failure that carries validation errors is rendered as one HttpValidationProblemDetails, +/// produced by the same mapping a thrown ZodException uses. A rule answers a failure whose errors include +/// its code (or category) with a response of its own, which is what lets one error code be a plain 404 +/// while the rest stay validation problems. +/// +/// +/// Rules are matched against the failure's whole error set, in this order: every code rule in registration +/// order, then every category rule in registration order, then the default validation problem. A rule +/// therefore applies when any of the failure's errors carries its code or category, so registering a code +/// rule guarantees it is reachable whatever else the schema reported. A factory that wants stricter semantics +/// returns to decline the failure, and matching continues. +/// +/// +/// A factory is handed the failure's full error set, so a rule never hides the other problems the caller has to +/// fix. Register one rule per code or category; registering the same value twice with different behaviour is +/// rejected at configuration time. +/// +/// +public sealed class ZodResultsHttpOptions +{ + readonly List _codeRules = []; + readonly List _categoryRules = []; + + /// + /// Renders a validation failure whose errors include as the standard validation + /// problem with a different default status code. + /// + /// The validation error code the rule applies to. + /// + /// The status code used when and the resolved error + /// type do not define one. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + public ZodResultsHttpOptions MapCode(string code, int statusCode) => AddCode(code, new(statusCode, null)); + + /// + /// Renders a validation failure whose errors include with a response of your own. + /// + /// The validation error code the rule applies to. + /// + /// Creates the response from the failure's validation errors, or returns to decline the + /// failure and let matching continue. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + /// Thrown when is null. + public ZodResultsHttpOptions MapCode(string code, Func, HttpContext, IResult?> map) + { + ArgumentNullException.ThrowIfNull(map); + + return AddCode(code, new(null, map)); + } + + /// + /// Renders a validation failure whose errors include the as the standard + /// validation problem with a different default status code. + /// + /// The validation error category the rule applies to. + /// + /// The status code used when and the resolved error + /// type do not define one. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + public ZodResultsHttpOptions MapCategory(string category, int statusCode) => + AddCategory(category, new(statusCode, null)); + + /// + /// Renders a validation failure whose errors include the with a response of your + /// own. + /// + /// The validation error category the rule applies to. + /// + /// Creates the response from the failure's validation errors, or returns to decline the + /// failure and let matching continue. + /// + /// The options instance for chaining. + /// + /// Thrown when is empty, or already has a rule with different behaviour. + /// + /// Thrown when is null. + public ZodResultsHttpOptions MapCategory( + string category, + Func, HttpContext, IResult?> map + ) + { + ArgumentNullException.ThrowIfNull(map); + + return AddCategory(category, new(null, map)); + } + + /// + /// Finds the rules that could answer a failure, in the order they are consulted. + /// + /// The validation errors the failure carries. + /// Every matching code rule in registration order, then every matching category rule. + internal IEnumerable Candidates(ImmutableArray errors) + { + foreach (var rule in Matches(_codeRules, errors, static error => error.Code)) + yield return rule; + + foreach (var rule in Matches(_categoryRules, errors, static error => error.Category)) + yield return rule; + } + + ZodResultsHttpOptions AddCode(string code, ZodErrorRuleBehaviour behaviour) => + Add(_codeRules, code, "code", behaviour); + + ZodResultsHttpOptions AddCategory(string category, ZodErrorRuleBehaviour behaviour) => + Add(_categoryRules, category, "category", behaviour); + + ZodResultsHttpOptions Add(List rules, string value, string kind, ZodErrorRuleBehaviour behaviour) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + foreach (var rule in rules) + { + if (!string.Equals(rule.Value, value, StringComparison.Ordinal)) + continue; + + // The same rule twice is idempotent; two different answers for one value are ambiguous. + if (rule.Behaviour == behaviour) + return this; + + // The same value with different behaviour is a configuration error. + throw new ArgumentException( + $"The validation error {kind} '{value}' already has a mapping. Register one rule per {kind}.", + nameof(value) + ); + } + + rules.Add(new ZodErrorRule(value, behaviour)); + + return this; + } + + /// + /// Finds the rules whose value any of the failure's errors carries, in registration order. + /// + static IEnumerable Matches( + List rules, + ImmutableArray errors, + Func select + ) + { + foreach (var rule in rules) + { + foreach (var error in errors) + { + if (!string.Equals(select(error), rule.Value, StringComparison.Ordinal)) + continue; + + yield return rule; + break; + } + } + } +} + +/// How a code or category rule answers the failures that mention it. +/// +/// The default status code of the validation problem, or when the rule supplies a factory. +/// +/// +/// The factory that creates the response, or when the rule renders a validation problem. +/// +readonly record struct ZodErrorRuleBehaviour( + int? StatusCode, + Func, HttpContext, IResult?>? Map +); + +/// One registered code or category rule. +/// The error code or category the rule applies to. +/// How the rule answers a failure that mentions the value. +sealed record ZodErrorRule(string Value, ZodErrorRuleBehaviour Behaviour); diff --git a/src/tests/AspNetCore.UnitTests/ResultsFailureMapperTests.cs b/src/tests/AspNetCore.UnitTests/ResultsFailureMapperTests.cs new file mode 100644 index 0000000..5396f00 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ResultsFailureMapperTests.cs @@ -0,0 +1,225 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Purview.Results.AspNetCore; + +/// +/// Tests for the failure-mapper extension point: a mapper registered with +/// answers a failure by its shape and shares the one +/// ordered fallback stage with . +/// +public sealed class ResultsFailureMapperTests +{ + [Test] + public async Task Map_GivenFailureMapper_ReturnsTheMappedResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFailureMapper() + ); + using var services = CreateServices(); + var context = ResultsHttpTestFactory.CreateContext(services); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemRejected(7).AsFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(response.Body).Contains("rejected 7"); + } + + [Test] + public async Task Map_GivenFailureMapperThatDefers_FallsThroughToTheNextFallback() + { + // Arrange + // The mapper declines a conflict, so the fallback registered after it answers instead. + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options + .AddFailureMapper() + .AddFallback((error, _) => error is ItemConflict ? TypedResults.Conflict() : null) + ); + using var services = CreateServices(); + var context = ResultsHttpTestFactory.CreateContext(services); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemConflict(7, "taken").AsFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + [Test] + public async Task Map_GivenCaseMappingAndFailureMapper_PrefersTheCaseMapping() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options + .Map(_ => TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed)) + .AddFailureMapper() + ); + using var services = CreateServices(); + var context = ResultsHttpTestFactory.CreateContext(services); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemRejected(7).AsFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status412PreconditionFailed); + } + + [Test] + public async Task Map_GivenUnionError_HandsTheMapperTheCaseAndTheError() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFailureMapper() + ); + using var services = CreateServices(); + var context = ResultsHttpTestFactory.CreateContext(services); + + // Act + await ResultsHttpTestFactory.ExecuteAsync(mapper.Map(new ItemNotFound(7).AsFailure(), context), context); + + // Assert + var observed = services.GetRequiredService().Observed; + await Assert.That(observed).IsNotNull(); + await Assert.That(observed!.Value.Case).IsTypeOf(); + await Assert.That(observed.Value.Error).IsTypeOf(); + } + + [Test] + public async Task Map_GivenFailureMapperWithDependencies_ResolvesThemFromRequestServices() + { + // Arrange + // The mapper takes its status code from a service, which is what registering a class instead of a + // fallback delegate buys a host. + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFailureMapper() + ); + using var services = CreateServices(collection => + collection.AddSingleton(new FailureMapperSettings(StatusCodes.Status410Gone)) + ); + var context = ResultsHttpTestFactory.CreateContext(services); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(new ItemRejected(7).AsFailure(), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status410Gone); + } + + [Test] + public async Task Map_GivenUnregisteredFailureMapper_ThrowsWithTheRegistrationToAdd() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFailureMapper() + ); + var context = ResultsHttpTestFactory.CreateContext(new ServiceCollection().BuildServiceProvider()); + + // Act + InvalidOperationException? exception = null; + try + { + mapper.Map(new ItemRejected(7).AsFailure(), context); + } + catch (InvalidOperationException caught) + { + exception = caught; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains(nameof(ItemIdentifierFailureMapper)); + await Assert.That(exception.Message).Contains("AddSingleton"); + } + + [Test] + public async Task Map_GivenSuccessfulResult_DoesNotConsultFailureMappers() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.AddFailureMapper() + ); + using var services = CreateServices(); + var context = ResultsHttpTestFactory.CreateContext(services); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Success(42), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status200OK); + await Assert.That(services.GetRequiredService().Observed).IsNull(); + } + + /// + /// Builds a provider that has the mapper registered, which is what the options' mapper resolution expects. + /// + static ServiceProvider CreateServices(Action? configure = null) + where TMapper : class + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(); + configure?.Invoke(services); + + return services.BuildServiceProvider(); + } +} + +/// Records the context a mapper was handed, so the shape of the failure can be asserted on. +public sealed class ShapeRecordingFailureMapper : IResultsFailureMapper +{ + /// Gets the context of the last failure the mapper saw. + public ResultsFailureContext? Observed { get; private set; } + + /// + public IResult? Map(ResultsFailureContext context) + { + Observed = context; + + return null; + } +} + +/// Turns a failure whose payload is an item identifier into 422. +public sealed class ItemIdentifierFailureMapper : IResultsFailureMapper +{ + /// + public IResult? Map(ResultsFailureContext context) => + context.Case switch + { + ItemRejected rejected => TypedResults.Problem( + statusCode: StatusCodes.Status422UnprocessableEntity, + title: $"rejected {rejected.ItemId}" + ), + _ => null, + }; +} + +/// Answers with a configured status code, which is why it has a dependency at all. +public sealed class ConfiguredFailureMapper(FailureMapperSettings settings) : IResultsFailureMapper +{ + /// + public IResult? Map(ResultsFailureContext context) => + context.Case is null ? null : TypedResults.StatusCode(settings.StatusCode); +} + +/// The setting reads. +/// The status code the mapper answers with. +public sealed record FailureMapperSettings(int StatusCode); diff --git a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs index 38c9855..7a4ac60 100644 --- a/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs +++ b/src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs @@ -2,7 +2,7 @@ namespace Purview.Results.AspNetCore; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types")] +// CA1815 is answered by the package's suppressor for every union opted in with [GenerateResult]. [GenerateResult] public readonly union HttpTestError(ItemNotFound, ItemConflict, ItemRejected); diff --git a/src/tests/Results.UnitTests/ResultExtensionsTests.cs b/src/tests/Results.UnitTests/ResultExtensionsTests.cs index 3529cb3..8063282 100644 --- a/src/tests/Results.UnitTests/ResultExtensionsTests.cs +++ b/src/tests/Results.UnitTests/ResultExtensionsTests.cs @@ -63,7 +63,7 @@ public async Task TryGetError_WhenSuccess_ShouldReturnFalse() var found = result.TryGetError(out var error); await Assert.That(found).IsFalse(); - await Assert.That(error).IsEqualTo(default(TestError)); + await Assert.That(error).IsEqualTo(default); } [Test] @@ -74,7 +74,7 @@ public async Task TryGetError_WhenUninitialized_ShouldReturnFalse() var found = result.TryGetError(out var error); await Assert.That(found).IsFalse(); - await Assert.That(error).IsEqualTo(default(TestError)); + await Assert.That(error).IsEqualTo(default); } #endregion diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index 1d56f77..eb5d102 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -26,9 +26,9 @@ namespace Purview.Results.SourceGenerator; public readonly record struct Tenant(TenantId TenantId); /// The errors a tenant operation can produce. -// The C# union declaration does not synthesise value equality, and these test types are compared by case -// in the tests, so CA1815 is not meaningful here. -#pragma warning disable CA1815 +/// +/// CA1815 is not raised for this declaration: the package's suppressor answers it for every union opted in +/// with [GenerateResult], so no pragma or suppress-message attribute is needed here. +/// [GenerateResult] public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); -#pragma warning restore CA1815 diff --git a/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs b/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs new file mode 100644 index 0000000..2268116 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/UnionEqualityDiagnosticSuppressorTests.cs @@ -0,0 +1,225 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Results.SourceGeneration; +using Purview.Results.SourceGeneration.Suppressors; + +namespace Purview.Results.SourceGenerator; + +/// +/// Tests for , the component that keeps CA1815 off +/// union declarations opted in with [GenerateResult]. +/// +/// +/// +/// The real CA1815 is reported by the .NET analyzers the SDK loads, which a unit-test compilation +/// cannot reference, so these tests report the same id at the same location +/// () and run it beside the suppressor through +/// CompilationWithAnalyzers. Everything else is real: the compilation is produced by running the +/// generator, so the union, the generated attribute and the generated helper class are all present, and the +/// suppressor under test is the shipped type. +/// +/// +/// The tests assert both halves of the contract: the suppressor removes CA1815 for an opted-in union, +/// and leaves it alone for a union that did not opt in, for the union's case types, and for any other value +/// type. +/// +/// +public class UnionEqualityDiagnosticSuppressorTests + : TUnitSourceGeneratorTestBase +{ + const string OptedInUnionSource = """ + namespace Test + { + public readonly record struct NotFound(int Id); + + [GenerateResult] + public readonly union TenantError(NotFound); + } + """; + + const string UnionWithoutOptInSource = """ + namespace Test + { + public readonly record struct NotFound(int Id); + + public readonly union TenantError(NotFound); + } + """; + + [Test] + public async Task SupportedSuppressions_GivenSuppressor_DeclaresOnlyTheCa1815Bucket() + { + // Arrange + // Act + UnionEqualityDiagnosticSuppressor suppressor = new(); + + // Assert + await Assert.That(suppressor.SupportedSuppressions.Length).IsEqualTo(1); + + var suppression = suppressor.SupportedSuppressions[0]; + + // The suppression id is this component's own bucket (the rules it reports are RSG1000-RSG1007), and it + // is the id a consumer adds to NoWarn to keep CA1815 instead. + await Assert.That(suppression.Id).IsEqualTo("RSG2000"); + await Assert.That(suppression.SuppressedDiagnosticId).IsEqualTo("CA1815"); + await Assert.That(suppression.Justification.ToString(CultureInfo.InvariantCulture)).IsNotEmpty(); + } + + [Test] + public async Task ReportSuppressions_GivenOptedInUnion_SuppressesCa1815OnTheUnionOnly( + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = await GenerateCompilationAsync(OptedInUnionSource, cancellationToken); + + // The reporter must actually raise the warning, otherwise the suppression below would pass vacuously. + var withoutSuppressor = await AnalyzeAsync(compilation, withSuppressor: false, cancellationToken); + + await Assert.That(Messages(withoutSuppressor, "TenantError")).IsEquivalentTo(["CA1815:suppressed=False"]); + + // Act + var withSuppressor = await AnalyzeAsync(compilation, withSuppressor: true, cancellationToken); + + // Assert + // The union's warning is reported and marked suppressed, while its case type - an ordinary value type - + // keeps the warning it would get without this component. + await Assert.That(Messages(withSuppressor, "TenantError")).IsEquivalentTo(["CA1815:suppressed=True"]); + await Assert.That(Messages(withSuppressor, "NotFound")).IsEquivalentTo(["CA1815:suppressed=False"]); + } + + [Test] + public async Task ReportSuppressions_GivenUnionWithoutTheOptInAttribute_LeavesCa1815Unsuppressed( + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = await GenerateCompilationAsync(UnionWithoutOptInSource, cancellationToken); + + // Act + var withSuppressor = await AnalyzeAsync(compilation, withSuppressor: true, cancellationToken); + + // Assert + // A union this component does not generate for is none of its business. + await Assert.That(Messages(withSuppressor, "TenantError")).IsEquivalentTo(["CA1815:suppressed=False"]); + } + + [Test] + public async Task ReportSuppressions_GivenNonUnionValueType_LeavesCa1815Unsuppressed( + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = await GenerateCompilationAsync(UnionWithoutOptInSource, cancellationToken); + + // Act + var withSuppressor = await AnalyzeAsync(compilation, withSuppressor: true, cancellationToken); + + // Assert + await Assert.That(Messages(withSuppressor, "NotFound")).IsEquivalentTo(["CA1815:suppressed=False"]); + } + + /// + /// Runs the generator so the compilation contains the generated attribute and helpers, and asserts it + /// compiles. + /// + async Task GenerateCompilationAsync(string source, CancellationToken cancellationToken) + { + var result = await GenerateAsync(source, cancellationToken); + + result.AssertNoCompilationErrors(); + + return result.CompilationResult.Compilation; + } + + /// + /// Runs the CA1815 reporter - and, optionally, the suppressor - over a compilation and returns every + /// diagnostic, including the suppressed ones. + /// + static async Task> AnalyzeAsync( + Compilation compilation, + bool withSuppressor, + CancellationToken cancellationToken + ) + { + ImmutableArray analyzers = withSuppressor + ? [new Ca1815ReporterAnalyzer(), new UnionEqualityDiagnosticSuppressor()] + : [new Ca1815ReporterAnalyzer()]; + + // ReportSuppressedDiagnostics is what makes the suppressor observable: a suppressed diagnostic is + // returned with IsSuppressed set instead of being filtered out. + CompilationWithAnalyzersOptions options = new( + new AnalyzerOptions([]), + onAnalyzerException: null, + concurrentAnalysis: true, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: true, + analyzerExceptionFilter: null + ); + + return await compilation.WithAnalyzers(analyzers, options).GetAllDiagnosticsAsync(cancellationToken); + } + + /// + /// Projects the CA1815 diagnostics the reporter raised for one type into an assertable shape, so a failure + /// shows which instance kept or lost the warning. + /// + static string[] Messages(ImmutableArray diagnostics, string typeName) => + [ + .. diagnostics + .Where(static diagnostic => diagnostic.Id == "CA1815") + // The generated extension class is named after the union, but generated code is not analyzed, so + // matching the quoted type name in the message keeps the type's own instance and nothing else. + .Where(diagnostic => + diagnostic + .GetMessage(CultureInfo.InvariantCulture) + .Contains($"'{typeName}'", StringComparison.Ordinal) + ) + .Select(static diagnostic => $"CA1815:suppressed={diagnostic.IsSuppressed}") + .Order(StringComparer.Ordinal), + ]; +} + +/// +/// Reports CA1815 at every named type declaration's location, standing in for the .NET analyzer that +/// owns the rule (see the test class remarks). +/// +// The stub deliberately reports the real rule's id, and it lives in a test assembly that references Workspaces +// and targets net11.0, which the compiler-extension authoring rules warn about. +#pragma warning disable RS1029, RS1036, RS1038, RS1041, RS2008 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class Ca1815ReporterAnalyzer : DiagnosticAnalyzer +{ + static readonly DiagnosticDescriptor Ca1815 = new( + id: "CA1815", + title: "Override equals and operator equals on value types", + messageFormat: "'{0}' should override Equals", + category: "Performance", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => [Ca1815]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction( + static symbolContext => + { + var symbol = (INamedTypeSymbol)symbolContext.Symbol; + + if (symbol.Locations.IsDefaultOrEmpty) + return; + + symbolContext.ReportDiagnostic(Diagnostic.Create(Ca1815, symbol.Locations[0], symbol.Name)); + }, + SymbolKind.NamedType + ); + } +} +#pragma warning restore RS1029, RS1036, RS1038, RS1041, RS2008 diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs index 59e2507..96a3d2a 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs @@ -4,7 +4,7 @@ namespace Purview.Results.ZodSharp.AspNetCore; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types")] +// CA1815 is answered by the package's suppressor for every union opted in with [GenerateResult]. [GenerateResult] public readonly union ValidationTestError(InputRejected, AggregateRejected, ItemMissing); diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs new file mode 100644 index 0000000..b5dae6f --- /dev/null +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs @@ -0,0 +1,387 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Purview.Results.AspNetCore; +using ZodSharp.Core; + +namespace Purview.Results.ZodSharp.AspNetCore; + +/// +/// Tests for and : the rules a host +/// registers per validation error code and category, and how they sit in the failure resolution order. +/// +public sealed class ZodResultsFailureMapperTests +{ + const string InvalidNameCode = "invalid_name"; + const string MissingCode = "missing"; + const string InvalidValueCategory = "invalid_value"; + + [Test] + public async Task Map_GivenCodeRuleWithStatus_ReturnsTheValidationProblemWithThatStatus() + { + // Arrange + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains(InvalidNameCode); + } + + [Test] + public async Task Map_GivenCodeRuleFactory_ReturnsThatFactoryResponse() + { + // Arrange + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, (_, _) => TypedResults.NotFound()) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status404NotFound); + } + + [Test] + public async Task Map_GivenCategoryRule_ReturnsTheValidationProblemWithThatStatus() + { + // Arrange + await using var services = CreateServices(zod => + zod.MapCategory(InvalidValueCategory, StatusCodes.Status409Conflict) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map( + new InputRejected(7, CreateCategorisedErrors(InvalidNameCode, InvalidValueCategory)).AsFailure(), + context + ), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(body).Contains(InvalidNameCode); + } + + [Test] + public async Task Map_GivenCodeAndCategoryRulesForTheSameFailure_PrefersTheCodeRule() + { + // Arrange + // A code is narrower than a category, so it wins however the host registered the two. + await using var services = CreateServices(zod => + zod.MapCategory(InvalidValueCategory, StatusCodes.Status409Conflict) + .MapCode(InvalidNameCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map( + new InputRejected(7, CreateCategorisedErrors(InvalidNameCode, InvalidValueCategory)).AsFailure(), + context + ), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + } + + [Test] + public async Task Map_GivenNoMatchingRule_ReturnsTheDefaultValidationProblem() + { + // Arrange + await using var services = CreateServices(zod => zod.MapCode("some_other_code", StatusCodes.Status409Conflict)); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(InvalidNameCode); + } + + [Test] + public async Task Map_GivenCodeRuleFactoryThatDeclines_FallsThroughToTheDefaultValidationProblem() + { + // Arrange + // A factory can tighten the match rule: here it only answers a failure whose every error is the code. + await using var services = CreateServices(zod => + zod.MapCode( + InvalidNameCode, + (errors, _) => errors.Length == 1 && errors[0].Code == InvalidNameCode ? TypedResults.NotFound() : null + ) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors(InvalidNameCode, MissingCode)).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(MissingCode); + } + + [Test] + public async Task Map_GivenDecliningCodeRuleAndMatchingCategoryRule_FallsThroughToTheCategoryRule() + { + // Arrange + // The strict code rule declines a mixed failure, so the category rule registered for a broader group + // answers it instead of the failure losing its validation response. + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, (errors, _) => errors.Length == 1 ? TypedResults.NotFound() : null) + .MapCategory(InvalidValueCategory, StatusCodes.Status409Conflict) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map( + new InputRejected( + 7, + CreateErrorsWithCategory(InvalidValueCategory, InvalidNameCode, MissingCode) + ).AsFailure(), + context + ), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(body).Contains(MissingCode); + } + + [Test] + public async Task Map_GivenMultipleErrorsAndAMatchingCodeRule_ListsEveryErrorInTheResponse() + { + // Arrange + // A rule matches when any error carries the code, and the response still carries the whole set, so a + // rule never hides the other problems the caller has to fix. + await using var services = CreateServices(zod => zod.MapCode(MissingCode, StatusCodes.Status409Conflict)); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors(MissingCode, InvalidNameCode)).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(body).Contains(MissingCode); + await Assert.That(body).Contains(InvalidNameCode); + } + + [Test] + public async Task Map_GivenCaseMappingAndCodeRule_PrefersTheCaseMapping() + { + // Arrange + await using var services = CreateServices( + zod => zod.MapCode(InvalidNameCode, StatusCodes.Status409Conflict), + results => results.Map(_ => TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed)) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status412PreconditionFailed); + } + + [Test] + public async Task Map_GivenHostFailureMapperRegisteredFirst_AnswersBeforeTheValidationMapping() + { + // Arrange + // A host mapper registered in AddResultsHttp runs before the ZodSharp mapping, which is how a host that + // does not want the code/category API answers validation carriers itself. + await using var services = CreateServices( + results: results => results.AddFailureMapper(), + configureServices: collection => collection.AddSingleton() + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status413PayloadTooLarge); + } + + [Test] + public async Task Map_GivenErrorThatIsNotAValidationCarrier_DeclinesAndReturnsTheUnmappedProblem() + { + // Arrange + await using var services = CreateServices(zod => zod.MapCode(InvalidNameCode, StatusCodes.Status409Conflict)); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync(mapper.Map(new ItemMissing(7).AsFailure(), context), context); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + await Assert.That(body).Contains(nameof(ItemMissing)); + } + + [Test] + public async Task MapCode_GivenEmptyCode_Throws() + { + // Arrange + ZodResultsHttpOptions options = new(); + + // Act + var exception = CaptureArgumentException(() => options.MapCode(string.Empty, StatusCodes.Status400BadRequest)); + + // Assert + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task MapCode_GivenTheSameCodeTwiceWithDifferentBehaviour_Throws() + { + // Arrange + // Two answers for one code are ambiguous, so the mistake is caught when the host configures it rather + // than per request. + ZodResultsHttpOptions options = new(); + options.MapCode(InvalidNameCode, StatusCodes.Status400BadRequest); + + // Act + var exception = CaptureArgumentException(() => options.MapCode(InvalidNameCode, StatusCodes.Status409Conflict)); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains(InvalidNameCode); + } + + [Test] + public async Task MapCode_GivenTheSameCodeTwiceWithTheSameBehaviour_KeepsTheSingleRule() + { + // Arrange + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, StatusCodes.Status409Conflict) + .MapCode(InvalidNameCode, StatusCodes.Status409Conflict) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + /// Builds the provider the tests resolve the mapper from. + static ServiceProvider CreateServices( + Action? zod = null, + Action? results = null, + Action? configureServices = null + ) + { + ServiceCollection services = new(); + services.AddLogging(); + configureServices?.Invoke(services); + services.AddResultsHttp(results); + services.AddResultsZodSharpHttp(zod); + + return services.BuildServiceProvider(); + } + + /// Creates a failure's errors, defaulting to the invalid name code. + static ImmutableArray CreateErrors(params string[] codes) => + [ + .. (codes.Length > 0 ? codes : [InvalidNameCode]).Select(static code => + ValidationError.Create(code, $"The {code} check failed.", ["Name"]) + ), + ]; + + /// Creates a failure's errors with a category, which is what a category rule looks for. + static ImmutableArray CreateCategorisedErrors(string code, string category) => + [ValidationError.Create(code, $"The {code} check failed.", ["Name"], category: category)]; + + /// Creates several errors that share one category. + static ImmutableArray CreateErrorsWithCategory(string category, params string[] codes) => + [ + .. codes.Select(code => + ValidationError.Create(code, $"The {code} check failed.", ["Name"], category: category) + ), + ]; + + /// Runs an action that must reject the host's configuration. + static ArgumentException? CaptureArgumentException(Action action) + { + try + { + action(); + return null; + } + catch (ArgumentException caught) + { + return caught; + } + } + + static DefaultHttpContext CreateContext(IServiceProvider services) => + new() { Response = { Body = new MemoryStream() }, RequestServices = services }; + + static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result, HttpContext context) + { + await result.ExecuteAsync(context); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using StreamReader reader = new(context.Response.Body); + var body = await reader.ReadToEndAsync(); + + return (context.Response.StatusCode, body); + } +} + +/// Answers any validation carrier itself, which is how a host overrides the ZodSharp mapping. +public sealed class HostValidationFailureMapper : IResultsFailureMapper +{ + /// + public IResult? Map(ResultsFailureContext context) => + context.Case is IValidationErrorCarrier ? TypedResults.StatusCode(StatusCodes.Status413PayloadTooLarge) : null; +} diff --git a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs index 994caf0..2e3249d 100644 --- a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs @@ -4,7 +4,7 @@ namespace Purview.Results.ZodSharp; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types")] +// CA1815 is answered by the package's suppressor for every union opted in with [GenerateResult]. [GenerateResult] public readonly union OperationError(OperationRejected, OperationResultInvalid); From 70b6bff3d54584a94e760a41fa1111c88e753d69 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 12:09:25 +0100 Subject: [PATCH 4/5] docs: added nuget badge to readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6939cb0..8ba3c5c 100644 --- a/README.md +++ b/README.md @@ -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` type, C# 15 union ergonomics @@ -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` and the `Result` factories. No dependencies. | `net11.0` | | [`Purview.Results.SourceGenerator`](src/src/SourceGenerator/Sdk/README.md) | Generates `AsFailure()` helpers for `[GenerateResult]` unions. | `netstandard2.0` | | [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net11.0` | @@ -261,4 +262,3 @@ to `main` runs the shared `Purview.Build` release pipeline, which packs, publish ## License MIT — see [LICENSE.md](LICENSE.md). - From 31588792f099eed45b9e8c2e542d5aab53cd6c27 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 13:29:39 +0100 Subject: [PATCH 5/5] style(csharpier): csharpier doesnt support union types properly --- .csharpierignore | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.csharpierignore b/.csharpierignore index db9f79f..b9178ab 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1,11 +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/examples/Examples.Basic/TenantError.cs -src/examples/Examples.Zod/TenantError.cs -src/examples/Examples.AspNetCore/TenantError.cs -src/examples/Examples.AspNetCore.Zod/TenantError.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