diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 9684594..aee8417 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -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? 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? 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, 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 + { + ["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` lookup for on-demand mapping, and the same mapping applies to +`ValidationResult`. + ## Dependency injection ```csharp diff --git a/package.json b/package.json index b38f4d1..5a278d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.9", + "version": "2.0.0-prerelease.10", "private": true, "license": "MIT", "author": { @@ -28,4 +28,4 @@ "bun": ">=1.4.2" }, "packageManager": "bun@1.4.2" -} +} \ No newline at end of file diff --git a/purview-build.json b/purview-build.json index c238151..c898ea7 100644 --- a/purview-build.json +++ b/purview-build.json @@ -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" ], diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 4cd45bc..6ad49cd 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -13,6 +13,7 @@ + @@ -20,6 +21,7 @@ + diff --git a/src/src/AspNetCore.Analyzers/AnalyzerReleases.Shipped.md b/src/src/AspNetCore.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..bc0f94d --- /dev/null +++ b/src/src/AspNetCore.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ \ No newline at end of file diff --git a/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..60bb0d0 --- /dev/null +++ b/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,5 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +ZODSASP001 | ZodSharp.AspNetCore | Warning | MessageFormat placeholder is not declared in Parameters \ No newline at end of file diff --git a/src/src/AspNetCore.Analyzers/AspNetCore.Analyzers.csproj b/src/src/AspNetCore.Analyzers/AspNetCore.Analyzers.csproj new file mode 100644 index 0000000..5d1f085 --- /dev/null +++ b/src/src/AspNetCore.Analyzers/AspNetCore.Analyzers.csproj @@ -0,0 +1,16 @@ + + + true + Purview.ZodSharp.AspNetCore.Analyzers + + + + + + + + + + + + diff --git a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs new file mode 100644 index 0000000..2b25902 --- /dev/null +++ b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs @@ -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; + +/// +/// Reports when an ErrorType.MessageFormat placeholder is not declared in the +/// ErrorType.Parameters list, so message templating gaps are caught at compile time. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ErrorTypeMessageFormatAnalyzer : DiagnosticAnalyzer +{ + /// + /// The diagnostic id for undeclared MessageFormat placeholders. + /// + 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 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() + .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; + } + + /// + /// Returns the declared parameter names, an empty set when the argument is omitted, or + /// null when the expression is present but not analyzable (analysis is skipped). + /// + static HashSet? TryGetDeclaredParameters( + SemanticModel semanticModel, + ExpressionSyntax? expression, + CancellationToken cancellationToken + ) + { + if (expression is null) + return []; + + var elements = expression switch + { + CollectionExpressionSyntax collection => collection + .Elements.OfType() + .Select(static element => element.Expression), + ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions, + ArrayCreationExpressionSyntax array => array.Initializer?.Expressions, + _ => null, + }; + + if (elements is null) + return null; + + HashSet names = new(StringComparer.Ordinal); + foreach (var element in elements) + { + if (GetConstantString(semanticModel, element, cancellationToken) is { } value) + names.Add(value); + } + + return names; + } +} diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 5061190..f845917 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -13,4 +13,13 @@ + + + + diff --git a/src/src/AspNetCore/ErrorType.cs b/src/src/AspNetCore/ErrorType.cs new file mode 100644 index 0000000..1ceb8f5 --- /dev/null +++ b/src/src/AspNetCore/ErrorType.cs @@ -0,0 +1,50 @@ +namespace ZodSharp.AspNetCore; + +/// +/// Defines a user-facing error type that maps a validation error code to an HTTP status code, +/// optional title/type metadata, and an optional message template whose named placeholders are +/// substituted from the error's . +/// +/// The validation error code this error type maps to. +/// An optional human-readable description of the error. +/// +/// The HTTP status code returned when an error with this code is surfaced. Defaults to +/// 400 Bad Request. +/// +/// An optional title used on the ProblemDetails payload. +/// An optional RFC 7807 type URI used on the ProblemDetails payload. +/// +/// An optional message template with named placeholders (for example +/// "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save"). Placeholders are +/// substituted from ; placeholders without a +/// matching value are left as-is so templating gaps stay visible. +/// +/// +/// +/// 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"] +/// }; +/// } +/// +/// +public sealed record ErrorType( + string Code, + string? Description = null, + int HttpStatus = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest, + string? Title = null, + string? Type = null, + string? MessageFormat = null +) +{ + /// + /// The named placeholders expected by (for example "AggregateId"). + /// + public IReadOnlyList Parameters { get; init; } = []; +} diff --git a/src/src/AspNetCore/ErrorTypeRegistry.cs b/src/src/AspNetCore/ErrorTypeRegistry.cs new file mode 100644 index 0000000..34cc932 --- /dev/null +++ b/src/src/AspNetCore/ErrorTypeRegistry.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; + +namespace ZodSharp.AspNetCore; + +/// +/// A code-keyed registry of definitions that allows users to register +/// error types once and resolve them by when +/// building ProblemDetails responses. +/// +public sealed class ErrorTypeRegistry +{ + /// + /// The process-wide default registry. Register application error types here to make them + /// available to by default. + /// + public static ErrorTypeRegistry Default { get; } = new(); + + readonly ConcurrentDictionary _byCode = new(StringComparer.Ordinal); + + /// + /// Registers an keyed by its . + /// + /// + /// Thrown when an error type with the same code is already registered. + /// + public void Register(ErrorType errorType) + { + ArgumentNullException.ThrowIfNull(errorType); + + if (!_byCode.TryAdd(errorType.Code, errorType)) + throw new InvalidOperationException($"An error type with code '{errorType.Code}' is already registered."); + } + + /// + /// Registers a sequence of definitions. + /// + public void Register(IEnumerable errorTypes) + { + ArgumentNullException.ThrowIfNull(errorTypes); + + foreach (var errorType in errorTypes) + Register(errorType); + } + + /// + /// Attempts to resolve the registered for the given code. + /// + public bool TryGet(string code, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ErrorType? errorType) => + _byCode.TryGetValue(code, out errorType); + + /// + /// Removes the registered for the given code. + /// + public bool Remove(string code) => _byCode.TryRemove(code, out _); + + /// + /// All registered definitions. + /// + public ICollection All => _byCode.Values; +} diff --git a/src/src/AspNetCore/ProblemDetailsExtensions.cs b/src/src/AspNetCore/ProblemDetailsExtensions.cs index caf338f..437c0e8 100644 --- a/src/src/AspNetCore/ProblemDetailsExtensions.cs +++ b/src/src/AspNetCore/ProblemDetailsExtensions.cs @@ -20,46 +20,42 @@ public static HttpValidationProblemDetails ToHttpValidationProblemDetails( int statusCode = StatusCodes.Status400BadRequest ) { - if (result.IsSuccess) - throw new InvalidOperationException("Cannot create ProblemDetails from a successful validation result."); - - Dictionary errors = new(StringComparer.Ordinal); - Dictionary> groupedMessages = new(StringComparer.Ordinal); - - foreach (var error in result.Errors) - { - var key = ToProblemDetailsKey(error.Path); - if (!groupedMessages.TryGetValue(key, out var messages)) - { - messages = []; - groupedMessages[key] = messages; - } - - messages.Add(error.Message); - } + EnsureFailed(result); + return ProblemDetailsMapper.Build( + result.Errors, + statusCode, + registry: null, + lookup: null, + formatMessages: false + ); + } - foreach (var pair in groupedMessages) - errors[pair.Key] = [.. pair.Value]; + /// + /// Converts a failed validation result into , resolving + /// s from the supplied registry to derive the status code, title, and messages. + /// + public static HttpValidationProblemDetails ToHttpValidationProblemDetails( + this ValidationResult result, + ErrorTypeRegistry registry, + int statusCode = StatusCodes.Status400BadRequest + ) + { + EnsureFailed(result); + return ProblemDetailsMapper.Build(result.Errors, statusCode, registry, lookup: null, formatMessages: true); + } - HttpValidationProblemDetails details = new(errors) - { - Title = "One or more validation errors occurred.", - Status = statusCode, - }; - details.Extensions["issues"] = result - .Errors.Select(static error => new ValidationIssue - { - Code = error.Code, - Origin = error.Origin, - Minimum = error.Minimum, - Maximum = error.Maximum, - Inclusive = error.Inclusive, - Path = [.. error.Path], - Message = error.Message, - }) - .ToArray(); - - return details; + /// + /// Converts a failed validation result into , resolving + /// s through the supplied lookup to derive the status code, title, and messages. + /// + public static HttpValidationProblemDetails ToHttpValidationProblemDetails( + this ValidationResult result, + Func lookup, + int statusCode = StatusCodes.Status400BadRequest + ) + { + EnsureFailed(result); + return ProblemDetailsMapper.Build(result.Errors, statusCode, registry: null, lookup, formatMessages: true); } /// @@ -71,7 +67,45 @@ public static ValidationProblemDetails ToValidationProblemDetails( ) { var details = result.ToHttpValidationProblemDetails(statusCode); - return new(details.Errors) + return ToValidationProblemDetails(details); + } + + /// + /// Converts a failed validation result into , resolving + /// s from the supplied registry. + /// + public static ValidationProblemDetails ToValidationProblemDetails( + this ValidationResult result, + ErrorTypeRegistry registry, + int statusCode = StatusCodes.Status400BadRequest + ) + { + var details = result.ToHttpValidationProblemDetails(registry, statusCode); + return ToValidationProblemDetails(details); + } + + /// + /// Converts a failed validation result into , resolving + /// s through the supplied lookup. + /// + public static ValidationProblemDetails ToValidationProblemDetails( + this ValidationResult result, + Func lookup, + int statusCode = StatusCodes.Status400BadRequest + ) + { + var details = result.ToHttpValidationProblemDetails(lookup, statusCode); + return ToValidationProblemDetails(details); + } + + static void EnsureFailed(ValidationResult result) + { + if (result.IsSuccess) + throw new InvalidOperationException("Cannot create ProblemDetails from a successful validation result."); + } + + internal static ValidationProblemDetails ToValidationProblemDetails(HttpValidationProblemDetails details) => + new(details.Errors) { Title = details.Title, Status = details.Status, @@ -80,31 +114,12 @@ public static ValidationProblemDetails ToValidationProblemDetails( Instance = details.Instance, Extensions = { ["issues"] = details.Extensions["issues"] }, }; - } - - static string ToProblemDetailsKey(System.Collections.Immutable.ImmutableArray path) - { - if (path.IsDefaultOrEmpty) - return string.Empty; - - System.Text.StringBuilder builder = new(); - for (var i = 0; i < path.Length; i++) - { - var segment = path[i]; - if (i > 0 && !segment.StartsWith('[')) - builder = builder.Append('.'); - - builder = builder.Append(segment); - } - - return builder.ToString(); - } } /// /// Serializable structured validation issue metadata included in ProblemDetails extensions. /// -public sealed class ValidationIssue +public readonly record struct ValidationIssue { /// /// The issue code. @@ -141,4 +156,12 @@ public sealed class ValidationIssue /// The human-readable issue message. /// public required string Message { get; init; } + + /// + /// The additional error parameters carried by the validation error (for example aggregate ids). + /// + [System.Text.Json.Serialization.JsonIgnore( + Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + )] + public IReadOnlyDictionary? Parameters { get; init; } } diff --git a/src/src/AspNetCore/ProblemDetailsMapper.cs b/src/src/AspNetCore/ProblemDetailsMapper.cs new file mode 100644 index 0000000..a37db7e --- /dev/null +++ b/src/src/AspNetCore/ProblemDetailsMapper.cs @@ -0,0 +1,209 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// Shared implementation that maps a set of s onto an +/// , honouring registered s. +/// +static class ProblemDetailsMapper +{ + public static HttpValidationProblemDetails Build( + ImmutableArray errors, + int defaultStatusCode, + ErrorTypeRegistry? registry, + Func? lookup, + bool formatMessages + ) + { + var errorCount = errors.IsDefault ? 0 : errors.Length; + + Dictionary> groupedMessages = new(StringComparer.Ordinal); + Dictionary errorDictionary = new(errorCount, StringComparer.Ordinal); + var issues = new ValidationIssue[errorCount]; + + ErrorType? highest = null; + var highestStatus = 0; + var issueIndex = 0; + + foreach (var error in errors.IsDefault ? [] : errors) + { + var errorType = TryResolve(registry, lookup, error.Code); + if (errorType is not null && errorType.HttpStatus > highestStatus) + { + highestStatus = errorType.HttpStatus; + highest = errorType; + } + + var key = ToProblemDetailsKey(error.Path); + if (!groupedMessages.TryGetValue(key, out var messages)) + { + messages = []; + groupedMessages[key] = messages; + } + + var message = + formatMessages && errorType?.MessageFormat is not null + ? FormatMessage(errorType.MessageFormat, error.Parameters) + : error.Message; + + messages.Add(message); + + issues[issueIndex++] = new ValidationIssue + { + Code = error.Code, + Origin = error.Origin, + Minimum = error.Minimum, + Maximum = error.Maximum, + Inclusive = error.Inclusive, + Path = error.Path.IsDefault ? [] : [.. error.Path], + Message = message, + Parameters = error.Parameters, + }; + } + + foreach (var pair in groupedMessages) + errorDictionary[pair.Key] = [.. pair.Value]; + + var status = highestStatus != 0 ? highestStatus : defaultStatusCode; + + HttpValidationProblemDetails details = new(errorDictionary) + { + Title = highest?.Title ?? "One or more validation errors occurred.", + Status = status, + }; + + if (highest?.Type is not null) + details.Type = highest.Type; + + if (highest?.Description is not null) + details.Detail = highest.Description; + + details.Extensions["issues"] = issues; + + if (lookup is not null || registry is not null) + MergeParameters(details, errors); + + return details; + } + + static ErrorType? TryResolve(ErrorTypeRegistry? registry, Func? lookup, string code) => + lookup is not null ? lookup(code) + : registry is not null && registry.TryGet(code, out var errorType) ? errorType + : null; + + static string ToProblemDetailsKey(ImmutableArray path) + { + if (path.IsDefaultOrEmpty) + return string.Empty; + + StringBuilder builder = new(); + for (var i = 0; i < path.Length; i++) + { + var segment = path[i]; + if (i > 0 && !segment.StartsWith('[')) + builder = builder.Append('.'); + + builder = builder.Append(segment); + } + + return builder.ToString(); + } + + static void MergeParameters(HttpValidationProblemDetails details, ImmutableArray errors) + { + foreach (var error in errors.IsDefault ? [] : errors) + { + if (error.Parameters is null) + continue; + + foreach (var pair in error.Parameters) + { + if (pair.Key is "issues" or "traceId") + continue; + + details.Extensions[JsonNamingPolicy.CamelCase.ConvertName(pair.Key)] = pair.Value; + } + } + } + + static string FormatMessage(string format, IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return format; + + // Single pass over the format string; only allocate a builder when a placeholder + // actually substitutes. Placeholders must be `{Identifier}`; `{{`/`{0}` never match, + // and names without a matching parameter value are left as-is. + StringBuilder? builder = null; + var start = 0; + + for (var i = 0; i < format.Length; i++) + { + if (format[i] != '{' || i + 1 >= format.Length || !IsIdentifierStart(format[i + 1])) + continue; + + var close = format.IndexOf('}', i + 1); + if (close < 0) + break; + + if (!IsIdentifierTail(format, i + 2, close)) + { + i = close; + continue; + } + + var name = format.AsSpan(i + 1, close - i - 1); + + object? value = null; + var found = false; + foreach (var pair in parameters) + { + if (pair.Key.AsSpan().SequenceEqual(name)) + { + value = pair.Value; + found = true; + break; + } + } + + if (!found || value is null) + { + i = close; + continue; + } + + builder ??= new StringBuilder(format.Length + 8); + builder.Append(format, start, i - start); + builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + + start = close + 1; + i = close; + } + + if (builder is null) + return format; + + builder.Append(format, start, format.Length - start); + return builder.ToString(); + } + + static bool IsIdentifierStart(char c) => c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or '_'; + + static bool IsIdentifierTail(string format, int start, int end) + { + for (var i = start; i < end; i++) + { + var c = format[i]; + if (c is not ((>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + return false; + } + + return true; + } +} diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 8b233f3..51df6c9 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -38,6 +38,43 @@ A `ValidationProblemDetails` overload is also available: var problem = result.ToValidationProblemDetails(); ``` +## Exception handling + +Thrown `ZodException`s (e.g. from a value object's strict deserialization) are converted automatically by +an `IExceptionHandler`: + +```csharp +builder.Services.AddZodSharpProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +## Mapping error types to status codes + +Register an `ErrorType` and map error codes to HTTP statuses and formatted messages: + +```csharp +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"] + }; +} + +ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +``` + +A `ValidationError` carrying `parameters` such as `["AggregateId"] = "agg-123"` is then surfaced as a +`409 Conflict` response whose message reads `Aggregate 'agg-123' (of type Invoice) failed to save`. The +analyzer `ZODSASP001` (bundled with the package) warns when a `MessageFormat` placeholder is not declared +in `Parameters`. + ## Documentation - [Homepage](https://purview.dev/projects/zodsharp/) diff --git a/src/src/AspNetCore/ZodExceptionExtensions.cs b/src/src/AspNetCore/ZodExceptionExtensions.cs new file mode 100644 index 0000000..518e542 --- /dev/null +++ b/src/src/AspNetCore/ZodExceptionExtensions.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// Converts a thrown into ASP.NET Core ProblemDetails payloads. +/// +public static class ZodExceptionExtensions +{ + /// + /// Converts a into . + /// + public static HttpValidationProblemDetails ToHttpValidationProblemDetails( + this ZodException exception, + int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + ) + { + ArgumentNullException.ThrowIfNull(exception); + return ProblemDetailsMapper.Build( + exception.Errors, + statusCode, + registry: null, + lookup: null, + formatMessages: false + ); + } + + /// + /// Converts a into , resolving + /// s from the supplied registry to derive the status code, title, and messages. + /// + public static HttpValidationProblemDetails ToHttpValidationProblemDetails( + this ZodException exception, + ErrorTypeRegistry registry, + int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + ) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(registry); + return ProblemDetailsMapper.Build(exception.Errors, statusCode, registry, lookup: null, formatMessages: true); + } + + /// + /// Converts a into , resolving + /// s through the supplied lookup to derive the status code, title, and messages. + /// + public static HttpValidationProblemDetails ToHttpValidationProblemDetails( + this ZodException exception, + Func lookup, + int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + ) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(lookup); + return ProblemDetailsMapper.Build(exception.Errors, statusCode, registry: null, lookup, formatMessages: true); + } + + /// + /// Converts a into . + /// + public static ValidationProblemDetails ToValidationProblemDetails( + this ZodException exception, + int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + ) + { + var details = exception.ToHttpValidationProblemDetails(statusCode); + return ProblemDetailsExtensions.ToValidationProblemDetails(details); + } + + /// + /// Converts a into , resolving + /// s from the supplied registry. + /// + public static ValidationProblemDetails ToValidationProblemDetails( + this ZodException exception, + ErrorTypeRegistry registry, + int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + ) + { + var details = exception.ToHttpValidationProblemDetails(registry, statusCode); + return ProblemDetailsExtensions.ToValidationProblemDetails(details); + } + + /// + /// Converts a into , resolving + /// s through the supplied lookup. + /// + public static ValidationProblemDetails ToValidationProblemDetails( + this ZodException exception, + Func lookup, + int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + ) + { + var details = exception.ToHttpValidationProblemDetails(lookup, statusCode); + return ProblemDetailsExtensions.ToValidationProblemDetails(details); + } +} diff --git a/src/src/AspNetCore/ZodExceptionHandler.cs b/src/src/AspNetCore/ZodExceptionHandler.cs new file mode 100644 index 0000000..1206d9e --- /dev/null +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace ZodSharp.AspNetCore; + +/// +/// An that converts a thrown +/// into a standard response, +/// resolving s from the configured . +/// +/// +/// +/// Register with services.AddZodSharpProblemDetails() and ensure +/// app.UseExceptionHandler() is present in the pipeline, otherwise the handler is never invoked. +/// +/// +public sealed class ZodExceptionHandler : IExceptionHandler +{ + static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web); + + readonly ZodProblemDetailsOptions _options; + + /// + /// Initializes a new instance of the class. + /// + public ZodExceptionHandler(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options.Value; + } + + /// + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (exception is not ZodSharp.Core.ZodException zodException) + return false; + + var defaultStatusCode = + _options.StatusCodeSelector?.Invoke(zodException.Errors) ?? StatusCodes.Status400BadRequest; + + var problem = zodException.ToHttpValidationProblemDetails(_options.Registry, defaultStatusCode); + problem.Extensions["traceId"] = httpContext.TraceIdentifier; + + httpContext.Response.StatusCode = problem.Status!.Value; + await httpContext.Response.WriteAsJsonAsync( + problem, + s_jsonOptions, + "application/problem+json", + cancellationToken + ); + + return true; + } +} diff --git a/src/src/AspNetCore/ZodProblemDetailsOptions.cs b/src/src/AspNetCore/ZodProblemDetailsOptions.cs new file mode 100644 index 0000000..c4a0e87 --- /dev/null +++ b/src/src/AspNetCore/ZodProblemDetailsOptions.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; + +namespace ZodSharp.AspNetCore; + +/// +/// Configuration for and the ProblemDetails mapping extensions. +/// +public sealed class ZodProblemDetailsOptions +{ + /// + /// The used to resolve error codes to s. + /// Defaults to . + /// + public ErrorTypeRegistry Registry { get; set; } = ErrorTypeRegistry.Default; + + /// + /// When true, an error's message is formatted from the matched + /// using the error's . Defaults to true. + /// + public bool FormatMessages { get; set; } = true; + + /// + /// An optional escape hatch that takes complete control of the response status code. When set, its + /// result overrides the highest matched . + /// + public Func, int>? StatusCodeSelector { get; set; } + + /// + /// Registers an with the configured . + /// + public ZodProblemDetailsOptions MapErrorType(ErrorType errorType) + { + Registry.Register(errorType); + return this; + } +} diff --git a/src/src/AspNetCore/ZodSharpServiceCollectionExtensions.cs b/src/src/AspNetCore/ZodSharpServiceCollectionExtensions.cs index fa82848..d2cde71 100644 --- a/src/src/AspNetCore/ZodSharpServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/ZodSharpServiceCollectionExtensions.cs @@ -36,4 +36,26 @@ public static IServiceCollection AddZodSharp( return services; } + + /// + /// Registers as an + /// and configures . + /// + /// + /// + /// Requires app.UseExceptionHandler() in the request pipeline for the handler to be invoked. + /// + /// + public static IServiceCollection AddZodSharpProblemDetails( + this IServiceCollection services, + Action? configure = null + ) + { + if (configure is not null) + services.Configure(configure); + + services.AddExceptionHandler(); + + return services; + } } diff --git a/src/src/Benchmarks/Benchmarks.csproj b/src/src/Benchmarks/Benchmarks.csproj index f96631e..3ea44c9 100644 --- a/src/src/Benchmarks/Benchmarks.csproj +++ b/src/src/Benchmarks/Benchmarks.csproj @@ -13,6 +13,7 @@ + diff --git a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs new file mode 100644 index 0000000..1b5d60f --- /dev/null +++ b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs @@ -0,0 +1,81 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Http; +using ZodSharp.AspNetCore; +using ZodSharp.Core; + +namespace ZodSharp; + +/// +/// Performance and allocation profile for converting a thrown into +/// . +/// +[MemoryDiagnoser] +[SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] +public class ProblemDetailsMappingPerformanceTests +{ + readonly ZodException _unmappedException; + readonly ZodException _mappedException; + readonly ErrorTypeRegistry _registry; + readonly Func _lookup; + + public ProblemDetailsMappingPerformanceTests() + { + _unmappedException = new ZodException([ + ValidationError.Create( + "too_small", + "Field 'Items' must contain at least 2 elements.", + ["Items"], + origin: "array", + minimum: 2, + inclusive: true + ), + ValidationError.Create( + "too_big", + "Field 'Order.Lines[3].Quantity' must contain no more than 5 elements.", + ["Order", "Lines", "[3]", "Quantity"], + origin: "collection", + maximum: 5, + inclusive: true + ), + ]); + + _mappedException = new ZodException([ + ValidationError.Create( + "aggregate_save_failed", + "The aggregate could not be saved.", + [], + parameters: new Dictionary + { + ["AggregateId"] = "agg-123", + ["AggregateType"] = "Invoice", + } + ), + ]); + + _registry = new ErrorTypeRegistry(); + _registry.Register( + new ErrorType( + "aggregate_save_failed", + "The aggregate could not be saved.", + StatusCodes.Status409Conflict, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = ["AggregateId", "AggregateType"], + } + ); + + _lookup = code => _registry.TryGet(code, out var errorType) ? errorType : null; + } + + [Benchmark] + public HttpValidationProblemDetails MapWithoutRegistry() => _unmappedException.ToHttpValidationProblemDetails(); + + [Benchmark] + public HttpValidationProblemDetails MapWithRegistryLookup() => + _mappedException.ToHttpValidationProblemDetails(_lookup); + + [Benchmark] + public HttpValidationProblemDetails MapWithRegistryAndFormatting() => + _mappedException.ToHttpValidationProblemDetails(_registry); +} diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj b/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj new file mode 100644 index 0000000..dadeff4 --- /dev/null +++ b/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj @@ -0,0 +1,14 @@ + + + Purview.ZodSharp.AspNetCore.Analyzers.UnitTests + + + + + + + + + + + diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs new file mode 100644 index 0000000..b94b818 --- /dev/null +++ b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs @@ -0,0 +1,168 @@ +using ZodSharp.AspNetCore.Analyzers.Infra; + +namespace ZodSharp.AspNetCore.Analyzers; + +public partial class ErrorTypeMessageFormatAnalyzerTests : ErrorTypeAnalyzerTestBase +{ + [Test] + public async Task GivenMessageFormat_WithUndeclaredPlaceholder_ReportsDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + HttpStatus: 409, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save") + { + Parameters = ["AggregateId"] + }; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ErrorTypeMessageFormatAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenMessageFormat_WithAllParametersDeclared_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + HttpStatus: 409, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save") + { + Parameters = ["AggregateId", "AggregateType"] + }; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenMessageFormat_PositionalArguments_ReportsPlaceholders(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + public static readonly ErrorType SaveFailed = new( + "aggregate_save_failed", null, 409, null, null, "Aggregate '{AggregateId}' failed to save"); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + } + + [Test] + public async Task GivenMessageFormat_WithEscapedBraces_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class Escaped + { + public static readonly ErrorType Saved = new( + Code: "saved", + MessageFormat: "The value {{Value}} is fine.") + { + Parameters = ["Value"] + }; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenMessageFormat_WithNumericPlaceholder_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class Numeric + { + public static readonly ErrorType Saved = new( + Code: "saved", + MessageFormat: "Field {0} is required."); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenParametersOmitted_ReportsPlaceholders(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class MissingParameters + { + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + MessageFormat: "Aggregate '{AggregateId}' failed to save"); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ErrorTypeMessageFormatAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenNoMessageFormat_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class Plain + { + public static readonly ErrorType Conflict = new(Code: "conflict", HttpStatus: 409); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenNonErrorTypeObjectCreation_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public sealed record Other(string MessageFormat); + + public static class SomeType + { + public static readonly Other Value = new(MessageFormat: "Aggregate '{AggregateId}' failed to save"); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestBase.cs b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestBase.cs new file mode 100644 index 0000000..82010a3 --- /dev/null +++ b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestBase.cs @@ -0,0 +1,7 @@ +namespace ZodSharp.AspNetCore.Analyzers.Infra; + +public abstract class ErrorTypeAnalyzerTestBase + : TUnitDiagnosticAnalyzerTestBase +{ + // Empty +} diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs new file mode 100644 index 0000000..70d0fcb --- /dev/null +++ b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs @@ -0,0 +1,26 @@ +namespace ZodSharp.AspNetCore.Analyzers.Infra; + +public sealed record ErrorTypeAnalyzerTestOptions : AnalyzerTestOptions +{ + public ErrorTypeAnalyzerTestOptions() + { + AdditionalNamespaces = ["ZodSharp.AspNetCore"]; + AdditionalSources = + [ + """ + namespace ZodSharp.AspNetCore; + + public sealed record ErrorType( + string Code, + string? Description = null, + int HttpStatus = 400, + string? Title = null, + string? Type = null, + string? MessageFormat = null) + { + public System.Collections.Generic.IReadOnlyList Parameters { get; init; } = []; + } + """, + ]; + } +} diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs new file mode 100644 index 0000000..9b25b49 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs @@ -0,0 +1,194 @@ +using Microsoft.AspNetCore.Http; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +public class ErrorTypeMappingTests +{ + [Test] + public async Task GivenMappedCode_ReturnsErrorTypeStatusAndFormattedMessage() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register( + new ErrorType( + "aggregate_save_failed", + "The aggregate could not be saved.", + StatusCodes.Status409Conflict, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = ["AggregateId", "AggregateType"], + } + ); + ZodException exception = new([ + ValidationError.Create( + "aggregate_save_failed", + "Aggregate failed to save.", + [], + parameters: new Dictionary + { + ["AggregateId"] = "agg-123", + ["AggregateType"] = "Invoice", + } + ), + ]); + + // Act + var details = exception.ToHttpValidationProblemDetails(registry); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(details.Detail).IsEqualTo("The aggregate could not be saved."); + await Assert + .That(details.Errors[string.Empty]) + .IsEquivalentTo(["Aggregate 'agg-123' (of type Invoice) failed to save"]); + } + + [Test] + public async Task GivenMappedCode_ExposesTitleAndParametersInExtensionsAndIssues() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register( + new ErrorType("locked", Title: "Resource is locked", HttpStatus: StatusCodes.Status423Locked) + ); + ZodException exception = new([ + ValidationError.Create( + "locked", + "Resource is locked.", + [], + parameters: new Dictionary { ["ResourceId"] = "res-42" } + ), + ]); + + // Act + var details = exception.ToHttpValidationProblemDetails(registry); + var issues = (ValidationIssue[])details.Extensions["issues"]!; + + // Assert + await Assert.That(details.Title).IsEqualTo("Resource is locked"); + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status423Locked); + await Assert.That(details.Extensions["resourceId"]).IsEqualTo("res-42"); + await Assert.That(issues).HasSingleItem(); + await Assert.That(issues[0].Parameters).IsNotNull(); + await Assert.That(issues[0].Parameters!["ResourceId"]).IsEqualTo("res-42"); + } + + [Test] + public async Task GivenUnmappedCode_ReturnsDefaultStatusAndDefaultTitle() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(new ErrorType("known")); + ZodException exception = new([ValidationError.Create("unknown", "Something failed.", [])]); + + // Act + var details = exception.ToHttpValidationProblemDetails(registry); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(details.Title).IsEqualTo("One or more validation errors occurred."); + } + + [Test] + public async Task GivenMultipleCodesWithDifferentStatuses_HighestStatusWins() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(new ErrorType("conflict", HttpStatus: StatusCodes.Status409Conflict)); + registry.Register(new ErrorType("locked", HttpStatus: StatusCodes.Status423Locked)); + ZodException exception = new([ + ValidationError.Create("conflict", "Conflict.", []), + ValidationError.Create("locked", "Locked.", []), + ]); + + // Act + var details = exception.ToHttpValidationProblemDetails(registry); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status423Locked); + await Assert.That(details.Errors.Keys).IsEquivalentTo([string.Empty]); + } + + [Test] + public async Task GivenMessageFormat_MissingPlaceholderIsLeftAsIs() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register( + new ErrorType( + "save_failed", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = ["AggregateId", "AggregateType"], + } + ); + ZodException exception = new([ + ValidationError.Create( + "save_failed", + "Save failed.", + [], + parameters: new Dictionary { ["AggregateId"] = "agg-123" } + ), + ]); + + // Act + var details = exception.ToHttpValidationProblemDetails(registry); + + // Assert + await Assert + .That(details.Errors[string.Empty]) + .IsEquivalentTo(["Aggregate 'agg-123' (of type {AggregateType}) failed to save"]); + } + + [Test] + public async Task GivenValidationResultWithRegistry_FormatsMessages() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register( + new ErrorType( + "too_small", + HttpStatus: StatusCodes.Status422UnprocessableEntity, + MessageFormat: "'{Field}' must be at least {Minimum} characters." + ) + { + Parameters = ["Field", "Minimum"], + } + ); + var result = ValidationResult.Failure( + ValidationError.Create( + "too_small", + "Too short.", + ["name"], + parameters: new Dictionary { ["Field"] = "Name", ["Minimum"] = 3 } + ) + ); + + // Act + var details = result.ToHttpValidationProblemDetails(registry); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(details.Errors["name"]).IsEquivalentTo(["'Name' must be at least 3 characters."]); + } + + [Test] + public async Task GivenValidationResult_ExistingOverloadKeepsBehavior() + { + // Arrange + var result = ValidationResult.Failure( + ValidationError.Create("too_small", "Field 'Items' must contain at least 2 elements.", ["Items"]) + ); + + // Act + var details = result.ToHttpValidationProblemDetails(); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(details.Title).IsEqualTo("One or more validation errors occurred."); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs new file mode 100644 index 0000000..e58acfe --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; + +namespace ZodSharp.AspNetCore; + +public class ErrorTypeRegistryTests +{ + [Test] + public async Task Register_ThenTryGet_ReturnsRegisteredType() + { + // Arrange + ErrorTypeRegistry registry = new(); + ErrorType errorType = new("aggregate_save_failed", HttpStatus: StatusCodes.Status409Conflict); + + // Act + registry.Register(errorType); + var found = registry.TryGet("aggregate_save_failed", out var resolved); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(resolved).IsSameReferenceAs(errorType); + } + + [Test] + public async Task Register_DuplicateCode_Throws() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(new ErrorType("duplicate")); + + // Act + var act = () => registry.Register(new ErrorType("duplicate")); + + // Assert + await Assert.That(act).Throws(); + } + + [Test] + public async Task Register_MultipleTypes_AllReturnsThem() + { + // Arrange + ErrorTypeRegistry registry = new(); + ErrorType first = new("first"); + ErrorType second = new("second", HttpStatus: StatusCodes.Status409Conflict); + + // Act + registry.Register([first, second]); + + // Assert + await Assert.That(registry.All).Contains(first); + await Assert.That(registry.All).Contains(second); + } + + [Test] + public async Task Remove_RemovesRegisteredType() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(new ErrorType("stale")); + + // Act + var removed = registry.Remove("stale"); + + // Assert + await Assert.That(removed).IsTrue(); + await Assert.That(registry.TryGet("stale", out _)).IsFalse(); + } + + [Test] + public async Task Default_IsUsableSharedInstance() + { + // Arrange / Act + var first = ErrorTypeRegistry.Default; + var second = ErrorTypeRegistry.Default; + + // Assert + await Assert.That(first).IsSameReferenceAs(second); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodExceptionExtensionsTests.cs b/src/tests/AspNetCore.UnitTests/ZodExceptionExtensionsTests.cs new file mode 100644 index 0000000..6c6da2a --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodExceptionExtensionsTests.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +public class ZodExceptionExtensionsTests +{ + [Test] + public async Task ToHttpValidationProblemDetails_GivenFailure_PreservesErrorsAndIssues() + { + // Arrange + ZodException exception = new([ + ValidationError.Create( + "too_small", + "Field 'Items' must contain at least 2 elements.", + ["Items"], + origin: "array", + minimum: 2, + inclusive: true + ), + ValidationError.Create( + "too_big", + "Field 'Order.Lines[3].Quantity' must contain no more than 5 elements.", + ["Order", "Lines", "[3]", "Quantity"], + origin: "collection", + maximum: 5, + inclusive: true + ), + ]); + + // Act + var details = exception.ToHttpValidationProblemDetails(); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(details.Errors["Items"]).IsEquivalentTo(["Field 'Items' must contain at least 2 elements."]); + await Assert + .That(details.Errors["Order.Lines[3].Quantity"]) + .IsEquivalentTo(["Field 'Order.Lines[3].Quantity' must contain no more than 5 elements."]); + await Assert.That(details.Extensions.ContainsKey("issues")).IsTrue(); + } + + [Test] + public async Task ToHttpValidationProblemDetails_GivenEmptyErrors_ProducesEmptyPayload() + { + // Arrange + ZodException exception = new([]); + + // Act + var details = exception.ToHttpValidationProblemDetails(); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(details.Errors).IsEmpty(); + } + + [Test] + public async Task ToValidationProblemDetails_GivenFailure_ProducesValidationProblemDetails() + { + // Arrange + ZodException exception = new([ + ValidationError.Create("too_small", "Field 'Items' must contain at least 2 elements.", ["Items"]), + ]); + + // Act + var details = exception.ToValidationProblemDetails(); + + // Assert + await Assert.That(details).IsTypeOf(); + await Assert.That(details.Errors["Items"]).HasSingleItem(); + await Assert.That(details.Extensions.ContainsKey("issues")).IsTrue(); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs new file mode 100644 index 0000000..ccaf0df --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +public class ZodExceptionHandlerTests +{ + [Test] + public async Task TryHandleAsync_GivenZodException_ReturnsTrueAndWritesProblemDetails( + CancellationToken cancellationToken + ) + { + // Arrange + var options = Microsoft.Extensions.Options.Options.Create(new ZodProblemDetailsOptions()); + ZodExceptionHandler handler = new(options); + ZodException exception = new([ValidationError.Create("invalid", "Invalid value.", ["value"])]); + var httpContext = NewContext(); + + // Act + var handled = await handler.TryHandleAsync(httpContext, exception, cancellationToken); + + // Assert + await Assert.That(handled).IsTrue(); + await Assert.That(httpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(httpContext.Response.ContentType).IsEqualTo("application/problem+json"); + + var body = await ReadBody(httpContext, cancellationToken); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(root.GetProperty("errors").GetProperty("value")[0].GetString()).IsEqualTo("Invalid value."); + await Assert.That(root.GetProperty("traceId").GetString()).IsEqualTo("trace-1"); + await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(1); + } + + [Test] + public async Task TryHandleAsync_GivenMappedErrorType_UsesMappedStatusAndFormattedMessage( + CancellationToken cancellationToken + ) + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register( + new ErrorType( + "aggregate_save_failed", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Aggregate '{AggregateId}' failed to save" + ) + { + Parameters = ["AggregateId"], + } + ); + var options = Microsoft.Extensions.Options.Options.Create(new ZodProblemDetailsOptions { Registry = registry }); + ZodExceptionHandler handler = new(options); + ZodException exception = new([ + ValidationError.Create( + "aggregate_save_failed", + "Save failed.", + [], + parameters: new Dictionary { ["AggregateId"] = "agg-123" } + ), + ]); + var httpContext = NewContext(); + + // Act + var handled = await handler.TryHandleAsync(httpContext, exception, cancellationToken); + + // Assert + await Assert.That(handled).IsTrue(); + await Assert.That(httpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status409Conflict); + + var body = await ReadBody(httpContext, cancellationToken); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(StatusCodes.Status409Conflict); + await Assert + .That(root.GetProperty("errors").GetProperty("")[0].GetString()) + .IsEqualTo("Aggregate 'agg-123' failed to save"); + await Assert.That(root.GetProperty("aggregateId").GetString()).IsEqualTo("agg-123"); + } + + [Test] + public async Task TryHandleAsync_GivenStatusCodeSelector_UsesSelectorStatus(CancellationToken cancellationToken) + { + // Arrange + var options = Microsoft.Extensions.Options.Options.Create( + new ZodProblemDetailsOptions { StatusCodeSelector = static _ => StatusCodes.Status503ServiceUnavailable } + ); + ZodExceptionHandler handler = new(options); + ZodException exception = new([ValidationError.Create("invalid", "Invalid value.", [])]); + var httpContext = NewContext(); + + // Act + var handled = await handler.TryHandleAsync(httpContext, exception, cancellationToken); + + // Assert + await Assert.That(handled).IsTrue(); + await Assert.That(httpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task TryHandleAsync_GivenOtherException_ReturnsFalse(CancellationToken cancellationToken) + { + // Arrange + ZodExceptionHandler handler = new(Microsoft.Extensions.Options.Options.Create(new ZodProblemDetailsOptions())); + var httpContext = NewContext(); + + // Act + var handled = await handler.TryHandleAsync( + httpContext, + new InvalidOperationException("boom"), + cancellationToken + ); + + // Assert + await Assert.That(handled).IsFalse(); + } + + static DefaultHttpContext NewContext() + { + DefaultHttpContext httpContext = new(); + httpContext.TraceIdentifier = "trace-1"; + httpContext.Response.Body = new MemoryStream(); + return httpContext; + } + + static async Task ReadBody(HttpContext httpContext, CancellationToken cancellationToken) + { + httpContext.Response.Body.Position = 0; + using StreamReader reader = new(httpContext.Response.Body); + return await reader.ReadToEndAsync(cancellationToken); + } +}