diff --git a/Directory.Packages.props b/Directory.Packages.props index 632e6d8..0530aef 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,12 +7,13 @@ 5.9.0 1.68.4 1.0.0-prerelease.44 - 2.0.0-prerelease.9 + 2.0.0-prerelease.10 + diff --git a/README.md b/README.md index 59ea8a6..6186414 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ public readonly partial record struct EmailAddress var result = EmailAddressSchema.Validate(EmailAddress.Create("demo@example.com")); ``` -See [ZodSharp Validation](docs/ZodSharp-Validation.md) and the `samples/ValueObjects.ZodSharpSample` project. +See [ZodSharp Validation](docs/ZodSharp-Validation.md), the `samples/ValueObjects.ZodSharpSample` project, and +the `src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for strict deserialization failures). ## How it works diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 2f6bde9..0459c1a 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -173,6 +173,11 @@ constructed instance through `EmailAddressSchema` — `EmailAddress.Create("not- `ZodException`. Use `ZodSchemaMode.InsteadOfHooks` on the attribute to run the schema instead of the `OnValidate` hook. +In ASP.NET Core, `Purview.ZodSharp.AspNetCore` converts those `ZodException`s into standard Problem +Details responses — combine `ValueObjectDeserializationMode.Strict` with +`AddZodSharpProblemDetails()` + `UseExceptionHandler()` so invalid request bodies return +`HttpValidationProblemDetails` automatically. See the `src/ZodSharp.AspNetCoreSample` project. + See `ZodSharp-Validation.md` and the `samples/` folder. ## Next steps diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index b4fe34f..49cd4d2 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -216,6 +216,84 @@ foreach (var error in result.Errors) Use `Parse` / `GetValueOrThrow()` to throw a `ZodException` on failure instead of inspecting the result. +### ASP.NET Core Problem Details + +In ASP.NET Core, `Purview.ZodSharp.AspNetCore` maps thrown `ZodException`s to standard +`HttpValidationProblemDetails` responses. This covers strict deserialization of value objects +(`ValueObjectDeserializationMode.Strict`) and any `Create`/`Parse` failure that bubbles up as a +`ZodException`. + +Wire the handler into the pipeline: + +```csharp +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +Mark the value object for strict deserialization and ZodSharp validation so invalid request bodies throw +during model binding: + +```csharp +[Scalar( + ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, + DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required, EmailAddress] + public string Value { get; } +} + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +``` + +A `POST` body with an invalid email now returns `400 application/problem+json` with the structured issues +in the `issues` extension. + +Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`: + +```csharp +public static class ConcurrentErrorType +{ + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Description: "The order could not be saved because it was modified concurrently.", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save") + { + Parameters = ["OrderId", "AggregateType"] + }; +} + +ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +``` + +Throwing a `ZodException` with that code and parameters yields a `409 Conflict` whose message is +formatted from the error's parameters: + +```csharp +throw new ZodException([ + ValidationError.Create( + "aggregate_save_failed", + "The order could not be saved.", + path: [], + parameters: new Dictionary + { + ["OrderId"] = orderId, + ["AggregateType"] = "Order", + }), +]); +``` + +The bundled `ZODSASP001` analyzer flags `MessageFormat` placeholders missing from `Parameters` at +compile time. See the +[ASP.NET Core integration](https://purview.dev/docs/zodsharp/aspnetcore-integration/) guide and the +`src/ZodSharp.AspNetCoreSample` project. + ## JSON Schema export Export a schema to JSON Schema (Draft 2020-12) for cross-platform sharing with TypeScript Zod: diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx index aef2e03..93c1977 100644 --- a/src/ValueObjects.slnx +++ b/src/ValueObjects.slnx @@ -17,6 +17,7 @@ + diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs index 4a8f631..90baff7 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -27,8 +27,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // short-circuits instead of re-executing every value-object transform (see PreCompilationMarker). #pragma warning disable RSEXPERIMENTAL007 // Pre-compilation source output is intentionally used to stabilize the incremental cache. context.RegisterPreCompilationSourceOutput( - Common.PreCompilationMarker.Provider(context), - static (spc, source) => spc.AddSource(Common.PreCompilationMarker.HintName, source) + PreCompilationMarker.Provider(context), + static (spc, source) => spc.AddSource(PreCompilationMarker.HintName, source) ); #pragma warning restore RSEXPERIMENTAL007 diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index 290218d..456d3f4 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -16,6 +16,7 @@ ImmutableArray attributes if (!assemblyDefaults.Exists) return typeOptions; + // Merge assembly defaults into the type options, but only for properties that are not explicitly set on the type. return typeOptions with { GenerateJsonConverter = MergeBool( @@ -77,6 +78,7 @@ ImmutableArray attributes if (!assemblyDefaults.Exists) return typeOptions; + // Merge assembly defaults into the type options, but only for properties that are not explicitly set on the type. return typeOptions with { GenerateJsonConverter = MergeBool( diff --git a/src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs b/src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs new file mode 100644 index 0000000..3629aa5 --- /dev/null +++ b/src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs @@ -0,0 +1,24 @@ +using ZodSharp.AspNetCore; + +namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample; + +/// +/// Error types registered in that map validation error codes to +/// HTTP status codes and formatted messages. The bundled ZODSASP001 analyzer verifies that +/// every placeholder is declared in . +/// +static class ConcurrentErrorType +{ + /// + /// Maps the aggregate_save_failed code to a 409 Conflict response. + /// + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Description: "The order could not be saved because it was modified concurrently.", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = ["OrderId", "AggregateType"], + }; +} diff --git a/src/src/ZodSharp.AspNetCoreSample/Models.cs b/src/src/ZodSharp.AspNetCoreSample/Models.cs new file mode 100644 index 0000000..6ef3f68 --- /dev/null +++ b/src/src/ZodSharp.AspNetCoreSample/Models.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects.Serialization; +using ZodSharp; + +namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample; + +/// +/// A scalar value object validated by the ZodSharp-generated schema and deserialized in +/// mode, so invalid JSON input throws a +/// ZodException that the registered exception handler converts to Problem Details. +/// +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +readonly partial record struct EmailAddress +{ + [Required, EmailAddress, StringLength(254)] + public string Value { get; } +} + +/// +/// A scalar value object validated by the ZodSharp-generated schema and deserialized strictly. +/// +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +readonly partial record struct OrderQuantity +{ + [Range(1, 100)] + public int Value { get; } +} + +/// +/// The request DTO bound by POST /orders. Its value object members are deserialized strictly. +/// +sealed record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); diff --git a/src/src/ZodSharp.AspNetCoreSample/Program.cs b/src/src/ZodSharp.AspNetCoreSample/Program.cs new file mode 100644 index 0000000..14c05c5 --- /dev/null +++ b/src/src/ZodSharp.AspNetCoreSample/Program.cs @@ -0,0 +1,76 @@ +using Purview.ValueObjects.Serialization; +using ZodSharp.AspNetCore; +using ZodSharp.Core; + +var builder = WebApplication.CreateBuilder(args); + +// Register the ScalarJsonConverterFactory so value objects serialize as their scalar value and +// deserialize through the configured ValueObjectDeserializationMode (Strict here). +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory()) +); + +// Registers ZodExceptionHandler as an IExceptionHandler. Requires UseExceptionHandler() in the +// pipeline (added below), otherwise the handler is never invoked. +builder.Services.AddZodSharpProblemDetails(); + +// Required by the parameterless UseExceptionHandler() for its default fallback response when no +// registered handler matches the exception. +builder.Services.AddProblemDetails(); + +// Map error codes to HTTP statuses and formatted messages. +ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); + +var app = builder.Build(); + +app.UseExceptionHandler(); + +// Binds a request containing strict scalar value objects. Invalid JSON (for example an invalid +// email or a quantity outside 1-100) throws a ZodException during deserialization, which the +// exception handler turns into a 400 HttpValidationProblemDetails response. +app.MapPost( + "/orders", + (PlaceOrderRequest request) => + Results.Ok(new { Email = request.CustomerEmail.Value, Quantity = request.Quantity.Value }) +); + +// Throws a ZodException carrying a registered error code. The handler resolves the ErrorType from +// the registry and returns a 409 Conflict response whose message is formatted from the error's +// parameters. +app.MapPost("/orders/{orderId}/confirm", ConfirmOrder); + +static IResult ConfirmOrder(string orderId) => + throw new ZodException([ + ValidationError.Create( + "aggregate_save_failed", + "The order could not be saved.", + path: [], + parameters: new Dictionary { ["OrderId"] = orderId, ["AggregateType"] = "Order" } + ), + ]); + +// Demonstrates on-demand mapping: a ZodException caught in the handler is converted explicitly +// with ErrorType resolution, without relying on the exception-handling middleware. +app.MapGet( + "/orders/{orderId}/email", + (string orderId, string email) => + { + try + { + var parsed = EmailAddress.Create(email); + return Results.Ok(new { Email = parsed.Value, OrderId = orderId }); + } + catch (ZodException ex) + { + var problem = ex.ToHttpValidationProblemDetails(ErrorTypeRegistry.Default); + return Results.Problem( + problem.Detail, + statusCode: problem.Status, + title: problem.Title, + extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] } + ); + } + } +); + +app.Run(); diff --git a/src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json b/src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json new file mode 100644 index 0000000..17a974f --- /dev/null +++ b/src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ZodSharp.AspNetCoreSample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61096;http://localhost:61097" + } + } +} \ No newline at end of file diff --git a/src/src/ZodSharp.AspNetCoreSample/README.md b/src/src/ZodSharp.AspNetCoreSample/README.md new file mode 100644 index 0000000..ad55fbe --- /dev/null +++ b/src/src/ZodSharp.AspNetCoreSample/README.md @@ -0,0 +1,60 @@ +# Purview.ValueObjects + ZodSharp ASP.NET Core Sample + +Shows how value objects validated by [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) +surface as standard ASP.NET Core `Problem Details` responses using +[Purview.ZodSharp.AspNetCore](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore). + +## Run + +```text +dotnet run --project src/src/ZodSharp.AspNetCoreSample +``` + +## What it shows + +- **Strict deserialization of value objects** — `EmailAddress` and `OrderQuantity` are annotated with + `[Scalar]` + `[ZodSchema]` and `ValueObjectDeserializationMode.Strict`. Their generated `Create` runs + the ZodSharp-generated schema, so deserializing an invalid value throws a `ZodException`. +- **Automatic exception handling** — `AddZodSharpProblemDetails()` registers `ZodExceptionHandler` and + `app.UseExceptionHandler()` catches the thrown `ZodException`, converting it to a + `HttpValidationProblemDetails` response with the structured issues in the `issues` extension. +- **Mapping error types to status codes** — `ConcurrentErrorType.SaveFailed` is registered in + `ErrorTypeRegistry.Default`; a `ZodException` carrying the `aggregate_save_failed` code is returned as + a `409 Conflict` with a message formatted from the error's parameters. +- **On-demand mapping** — `ZodException.ToHttpValidationProblemDetails(...)` converts an exception + explicitly, without the exception-handling middleware. +- **Compile-time placeholder checking** — the `ZODSASP001` analyzer (bundled with the package) verifies + that every `MessageFormat` placeholder is declared in `ErrorType.Parameters`. + +## Try it + +Place an order with a valid body: + +```bash +curl -i -X POST http://localhost:5000/orders \ + -H "Content-Type: application/json" \ + -d '{"customerEmail":"demo@example.com","quantity":2}' +``` + +Send an invalid email — the strict value object throws and the handler returns a 400 Problem Details +payload: + +```bash +curl -i -X POST http://localhost:5000/orders \ + -H "Content-Type: application/json" \ + -d '{"customerEmail":"not-an-email","quantity":2}' +``` + +Simulate an optimistic-concurrency failure (returns `409 Conflict` with a formatted message): + +```bash +curl -i -X POST http://localhost:5000/orders/ord-123/confirm +``` + +Validate an email on demand: + +```bash +curl -i "http://localhost:5000/orders/ord-123/email?email=not-an-email" +``` + +See `docs/ZodSharp-Validation.md` for the full guide. \ No newline at end of file diff --git a/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj new file mode 100644 index 0000000..d113306 --- /dev/null +++ b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj @@ -0,0 +1,30 @@ + + + false + + $(NoWarn);CA1506 + + + + + + + + + + + + + + diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs index 32286ad..5d1b58b 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectDefaultsGeneratorTests.cs @@ -1,5 +1,3 @@ -using System.Reflection; - namespace Purview.ValueObjects.SourceGenerator.Generators; ///