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
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<RoslynVersion>5.9.0</RoslynVersion>
<TUnitVersion>1.68.4</TUnitVersion>
<PurviewSGFVersion>1.0.0-prerelease.44</PurviewSGFVersion>
<PurviewZodSharpVersion>2.0.0-prerelease.10</PurviewZodSharpVersion>
<PurviewZodSharpVersion>2.0.0-prerelease.11</PurviewZodSharpVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Purview.SourceGeneratorFramework" Version="$(PurviewSGFVersion)" />
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ modelBuilder
.HasColumnType("jsonb");
```

See the `samples/` folder for end-to-end examples and `docs/` for guidance.
See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples and `docs/` for guidance.

## Validation with ZodSharp

Expand Down Expand Up @@ -104,8 +104,8 @@ public readonly partial record struct EmailAddress
var result = EmailAddressSchema.Validate(EmailAddress.Create("demo@example.com"));
```

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).
See [ZodSharp Validation](docs/ZodSharp-Validation.md), the `src/src/ZodSharpSample` project, and
the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for strict deserialization failures).

## How it works

Expand Down
2 changes: 1 addition & 1 deletion docs/Entity-Framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@ using the generated `[JsonConverter]` (present by default) or the shared options
to support EF Core materialization.
- Value objects are immutable; EF tracks them by value like any struct/record.

See `samples/` for a runnable DTO + JSON-column example.
See `src/src/Sample` for a runnable DTO + JSON-column example.
6 changes: 3 additions & 3 deletions docs/Getting-Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,13 @@ constructed instance through `EmailAddressSchema` — `EmailAddress.Create("not-
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.
`HttpValidationProblemDetails` automatically. See the `src/src/ZodSharp.AspNetCoreSample` project.

See `ZodSharp-Validation.md` and the `samples/` folder.
See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project.

## Next steps

- `Entity-Framework.md` – mapping value objects to EF JSON columns.
- `Value-Object-Design.md` – where validation lives and the `Create`/`Hydrate` split.
- `ZodSharp-Validation.md` – validating value objects with Purview.ZodSharp.
- The `samples/` folder for runnable examples.
- The `src/src/Sample` and `src/src/ZodSharpSample` projects for runnable examples.
103 changes: 71 additions & 32 deletions docs/ZodSharp-Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[Zod](https://github.com/colinhacks/zod) schema validation library. It complements `Purview.ValueObjects`:
the value object owns the invariants, ZodSharp owns the rule definitions and validation results.

Three patterns are covered here, demonstrated in the `samples/` folder:
Three patterns are covered here, demonstrated in the `src/src/ZodSharpSample` project:

1. **Generator-integrated validation** — a value object annotated with both `[Scalar]`/`[ValueObject]`
and `[ZodSchema]` has its generated `Create` wired to the ZodSharp-generated schema.
Expand Down Expand Up @@ -110,7 +110,18 @@ public readonly partial record struct PhoneNumber
}
```

A custom schema class name (from ZodSharp's `[ZodSchema(SchemaName = "...")]`) is honored.
The `[ZodSchema]` attribute also exposes generator options that tune the emitted schema:

- `RefinementMethodName` — names a synchronous instance refinement method (default `Validate`) that the
generator runs after the DataAnnotations rules.
- `CustomValidationMethodName` — names a static async method that the generated validator's
`ValidateAsync` awaits after the synchronous rules pass (default `CustomValidationAsync`).
- `GenerateParseMethod` / `GenerateValidateMethod` / `EnableComposition` — toggle the emitted `Parse`,
`Validate`, and composition (`ApplyAnd`/`ApplyOr`/`ApplyRefine`) members.

> Note: `SchemaName` on `[ZodSchema]` is reserved by the attribute today but is not yet applied by the
> ZodSharp generator — the generated schema class is always named `{TypeName}Schema`. Use the default
> name when combining `[Scalar]`/`[ValueObject]` with `[ZodSchema]`.

## 3. Schema-first validation

Expand Down Expand Up @@ -156,7 +167,7 @@ Annotate a request/DTO class with `[ZodSchema]`, validate it, then map the valid
value objects:

```csharp
[ZodSchema]
[ZodSchema(RefinementMethodName = nameof(ValidateRegistration))]
public sealed class RegistrationDto
{
[Required, StringLength(100, MinimumLength = 2)]
Expand All @@ -168,8 +179,9 @@ public sealed class RegistrationDto
[Required, EmailAddress]
public string Email { get; init; } = string.Empty;

// Custom sync refinement: the generator runs these errors after the DataAnnotations rules.
public IEnumerable<ValidationError> Validate()
// Custom sync refinement, discovered via the RefinementMethodName option. The generator runs
// these errors after the DataAnnotations rules.
public IEnumerable<ValidationError> ValidateRegistration()
{
if (Name.StartsWith("x", StringComparison.OrdinalIgnoreCase))
yield return new ValidationError("name", "Name cannot start with 'x'.", [nameof(Name)]);
Expand All @@ -186,6 +198,34 @@ if (result.IsSuccess)
}
```

### Async custom validation

`CustomValidationMethodName` names a static async method with the signature
`static ValueTask<ValidationResult<T>> Method(T value, CancellationToken cancellationToken)`. The
generated `{Type}SchemaValidator` (which implements `IZodSchemaValidator<T>`) awaits it in its
`ValidateAsync` after the synchronous rules pass:

```csharp
[ZodSchema(CustomValidationMethodName = nameof(ValidatePromoCodeAsync))]
public sealed class PromoCode
{
[Required, RegularExpression(@"^[A-Z0-9]{4,10}$")]
public string Code { get; init; } = string.Empty;

internal static ValueTask<ValidationResult<PromoCode>> ValidatePromoCodeAsync(
PromoCode value, CancellationToken cancellationToken) =>
ValueTask.FromResult(
value.Code is "SAVE10" or "WELCOME20"
? ValidationResult<PromoCode>.Success(value)
: ValidationResult<PromoCode>.Failure(
new ValidationError("code", "Unknown promotional code.", [nameof(Code)]))
);
}

PromoCodeSchemaValidator validator = new();
var result = await validator.ValidateAsync(new PromoCode { Code = "HOMERUN42" });
```

## 5. Dependency injection and the schema factory

`ZodSchemaFactory` resolves validators by validated type. Register the generated adapter or wrap a
Expand Down Expand Up @@ -254,45 +294,44 @@ builder.Services.ConfigureHttpJsonOptions(options =>
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`:
Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`. Mark a
static partial class with `[ErrorType]` on a `static readonly ErrorType` field and the bundled
`ErrorTypeGenerator` emits `Create{Field}(...)` (builds a `ValidationError`) and `Throw{Field}(...)`
(a `void` + `[DoesNotReturn]` method that throws the `ZodException`):

```csharp
public static class ConcurrentErrorType
[ErrorType]
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")
{
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"]
};
}
Parameters = ["OrderId", "AggregateType"]
};

ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
ErrorTypeRegistry.Default.Register(ErrorTypes.SaveFailed);
```

Throwing a `ZodException` with that code and parameters yields a `409 Conflict` whose message is
formatted from the error's parameters:
The generated `ThrowSaveFailed(orderId, aggregateType)` throws a `ZodException` carrying the
`aggregate_save_failed` code, yielding a `409 Conflict` whose message is formatted from the error's
parameters. Because it is `void` + `[DoesNotReturn]`, use it as a terminal call — for example a `void`
minimal-API handler that always throws (the endpoint returns the mapped `409` via the exception
handler):

```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",
}),
]);
app.MapPost("/orders/{orderId}/confirm", ConfirmOrder);

static void ConfirmOrder(string orderId) => ErrorTypes.ThrowSaveFailed(orderId, "Order");
```

(If you prefer not to use the generator, construct the `ZodException` manually with
`ValidationError.Create(code, message, path: [], parameters: ...)` — it maps the same way.)

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.
`src/src/ZodSharp.AspNetCoreSample` project.

## JSON Schema export

Expand All @@ -310,6 +349,6 @@ that references the package, so `[ZodSchema]` is available there.

## See also

- The runnable `samples/ValueObjects.ZodSharpSample` project.
- The runnable `src/src/ZodSharpSample` project.
- [Getting Started](Getting-Started.md)
- [Value Object Design](Value-Object-Design.md)
2 changes: 1 addition & 1 deletion src/src/Sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Entity Framework JSON-column shape.
## Run

```text
dotnet run --project samples/ValueObjects.Sample
dotnet run --project src/src/Sample
```

## What it shows
Expand Down
2 changes: 1 addition & 1 deletion src/src/ValueObjects/Sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ modelBuilder
.HasColumnType("jsonb");
```

See the `samples/` folder for end-to-end examples.
See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples.
3 changes: 2 additions & 1 deletion src/src/ZodSharp.AspNetCoreSample/ErrorTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ namespace Purview.ValueObjects.ZodSharp.AspNetCoreSample;
/// 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
static partial class ErrorTypes
{
/// <summary>
/// Maps the <c>aggregate_save_failed</c> code to a <c>409 Conflict</c> response.
/// </summary>
[ErrorType]
public static readonly ErrorType SaveFailed = new(
Code: "aggregate_save_failed",
Description: "The order could not be saved because it was modified concurrently.",
Expand Down
15 changes: 4 additions & 11 deletions src/src/ZodSharp.AspNetCoreSample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
builder.Services.AddProblemDetails();

// Map error codes to HTTP statuses and formatted messages.
ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
ErrorTypeRegistry.Default.Register(ErrorTypes.SaveFailed);

var app = builder.Build();

Expand All @@ -36,18 +36,11 @@

// 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.
// parameters. ThrowSaveFailed is generated as void + [DoesNotReturn], so the endpoint is a void
// handler that always throws.
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" }
),
]);
static void ConfirmOrder(string orderId) => ErrorTypes.ThrowSaveFailed(orderId, "Order");

// Demonstrates on-demand mapping: a ZodException caught in the handler is converted explicitly
// with ErrorType resolution, without relying on the exception-handling middleware.
Expand Down
7 changes: 4 additions & 3 deletions src/src/ZodSharp.AspNetCoreSample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ dotnet run --project src/src/ZodSharp.AspNetCoreSample
- **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.
- **Mapping error types to status codes** — `ErrorTypes.SaveFailed` (a `[ErrorType]`-generated partial)
is registered in `ErrorTypeRegistry.Default`; `ErrorTypes.ThrowSaveFailed(...)` throws a `ZodException`
carrying the `aggregate_save_failed` code, which 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
Expand Down
21 changes: 21 additions & 0 deletions src/src/ZodSharpSample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
Console.WriteLine("== Generated DTO validation ==");
DtoValidation();

Console.WriteLine();
Console.WriteLine("== Async custom validation ==");
await AsyncCustomValidation();

Console.WriteLine();
Console.WriteLine("== DI / factory ==");
FactoryValidation();
Expand Down Expand Up @@ -141,6 +145,23 @@ static void DtoValidation()
Console.WriteLine($"Mapped -> {email.Value}, {money.Amount} {money.Currency.Value}");
}

static async Task AsyncCustomValidation()
{
// [ZodSchema(CustomValidationMethodName = ...)] names a static async validation method that the
// generated validator adapter awaits in its ValidateAsync after the synchronous rules pass.
PromoCodeSchemaValidator validator = new();

PromoCode known = new() { Code = "SAVE10" };
var knownResult = await validator.ValidateAsync(known, CancellationToken.None);
Console.WriteLine($"PromoCodeSchemaValidator.ValidateAsync('SAVE10') -> {knownResult.IsSuccess}");

PromoCode unknown = new() { Code = "HOMERUN42" };
var unknownResult = await validator.ValidateAsync(unknown, CancellationToken.None);
Console.WriteLine(
$"PromoCodeSchemaValidator.ValidateAsync('HOMERUN42') -> {unknownResult.IsSuccess}, {FormatErrors(unknownResult.Errors)}"
);
}

static void FactoryValidation()
{
ZodSchemaFactory factory = new();
Expand Down
36 changes: 36 additions & 0 deletions src/src/ZodSharpSample/PromoCode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using ZodSharp;
using ZodSharp.Core;

namespace Purview.ValueObjects.ZodSharpSample;

/// <summary>
/// A DTO validated by the source-generated <c>PromoCodeSchema</c>. The
/// <c>CustomValidationMethodName</c> option names a static async validation method that the generator
/// wires into the generated <c>ValidateAsync</c>, which awaits it after the synchronous DataAnnotations
/// rules pass.
/// </summary>
[ZodSchema(CustomValidationMethodName = nameof(ValidatePromoCodeAsync))]
sealed class PromoCode
{
[Required]
[RegularExpression(@"^[A-Z0-9]{4,10}$")]
public string Code { get; init; } = string.Empty;

internal static ValueTask<ValidationResult<PromoCode>> ValidatePromoCodeAsync(
PromoCode value,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();

var isKnownPromo = value.Code is "SAVE10" or "WELCOME20";
return ValueTask.FromResult(
isKnownPromo
? ValidationResult<PromoCode>.Success(value)
: ValidationResult<PromoCode>.Failure(
new ValidationError("code", "Unknown promotional code.", [nameof(Code)])
)
);
}
}
5 changes: 4 additions & 1 deletion src/src/ZodSharpSample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ dotnet run --project src/src/ZodSharpSample
`Z.Enum<OrderStatusKind>()`, `Z.Number().Positive()`) validate the raw underlying value, then the
result is mapped onto the value object via its strict `Create` factory.
- **DTO validation** — a `[ZodSchema]` `RegistrationDto` validated by the generated schema, including
a custom `Validate()` refinement method, then mapped to value objects.
a custom refinement method wired via the `RefinementMethodName` option, then mapped to value objects.
- **Async custom validation** — a `[ZodSchema(CustomValidationMethodName = ...)]` `PromoCode` whose
generated `PromoCodeSchemaValidator.ValidateAsync` awaits a hand-written async rule after the
synchronous DataAnnotations rules pass.
- **DI / factory** — `ZodSchemaFactory` resolving both the generated `EmailAddressSchemaValidator`
and a hand-built `ZodSchemaValidator<string>`.

Expand Down
Loading
Loading