Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
<RoslynVersion>5.9.0</RoslynVersion>
<TUnitVersion>1.68.4</TUnitVersion>
<PurviewSGFVersion>1.0.0-prerelease.44</PurviewSGFVersion>
<PurviewZodSharpVersion>2.0.0-prerelease.9</PurviewZodSharpVersion>
<PurviewZodSharpVersion>2.0.0-prerelease.10</PurviewZodSharpVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Purview.SourceGeneratorFramework" Version="$(PurviewSGFVersion)" />
<PackageVersion Include="Purview.SourceGeneratorFramework.Testing.TUnit" Version="$(PurviewSGFVersion)" />
<PackageVersion Include="Purview.ZodSharp" Version="$(PurviewZodSharpVersion)" />
<PackageVersion Include="Purview.ZodSharp.AspNetCore" Version="$(PurviewZodSharpVersion)" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.401" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="$(RoslynVersion)" />
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/Getting-Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions docs/ZodSharp-Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>
{
["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:
Expand Down
1 change: 1 addition & 0 deletions src/ValueObjects.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Folder Name="/src/samples/">
<Project Path="src/Sample/Sample.csproj" />
<Project Path="src/ZodSharpSample/ZodSharpSample.csproj" />
<Project Path="src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ImmutableArray<AttributeData> 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(
Expand Down Expand Up @@ -77,6 +78,7 @@ ImmutableArray<AttributeData> 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(
Expand Down
24 changes: 24 additions & 0 deletions src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using ZodSharp.AspNetCore;

namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample;

/// <summary>
/// Error types registered in <see cref="ErrorTypeRegistry"/> that map validation error codes to
/// HTTP status codes and formatted messages. The bundled <c>ZODSASP001</c> analyzer verifies that
/// every <see cref="ErrorType.MessageFormat"/> placeholder is declared in <see cref="ErrorType.Parameters"/>.
/// </summary>
static class ConcurrentErrorType
{
/// <summary>
/// Maps the <c>aggregate_save_failed</c> code to a <c>409 Conflict</c> response.
/// </summary>
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"],
};
}
34 changes: 34 additions & 0 deletions src/src/ZodSharp.AspNetCoreSample/Models.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using Purview.ValueObjects.Serialization;
using ZodSharp;

namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample;

/// <summary>
/// A scalar value object validated by the ZodSharp-generated schema and deserialized in
/// <see cref="ValueObjectDeserializationMode.Strict"/> mode, so invalid JSON input throws a
/// <c>ZodException</c> that the registered exception handler converts to Problem Details.
/// </summary>
[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)]
[ZodSchema]
readonly partial record struct EmailAddress
{
[Required, EmailAddress, StringLength(254)]
public string Value { get; }
}

/// <summary>
/// A scalar value object validated by the ZodSharp-generated schema and deserialized strictly.
/// </summary>
[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)]
[ZodSchema]
readonly partial record struct OrderQuantity
{
[Range(1, 100)]
public int Value { get; }
}

/// <summary>
/// The request DTO bound by <c>POST /orders</c>. Its value object members are deserialized strictly.
/// </summary>
sealed record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity);
76 changes: 76 additions & 0 deletions src/src/ZodSharp.AspNetCoreSample/Program.cs
Original file line number Diff line number Diff line change
@@ -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<string, object?> { ["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<string, object?> { ["issues"] = problem.Extensions["issues"] }
);
}
}
);

app.Run();
12 changes: 12 additions & 0 deletions src/src/ZodSharp.AspNetCoreSample/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profiles": {
"ZodSharp.AspNetCoreSample": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:61096;http://localhost:61097"
}
}
}
60 changes: 60 additions & 0 deletions src/src/ZodSharp.AspNetCoreSample/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading