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
117 changes: 117 additions & 0 deletions docs/wiki/AspNetCore-Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,126 @@ public sealed class ValidationIssue
public bool? Inclusive { get; init; }
public required string[] Path { get; init; }
public required string Message { get; init; }
public IReadOnlyDictionary<string, object?>? Parameters { get; init; }
}
```

## Exception handling

A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a value object's generated
`Create` under strict deserialization) can be mapped to ProblemDetails on demand:

```csharp
using ZodSharp.AspNetCore;

try
{
var parsed = EmailAddressSchema.Parse(rawValue);
}
catch (ZodException ex)
{
return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors);
}
```

Or handled automatically by an `IExceptionHandler`. Register it and ensure `UseExceptionHandler()` is in
the pipeline:

```csharp
builder.Services.AddZodSharpProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();
```

`AddZodSharpProblemDetails(Action<ZodProblemDetailsOptions>? configure)` registers
`ZodExceptionHandler` and configures `ZodProblemDetailsOptions`:

- `ErrorTypeRegistry Registry` — resolves error codes to `ErrorType`s. Defaults to
`ErrorTypeRegistry.Default`.
- `bool FormatMessages` — when `true`, messages are formatted from the matched `ErrorType.MessageFormat`.
- `Func<ImmutableArray<ValidationError>, int>? StatusCodeSelector` — an escape hatch that takes complete
control of the response status code.

## Mapping error types to status codes

Register an `ErrorType` (code, description, HTTP status, and optional message template) in a registry,
then let the mapper derive the status code, title, detail, and formatted messages automatically:

```csharp
using ZodSharp.AspNetCore;

public static class ConcurrentErrorType
{
public static readonly ErrorType SaveFailed = new(
Code: "aggregate_save_failed",
Description: "The aggregate could not be saved.",
HttpStatus: StatusCodes.Status409Conflict,
MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save")
{
Parameters = ["AggregateId", "AggregateType"]
};
}

// Register once at startup:
ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
```

When an error carries that code, the response status, title, and message are derived automatically:

```csharp
throw new ZodException([
ValidationError.Create(
"aggregate_save_failed",
"The aggregate could not be saved.",
path: [],
parameters: new Dictionary<string, object?>
{
["AggregateId"] = "agg-123",
["AggregateType"] = "Invoice",
}),
]);
```

Produces a `409 Conflict` `HttpValidationProblemDetails` with:

```json
{
"status": 409,
"detail": "The aggregate could not be saved.",
"errors": {
"": ["Aggregate 'agg-123' (of type Invoice) failed to save"]
},
"traceId": "...",
"aggregateId": "agg-123",
"issues": [
{
"code": "aggregate_save_failed",
"path": [],
"message": "Aggregate 'agg-123' (of type Invoice) failed to save",
"parameters": { "AggregateId": "agg-123", "AggregateType": "Invoice" }
}
]
}
```

Mapping rules:

- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to the default
(`400`). Override with `ZodProblemDetailsOptions.StatusCodeSelector`.
- **Title / Type / Detail** — taken from the highest-status matched `ErrorType`; otherwise defaulted.
- **Message** — `MessageFormat` named placeholders (for example `{AggregateId}`) are substituted from
`ValidationError.Parameters`. Placeholders without a matching value are left as-is so templating gaps
stay visible. The analyzer `ZODSASP001` (bundled with the package) warns at compile time when a
`MessageFormat` placeholder is not declared in `Parameters`.
- **Parameters** — the error's `ValidationError.Parameters` are surfaced both per-issue in the `issues`
extension and merged (camel-cased) into the top-level ProblemDetails extensions for client correlation.

`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` accept an `ErrorTypeRegistry` or a
`Func<string, ErrorType?>` lookup for on-demand mapping, and the same mapping applies to
`ValidationResult<T>`.

## Dependency injection

```csharp
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
"version": "2.0.0-prerelease.9",
"version": "2.0.0-prerelease.10",
"private": true,
"license": "MIT",
"author": {
Expand Down Expand Up @@ -28,4 +28,4 @@
"bun": ">=1.4.2"
},
"packageManager": "bun@1.4.2"
}
}
1 change: 1 addition & 0 deletions purview-build.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"lib/net9.0/Purview.ZodSharp.AspNetCore.xml",
"lib/net8.0/Purview.ZodSharp.AspNetCore.dll",
"lib/net8.0/Purview.ZodSharp.AspNetCore.xml",
"analyzers/dotnet/cs/Purview.ZodSharp.AspNetCore.Analyzers.dll",
"README.md",
"purview-logo-light.png"
],
Expand Down
2 changes: 2 additions & 0 deletions src/ZodSharp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj" />
<Project Path="tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj" />
<Project Path="tests/NewtonsoftJson.UnitTests/NewtonsoftJson.UnitTests.csproj" />
<Project Path="tests/SourceGenerators.UnitTests/SourceGenerators.UnitTests.csproj" />
<Project Path="tests/SystemTextJson.UnitTests/SystemTextJson.UnitTests.csproj" />
<Project Path="tests/ZodSharp.UnitTests/ZodSharp.UnitTests.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/AspNetCore/AspNetCore.csproj" />
<Project Path="src/AspNetCore.Analyzers/AspNetCore.Analyzers.csproj" />
<Project Path="src/NewtonsoftJson/NewtonsoftJson.csproj" />
<Project Path="src/SourceGenerators/SourceGenerators.csproj" />
<Project Path="src/SystemTextJson/SystemTextJson.csproj" />
Expand Down
6 changes: 6 additions & 0 deletions src/src/AspNetCore.Analyzers/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## Release 1.0

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|------
5 changes: 5 additions & 0 deletions src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|------
ZODSASP001 | ZodSharp.AspNetCore | Warning | MessageFormat placeholder is not declared in Parameters
16 changes: 16 additions & 0 deletions src/src/AspNetCore.Analyzers/AspNetCore.Analyzers.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsRoslynComponent>true</IsRoslynComponent>
<AssemblyName>Purview.ZodSharp.AspNetCore.Analyzers</AssemblyName>
</PropertyGroup>

<ItemGroup Label="Analyzer Release Files">
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
</ItemGroup>
</Project>
197 changes: 197 additions & 0 deletions src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ZodSharp.AspNetCore.Analyzers;

/// <summary>
/// Reports when an <c>ErrorType.MessageFormat</c> placeholder is not declared in the
/// <c>ErrorType.Parameters</c> list, so message templating gaps are caught at compile time.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ErrorTypeMessageFormatAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// The diagnostic id for undeclared MessageFormat placeholders.
/// </summary>
public const string DiagnosticId = "ZODSASP001";

const string ErrorTypeMetadataName = "ZodSharp.AspNetCore.ErrorType";

static readonly Regex s_placeholderRegex = new(@"\{([A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled);

static readonly DiagnosticDescriptor s_descriptor = new(
id: DiagnosticId,
title: "MessageFormat placeholder is not declared in Parameters",
messageFormat: "MessageFormat placeholder '{0}' is not declared in ErrorType.Parameters",
category: "ZodSharp.AspNetCore",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [s_descriptor];

public override void Initialize(AnalysisContext context)
{
if (context is null)
throw new ArgumentNullException(nameof(context));

context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSyntaxNodeAction(
AnalyzeObjectCreation,
Microsoft.CodeAnalysis.CSharp.SyntaxKind.ObjectCreationExpression,
Microsoft.CodeAnalysis.CSharp.SyntaxKind.ImplicitObjectCreationExpression
);
}

static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context)
{
if (
context.SemanticModel.GetSymbolInfo(context.Node, context.CancellationToken).Symbol
is not IMethodSymbol ctor
)
return;

if (ctor.ContainingType?.ToDisplayString() != ErrorTypeMetadataName)
return;

var argumentList = context.Node switch
{
ObjectCreationExpressionSyntax objectCreation => objectCreation.ArgumentList,
ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.ArgumentList,
_ => null,
};

if (argumentList is null)
return;

var messageFormatExpression = FindMemberExpression(argumentList, ctor, "MessageFormat");
var messageFormat = GetConstantString(
context.SemanticModel,
messageFormatExpression,
context.CancellationToken
);
if (messageFormat is null)
return;

var declaredParameters = TryGetDeclaredParameters(
context.SemanticModel,
FindMemberExpression(argumentList, ctor, "Parameters"),
context.CancellationToken
);
if (declaredParameters is null)
return;

var placeholders = s_placeholderRegex
.Matches(messageFormat)
.Cast<Match>()
.Select(static m => m.Groups[1].Value)
.Distinct(StringComparer.Ordinal);

var location = messageFormatExpression!.GetLocation();
foreach (var placeholder in placeholders)
{
if (!declaredParameters.Contains(placeholder))
context.ReportDiagnostic(Diagnostic.Create(s_descriptor, location, placeholder));
}
}

static ExpressionSyntax? FindMemberExpression(
ArgumentListSyntax argumentList,
IMethodSymbol ctor,
string memberName
)
{
for (var i = 0; i < argumentList.Arguments.Count; i++)
{
var argument = argumentList.Arguments[i];
if (argument.NameColon is not null)
{
if (argument.NameColon.Name.Identifier.ValueText == memberName)
return argument.Expression;

continue;
}

if (i < ctor.Parameters.Length && ctor.Parameters[i].Name == memberName)
return argument.Expression;
}

var initializer = argumentList.Parent switch
{
ObjectCreationExpressionSyntax objectCreation => objectCreation.Initializer,
ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.Initializer,
_ => null,
};

if (initializer is null)
return null;

foreach (var element in initializer.Expressions)
{
if (
element is AssignmentExpressionSyntax assignment
&& assignment.Left is IdentifierNameSyntax identifier
&& identifier.Identifier.ValueText == memberName
)
{
return assignment.Right;
}
}

return null;
}

static string? GetConstantString(
SemanticModel semanticModel,
ExpressionSyntax? expression,
CancellationToken cancellationToken
)
{
if (expression is null)
return null;

var constant = semanticModel.GetConstantValue(expression, cancellationToken);
return constant.HasValue && constant.Value is string value ? value : null;
}

/// <summary>
/// Returns the declared parameter names, an empty set when the argument is omitted, or
/// <c>null</c> when the expression is present but not analyzable (analysis is skipped).
/// </summary>
static HashSet<string>? TryGetDeclaredParameters(
SemanticModel semanticModel,
ExpressionSyntax? expression,
CancellationToken cancellationToken
)
{
if (expression is null)
return [];

var elements = expression switch
{
CollectionExpressionSyntax collection => collection
.Elements.OfType<ExpressionElementSyntax>()
.Select(static element => element.Expression),
ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions,
ArrayCreationExpressionSyntax array => array.Initializer?.Expressions,
_ => null,
};

if (elements is null)
return null;

HashSet<string> names = new(StringComparer.Ordinal);
foreach (var element in elements)
{
if (GetConstantString(semanticModel, element, cancellationToken) is { } value)
names.Add(value);
}

return names;
}
}
Loading
Loading