diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index aee8417..28c26a8 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -94,8 +94,9 @@ then let the mapper derive the status code, title, detail, and formatted message ```csharp using ZodSharp.AspNetCore; -public static class ConcurrentErrorType +public static partial class ConcurrentErrorType { + [ErrorType] public static readonly ErrorType SaveFailed = new( Code: "aggregate_save_failed", Description: "The aggregate could not be saved.", @@ -126,6 +127,39 @@ throw new ZodException([ ]); ``` +### Generated `Create` / `Throw` helpers + +Marking the field with `[ErrorType]` and the containing class `partial` lets the bundled source +generator turn each field into strongly typed static helpers. For the field above it generates +`ConcurrentErrorType.CreateSaveFailed(...)` and `ConcurrentErrorType.ThrowSaveFailed(...)` with one +named parameter per entry in `Parameters`: + +```csharp +// Returns a ValidationError with the code, the formatted message, and the named parameters. +var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); + +// Throws a ZodException carrying the same ValidationError. +ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); + +// The path and structured issue metadata can be populated too: +var error = ConcurrentErrorType.CreateSaveFailed( + "agg-123", + "Invoice", + path: ["order", "items", "[0]"], + origin: "collection", + minimum: 1, + maximum: 10, + inclusive: true); +``` + +The generated `Create` builds the `parameters` dictionary and sets the message from +`ErrorType.FormatMessage`, so `error.Message` already reads +`Aggregate 'agg-123' (of type Invoice) failed to save` and mapping through the registry produces the +`409 Conflict` response described below. The analyzers `ZODSASP001`/`ZODSASP002`/`ZODSASP003` +(bundled with the package) warn when a `MessageFormat` placeholder is not declared in `Parameters`, +when an `[ErrorType]` field's containing class is not `partial`, or when the field is not +`static readonly`. + Produces a `409 Conflict` `HttpValidationProblemDetails` with: ```json diff --git a/package.json b/package.json index 5a278d6..7e37c75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.10", + "version": "2.0.0-prerelease.11", "private": true, "license": "MIT", "author": { diff --git a/purview-build.json b/purview-build.json index c898ea7..4371005 100644 --- a/purview-build.json +++ b/purview-build.json @@ -31,6 +31,7 @@ "lib/net8.0/Purview.ZodSharp.AspNetCore.dll", "lib/net8.0/Purview.ZodSharp.AspNetCore.xml", "analyzers/dotnet/cs/Purview.ZodSharp.AspNetCore.Analyzers.dll", + "analyzers/dotnet/cs/Purview.ZodSharp.AspNetCore.SourceGenerators.dll", "README.md", "purview-logo-light.png" ], diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 6ad49cd..d1aa049 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -14,6 +14,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md index 60bb0d0..56dcb4d 100644 --- a/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md @@ -2,4 +2,6 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------ -ZODSASP001 | ZodSharp.AspNetCore | Warning | MessageFormat placeholder is not declared in Parameters \ No newline at end of file +ZODSASP001 | ZodSharp.AspNetCore | Warning | MessageFormat placeholder is not declared in Parameters +ZODSASP002 | ZodSharp.AspNetCore | Warning | Error type containing type must be partial +ZODSASP003 | ZodSharp.AspNetCore | Warning | ErrorType field must be static readonly \ No newline at end of file diff --git a/src/src/AspNetCore.Analyzers/DiagnosticLibrary.cs b/src/src/AspNetCore.Analyzers/DiagnosticLibrary.cs new file mode 100644 index 0000000..28c5917 --- /dev/null +++ b/src/src/AspNetCore.Analyzers/DiagnosticLibrary.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; + +namespace ZodSharp.AspNetCore.Analyzers; + +/// +/// Diagnostic descriptors reported by the ZodSharp.AspNetCore analyzers. +/// +static class DiagnosticLibrary +{ + const string Category = "ZodSharp.AspNetCore"; + + public static readonly DiagnosticDescriptor MessageFormatPlaceholderNotDeclared = new( + id: "ZODSASP001", + title: "MessageFormat placeholder is not declared in Parameters", + messageFormat: "MessageFormat placeholder '{0}' is not declared in ErrorType.Parameters", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ErrorTypeContainingTypeNotPartial = new( + id: "ZODSASP002", + title: "Error type containing type must be partial", + messageFormat: "The containing type '{0}' must be declared 'partial' so that Create/Throw methods can be generated", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ErrorTypeFieldInvalid = new( + id: "ZODSASP003", + title: "ErrorType field must be static readonly", + messageFormat: "The ErrorType field '{0}' must be declared 'static readonly' for Create/Throw methods to be generated", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); +} diff --git a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs index 2b25902..bdb82fd 100644 --- a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs +++ b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs @@ -1,8 +1,8 @@ using System.Collections.Immutable; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; namespace ZodSharp.AspNetCore.Analyzers; @@ -22,16 +22,12 @@ public sealed class ErrorTypeMessageFormatAnalyzer : DiagnosticAnalyzer 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 - ); + static readonly ImmutableArray s_supportedDiagnostics = + [ + DiagnosticLibrary.MessageFormatPlaceholderNotDeclared, + ]; - public override ImmutableArray SupportedDiagnostics => [s_descriptor]; + public override ImmutableArray SupportedDiagnostics => s_supportedDiagnostics; public override void Initialize(AnalysisContext context) { @@ -41,49 +37,36 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction( - AnalyzeObjectCreation, - Microsoft.CodeAnalysis.CSharp.SyntaxKind.ObjectCreationExpression, - Microsoft.CodeAnalysis.CSharp.SyntaxKind.ImplicitObjectCreationExpression - ); + context.RegisterCompilationStartAction(compilationContext => + { + var errorType = compilationContext.Compilation.GetTypeByMetadataName(ErrorTypeMetadataName); + if (errorType is null) + return; + + compilationContext.RegisterOperationAction( + operationContext => AnalyzeObjectCreation(operationContext, errorType), + OperationKind.ObjectCreation + ); + }); } - static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) + static void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSymbol errorType) { - if ( - context.SemanticModel.GetSymbolInfo(context.Node, context.CancellationToken).Symbol - is not IMethodSymbol ctor - ) + if (context.Operation is not IObjectCreationOperation creation) return; - if (ctor.ContainingType?.ToDisplayString() != ErrorTypeMetadataName) + if (!SymbolEqualityComparer.Default.Equals(creation.Type, errorType)) return; - var argumentList = context.Node switch - { - ObjectCreationExpressionSyntax objectCreation => objectCreation.ArgumentList, - ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.ArgumentList, - _ => null, - }; - - if (argumentList is null) + if (GetArgumentValue(creation, "MessageFormat") is not string messageFormat) return; - var messageFormatExpression = FindMemberExpression(argumentList, ctor, "MessageFormat"); - var messageFormat = GetConstantString( - context.SemanticModel, - messageFormatExpression, - context.CancellationToken - ); - if (messageFormat is null) + var declaredParameters = GetDeclaredParameters(creation); + if (declaredParameters is null) return; - var declaredParameters = TryGetDeclaredParameters( - context.SemanticModel, - FindMemberExpression(argumentList, ctor, "Parameters"), - context.CancellationToken - ); - if (declaredParameters is null) + var location = GetArgumentLocation(creation, "MessageFormat"); + if (location is null) return; var placeholders = s_placeholderRegex @@ -92,106 +75,104 @@ is not IMethodSymbol ctor .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)); + { + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticLibrary.MessageFormatPlaceholderNotDeclared, location, placeholder) + ); + } } } - static ExpressionSyntax? FindMemberExpression( - ArgumentListSyntax argumentList, - IMethodSymbol ctor, - string memberName - ) + static object? GetArgumentValue(IObjectCreationOperation creation, string parameterName) { - for (var i = 0; i < argumentList.Arguments.Count; i++) + foreach (var argument in creation.Arguments) { - var argument = argumentList.Arguments[i]; - if (argument.NameColon is not null) - { - if (argument.NameColon.Name.Identifier.ValueText == memberName) - return argument.Expression; - + if (argument.Parameter?.Name != parameterName) 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 UnwrapConversion(argument.Value).ConstantValue.Value; } return null; } - static string? GetConstantString( - SemanticModel semanticModel, - ExpressionSyntax? expression, - CancellationToken cancellationToken - ) + static Location? GetArgumentLocation(IObjectCreationOperation creation, string parameterName) { - if (expression is null) - return null; + foreach (var argument in creation.Arguments) + { + if (argument.Parameter?.Name == parameterName) + return argument.Syntax.GetLocation(); + } - var constant = semanticModel.GetConstantValue(expression, cancellationToken); - return constant.HasValue && constant.Value is string value ? value : null; + return null; } /// - /// Returns the declared parameter names, an empty set when the argument is omitted, or + /// Returns the declared parameter names, an empty set when the initializer is omitted, or /// null when the expression is present but not analyzable (analysis is skipped). /// - static HashSet? TryGetDeclaredParameters( - SemanticModel semanticModel, - ExpressionSyntax? expression, - CancellationToken cancellationToken - ) + static HashSet? GetDeclaredParameters(IObjectCreationOperation creation) { - if (expression is null) + if (creation.Initializer 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) + foreach (var initializer in creation.Initializer.Initializers) { - if (GetConstantString(semanticModel, element, cancellationToken) is { } value) - names.Add(value); + if ( + initializer is ISimpleAssignmentOperation + { + Target: IPropertyReferenceOperation { Property.Name: "Parameters" }, + } assignment + ) + return ExtractStrings(assignment.Value); } - return names; + return []; + } + + static HashSet? ExtractStrings(IOperation operation) + { + operation = UnwrapConversion(operation); + + switch (operation) + { + case IArrayCreationOperation array when array.Initializer is not null: + { + HashSet names = new(StringComparer.Ordinal); + foreach (var element in array.Initializer.ElementValues) + { + if (UnwrapConversion(element).ConstantValue.Value is string value) + names.Add(value); + } + + return names; + } + + case ICollectionExpressionOperation collection: + { + HashSet names = new(StringComparer.Ordinal); + foreach (var element in collection.Elements) + { + if (element is ISpreadOperation) + continue; + + if (UnwrapConversion(element).ConstantValue.Value is string value) + names.Add(value); + } + + return names; + } + + default: + return null; + } } + + static IOperation UnwrapConversion(IOperation operation) => + operation is IConversionOperation { IsImplicit: true } conversion + ? UnwrapConversion(conversion.Operand) + : operation; } diff --git a/src/src/AspNetCore.Analyzers/ErrorTypePartialClassAnalyzer.cs b/src/src/AspNetCore.Analyzers/ErrorTypePartialClassAnalyzer.cs new file mode 100644 index 0000000..0e9ab22 --- /dev/null +++ b/src/src/AspNetCore.Analyzers/ErrorTypePartialClassAnalyzer.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ZodSharp.AspNetCore.Analyzers; + +/// +/// Reports when an [ErrorType] static field's containing class is not declared +/// partial, or when the field is not static readonly, so the generated +/// Create/Throw helpers cannot be emitted. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ErrorTypePartialClassAnalyzer : DiagnosticAnalyzer +{ + /// + /// The diagnostic id for an error type whose containing type is not partial. + /// + public const string DiagnosticId = "ZODSASP002"; + + const string ErrorTypeAttributeMetadataName = "ZodSharp.AspNetCore.ErrorTypeAttribute"; + + static readonly ImmutableArray s_supportedDiagnostics = + [ + DiagnosticLibrary.ErrorTypeContainingTypeNotPartial, + DiagnosticLibrary.ErrorTypeFieldInvalid, + ]; + + public override ImmutableArray SupportedDiagnostics => s_supportedDiagnostics; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationContext => + { + var errorTypeAttribute = compilationContext.Compilation.GetTypeByMetadataName( + ErrorTypeAttributeMetadataName + ); + if (errorTypeAttribute is null) + return; + + compilationContext.RegisterSymbolAction( + symbolContext => AnalyzeField(symbolContext, errorTypeAttribute), + SymbolKind.Field + ); + }); + } + + static void AnalyzeField(SymbolAnalysisContext context, INamedTypeSymbol errorTypeAttribute) + { + if (context.Symbol is not IFieldSymbol field) + return; + + if (!HasAttribute(field, errorTypeAttribute)) + return; + + var location = GetMemberLocation(field); + if (location is null) + return; + + if (!field.IsStatic || !field.IsReadOnly) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticLibrary.ErrorTypeFieldInvalid, location, field.Name)); + return; + } + + var containing = field.ContainingType; + if ( + containing is null + || containing.TypeKind != TypeKind.Class + || containing.TypeParameters.Length > 0 + || containing.ContainingType is not null + ) + return; + + if (!IsPartial(containing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.ErrorTypeContainingTypeNotPartial, + GetMemberLocation(containing) ?? location, + containing.Name + ) + ); + } + } + + static bool HasAttribute(IFieldSymbol field, INamedTypeSymbol attribute) => + field.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attribute)); + + static bool IsPartial(INamedTypeSymbol containing) + { + foreach (var reference in containing.DeclaringSyntaxReferences) + { + if ( + reference.GetSyntax() is ClassDeclarationSyntax classDeclaration + && classDeclaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)) + ) + return true; + } + + return false; + } + + static Location? GetMemberLocation(ISymbol symbol) + { + foreach (var location in symbol.Locations) + { + if (location.IsInSource) + return location; + } + + return null; + } +} diff --git a/src/src/AspNetCore.SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/AspNetCore.SourceGenerators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..bfdc7fc --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/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.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/AspNetCore.SourceGenerators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..ab06adc --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,6 @@ +### New Rules + +| Rule ID | Category | Severity | Notes | +|---|---|---|---| +| ZODSASP100 | ZodSharp.AspNetCore | Error | Unhandled exception in the ErrorType source generator | +| ZODSASP101 | ZodSharp.AspNetCore | Error | ErrorType Parameters could not be extracted | \ No newline at end of file diff --git a/src/src/AspNetCore.SourceGenerators/AspNetCore.SourceGenerators.csproj b/src/src/AspNetCore.SourceGenerators/AspNetCore.SourceGenerators.csproj new file mode 100644 index 0000000..cf67b40 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/AspNetCore.SourceGenerators.csproj @@ -0,0 +1,17 @@ + + + true + Purview.ZodSharp.AspNetCore.SourceGenerators + + + + + + + + + + + + + diff --git a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs new file mode 100644 index 0000000..8d2a2c5 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs @@ -0,0 +1,192 @@ +using System.Collections.Immutable; +using ZodSharp.AspNetCore.SourceGenerators.Helpers; +using ZodSharp.AspNetCore.SourceGenerators.Models; + +namespace ZodSharp.AspNetCore.SourceGenerators; + +partial class ErrorTypeGenerator +{ + static void Emit(CodeWriter writer, ErrorTypeFieldModel model) + { + var parameterNames = BuildParameterNames(model.Parameters); + var parameters = BuildMethodParameters(parameterNames, writer); + + writer + .AutoGeneratedHeader() + .Using("System.Collections.Generic") + .Using("ZodSharp.AspNetCore") + .Using("ZodSharp.Core") + .NewLine(); + + using (writer.BlockNamespaceScope(model.Namespace)) + { + using ( + writer.ClassScope( + new TypeDeclarationOptions(new TypeIdentity(model.ClassName, model.Namespace), model.Accessibility) + { + IsPartial = true, + IsStatic = model.IsStatic, + IsAbstract = model.IsAbstract, + IsSealed = model.IsSealed, + } + ) + ) + { + EmitCreateMethod(writer, model, parameterNames, parameters); + writer.NewLine(); + EmitThrowMethod(writer, model, parameterNames, parameters); + } + } + } + + static void EmitCreateMethod( + CodeWriter writer, + ErrorTypeFieldModel model, + ImmutableArray parameterNames, + ImmutableArray parameters + ) + { + var dictionaryName = BuildDictionaryName(parameterNames); + + writer + .XmlSummary("Creates a ValidationError for this error type.") + .Method( + new MethodDeclarationOptions( + "Create" + PascalCase(model.FieldName), + TypeLibrary.Core.ValidationError.AsTypeReference(), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + Parameters = parameters, + }, + method => + { + method.OpenDelimitedBlock( + $"global::System.Collections.Generic.Dictionary {dictionaryName} = new({model.Parameters.Count})", + "{", + "};", + body => + { + for (var i = 0; i < model.Parameters.Count; i++) + body.Line($"[{model.Parameters[i].Surround()}] = {parameterNames[i]},"); + } + ); + + method.Line(); + method.Assignment("ErrorType", "errorType", model.FieldName); + method.Return( + $"ValidationError.Create(errorType.Code, errorType.FormatMessage({dictionaryName}), path is null ? [] : [.. path], {dictionaryName}, origin, minimum, maximum, inclusive)" + ); + } + ); + } + + static void EmitThrowMethod( + CodeWriter writer, + ErrorTypeFieldModel model, + ImmutableArray parameterNames, + ImmutableArray parameters + ) + { + writer + .XmlSummary("Throws a ZodException for this error type.") + .Method( + new MethodDeclarationOptions("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public) + { + IsStatic = true, + Attributes = [new AttributeDeclarationOptions(TypeLibrary.DoesNotReturnAttribute)], + Parameters = parameters, + }, + method => + { + var createMethod = + "Create" + + PascalCase(model.FieldName) + + "(" + + string.Join( + ", ", + parameterNames.Add("path").Add("origin").Add("minimum").Add("maximum").Add("inclusive") + ) + + ")"; + method.Throw($"new ZodException([{createMethod}])"); + } + ); + } + + static ImmutableArray BuildMethodParameters( + ImmutableArray parameterNames, + CodeWriter writer + ) + { + var objectType = PurviewTypeLibrary.System.Object.AsTypeReference().Nullable(writer); + var stringArrayType = PurviewTypeLibrary.System.String.AsTypeReference().MakeArray().Nullable(writer); + var stringType = PurviewTypeLibrary.System.String.AsTypeReference().Nullable(writer); + var intType = PurviewTypeLibrary.System.Int32.AsTypeReference().Nullable(writer); + var boolType = PurviewTypeLibrary.System.Boolean.AsTypeReference().Nullable(writer); + + var builder = ImmutableArray.CreateBuilder(parameterNames.Length + 5); + foreach (var name in parameterNames) + builder.Add(new ParameterDeclarationOptions(name, objectType)); + + builder.Add(new ParameterDeclarationOptions("path", stringArrayType) { DefaultValue = "null" }); + builder.Add(new ParameterDeclarationOptions("origin", stringType) { DefaultValue = "null" }); + builder.Add(new ParameterDeclarationOptions("minimum", intType) { DefaultValue = "null" }); + builder.Add(new ParameterDeclarationOptions("maximum", intType) { DefaultValue = "null" }); + builder.Add(new ParameterDeclarationOptions("inclusive", boolType) { DefaultValue = "null" }); + + return builder.ToImmutable(); + } + + /// + /// Camel-cases the declared parameter names while avoiding collisions with the reserved metadata + /// parameter names (path, origin, minimum, maximum, inclusive). + /// + static ImmutableArray BuildParameterNames(EquatableArray declaredNames) + { + HashSet used = ["path", "origin", "minimum", "maximum", "inclusive", "parameters"]; + + var builder = ImmutableArray.CreateBuilder(declaredNames.Count); + foreach (var declaredName in declaredNames) + { + var candidate = CamelCase(declaredName); + while (!used.Add(candidate)) + candidate += "Value"; + + builder.Add(candidate); + } + + return builder.ToImmutable(); + } + + /// + /// Chooses a local dictionary name that does not collide with any generated method parameter. + /// + static string BuildDictionaryName(ImmutableArray parameterNames) + { + HashSet used = [.. parameterNames]; + var candidate = "parameters"; + while (!used.Add(candidate)) + candidate += "Value"; + + return candidate; + } + + static string PascalCase(string name) + { + if (name.Length == 0 || !char.IsLower(name[0])) + return name; + + // Avoid pascal-casing names that are all lowercase (e.g., "url" -> "Url") + return char.ToUpperInvariant(name[0]) + name.Substring(1); + } + + static string CamelCase(string name) + { + if (name.Length == 0 || !char.IsUpper(name[0])) + return name; + + // Avoid camel-casing names that are all uppercase (e.g., "URL" -> "uRL") + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } +} diff --git a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs new file mode 100644 index 0000000..a674861 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs @@ -0,0 +1,114 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using ZodSharp.AspNetCore.SourceGenerators.Helpers; +using ZodSharp.AspNetCore.SourceGenerators.Models; + +namespace ZodSharp.AspNetCore.SourceGenerators; + +/// +/// Generates strongly-typed Create{Field}/Throw{Field} static methods for each +/// [ErrorType] field declared in a partial class. The generated methods take the error +/// type's declared Parameters as named arguments and return a ValidationError / +/// throw a ZodException. +/// +[Generator(LanguageNames.CSharp)] +public sealed partial class ErrorTypeGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context + .RegisterEmbeddedAttribute() + .RegisterPostInitializationOutput(static ctx => + ctx.AddSource("ErrorTypeAttribute.g.cs", ErrorTypeAttributeSource()) + ); + + var generationContext = IncrementalPipeline.GenerationContextValueProvider< + ErrorTypeGeneratorCapabilities, + ErrorTypeGenerator + >( + context, + static (compilation, _, _, _) => ErrorTypeGeneratorCapabilities.Create(compilation), + PropertyLibrary.DisableAspNetCoreErrorTypeGeneratorProperty + ); + + var errorTypeFields = IncrementalPipeline.ForAttributeWithMetadataName( + context, + TypeLibrary.AspNetCore.ErrorTypeAttribute, + predicate: static (node, _) => node is VariableDeclaratorSyntax, + transform: static (attributeContext, cancellationToken) => + ErrorTypeGeneratorLibrary.CreateModel(attributeContext, cancellationToken), + trackingName: "ForAttribute_ErrorTypeAttribute" + ); + + var generationModels = generationContext.CollectWith( + errorTypeFields, + static (context, fields, _) => + new ErrorTypeGenerationModel(context, new EquatableArray>(fields)), + "CollectErrorTypeFields" + ); + + context.RegisterSourceOutput( + generationModels, + static (spc, model) => + { + if (model.Context.Settings.IsSourceGeneratorDisabled) + return; + + if (!model.Context.Capabilities.HasErrorType) + return; + + foreach (var result in model.Fields) + { + foreach (var diagnostic in result.Diagnostics) + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); + + if (!result.ShouldProcess) + continue; + + try + { + var writer = model.Context.CreateCodeWriter(); + Emit(writer, result.Value); + spc.AddSource(result.Value.HintName, writer); + } + catch (Exception ex) + { + spc.ReportDiagnostic( + ReportableDiagnostic + .Create( + DiagnosticLibrary.UnhandledException, + true, + [result.Value.ClassName, ex.Message] + ) + .ToDiagnostic() + ); + } + } + } + ); + } + + static SourceText ErrorTypeAttributeSource() + { + CodeWriter writer = new(GenerationSettings.Create()); + + writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.AspNetCore.ErrorTypeAttribute); + + writer + .XmlSummary( + "Attribute to mark a static ErrorType field for strongly-typed helper generation.", + "When applied to a static ErrorType field in a partial class, Create/Throw helpers are generated at compile time." + ) + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.AspNetCore.ErrorTypeAttribute), + AttributeTargets.Field, + static _ => { }, + inherited: false, + allowMultiple: false + ); + + return writer; + } +} diff --git a/src/src/AspNetCore.SourceGenerators/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs b/src/src/AspNetCore.SourceGenerators/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs new file mode 100644 index 0000000..530487c --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs @@ -0,0 +1,12 @@ +#if NETSTANDARD2_0 || NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET6 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 + +using System.ComponentModel; + +// Compilation error of CS0518 IsExternalInit is not defined when using .NET Standard. +// re: https://mking.net/blog/error-cs0518-isexternalinit-not-defined +namespace System.Runtime.CompilerServices; + +[EditorBrowsable(EditorBrowsableState.Never)] +static class IsExternalInit; + +#endif diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs new file mode 100644 index 0000000..3f53911 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; + +namespace ZodSharp.AspNetCore.SourceGenerators.Helpers; + +/// +/// Diagnostic descriptors reported by the . +/// +static class DiagnosticLibrary +{ + const string Category = "ZodSharp.AspNetCore"; + + public static readonly DiagnosticDescriptor UnhandledException = new( + id: "ZODSASP100", + title: "Unhandled exception in the ErrorType source generator", + messageFormat: "The ErrorType source generator failed for '{0}': {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor InvalidParameters = new( + id: "ZODSASP101", + title: "Unable to extract ErrorType parameters", + messageFormat: "The Parameters of ErrorType field '{0}' in '{1}' could not be extracted; only string collection literals are supported", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); +} diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs new file mode 100644 index 0000000..0600149 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs @@ -0,0 +1,156 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using ZodSharp.AspNetCore.SourceGenerators.Models; + +namespace ZodSharp.AspNetCore.SourceGenerators.Helpers; + +/// +/// Builds the value-equatable from attribute-annotated fields. +/// +static class ErrorTypeGeneratorLibrary +{ + const string ErrorTypeMetadataName = "ZodSharp.AspNetCore.ErrorType"; + + public static GeneratorResult CreateModel( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + if (context.TargetSymbol is not IFieldSymbol field) + return GeneratorResult.Empty; + + if (!field.IsStatic) + return GeneratorResult.Empty; + + var errorTypeSymbol = context.SemanticModel.Compilation.GetTypeByMetadataName(ErrorTypeMetadataName); + if (errorTypeSymbol is null || !SymbolEqualityComparer.Default.Equals(field.Type, errorTypeSymbol)) + return GeneratorResult.Empty; + + var containing = field.ContainingType; + if ( + containing is null + || containing.TypeKind != TypeKind.Class + || containing.TypeParameters.Length > 0 + || containing.ContainingType is not null + ) + return GeneratorResult.Empty; + + if (context.TargetNode is not VariableDeclaratorSyntax declarator) + return GeneratorResult.Empty; + + var classDeclaration = declarator.FirstAncestorOrSelf(); + if ( + classDeclaration is null + || !classDeclaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)) + ) + return GeneratorResult.Empty; + + var @namespace = containing.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : containing.ContainingNamespace.ToDisplayString(); + + var parametersResult = ExtractParameters(declarator.Initializer?.Value, context, cancellationToken); + if (!parametersResult.HasValue) + { + return GeneratorResult.Create([ + ReportableDiagnostic.Create( + DiagnosticLibrary.InvalidParameters, + isBlocking: true, + declarator.GetLocation(), + [field.Name, containing.Name] + ), + ]); + } + + var parameters = parametersResult.Value; + + return GeneratorResult.Create( + new ErrorTypeFieldModel( + @namespace, + containing.Name, + containing.DeclaredAccessibility.ToTypeDeclarationAccessibility() + ?? TypeDeclarationAccessibility.Internal, + containing.IsStatic, + containing.IsAbstract && !containing.IsStatic, + containing.IsSealed && !containing.IsStatic, + field.Name, + new EquatableArray(parameters) + ) + ); + } + + /// + /// Extracts the declared Parameters from the field initializer. Returns null + /// when a Parameters member is present but cannot be statically analysed. + /// + static ImmutableArray? ExtractParameters( + ExpressionSyntax? initializer, + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + if (initializer is null) + return []; + + var objectInitializer = initializer switch + { + ObjectCreationExpressionSyntax objectCreation => objectCreation.Initializer, + ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.Initializer, + _ => null, + }; + + if (objectInitializer is null) + return []; + + var parametersExpression = FindParametersExpression(objectInitializer); + if (parametersExpression is null) + return []; + + var elements = CollectionElements(parametersExpression); + if (elements is null) + return null; + + var builder = ImmutableArray.CreateBuilder(); + foreach (var element in elements) + { + var constant = context.SemanticModel.GetConstantValue(element, cancellationToken); + if (constant.HasValue && constant.Value is string value) + builder.Add(value); + } + + return builder.ToImmutable(); + } + + static ExpressionSyntax? FindParametersExpression(InitializerExpressionSyntax objectInitializer) + { + foreach (var element in objectInitializer.Expressions) + { + if ( + element is AssignmentExpressionSyntax { Left: IdentifierNameSyntax identifier } assignment + && identifier.Identifier.ValueText == "Parameters" + ) + return assignment.Right; + } + + return null; + } + + /// + /// Returns the collection elements for recognised collection syntax, or null when the + /// expression is not a statically-analysable collection literal. + /// + static IEnumerable? CollectionElements(ExpressionSyntax expression) + { + return expression switch + { + CollectionExpressionSyntax collection => collection + .Elements.OfType() + .Select(static element => element.Expression), + ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions, + ArrayCreationExpressionSyntax array => array.Initializer?.Expressions ?? [], + _ => null, + }; + } +} diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/PropertyLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/PropertyLibrary.cs new file mode 100644 index 0000000..aee7e85 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Helpers/PropertyLibrary.cs @@ -0,0 +1,9 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Helpers; + +/// +/// MSBuild property names consumed by the . +/// +static class PropertyLibrary +{ + public const string DisableAspNetCoreErrorTypeGeneratorProperty = "DisableAspNetCoreErrorTypeGenerator"; +} diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs new file mode 100644 index 0000000..2eab0f0 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs @@ -0,0 +1,26 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Helpers; + +/// +/// Type identities used by the when emitting source. +/// +static class TypeLibrary +{ + public static class AspNetCore + { + public static readonly TypeIdentity ErrorType = new("ErrorType", "ZodSharp.AspNetCore"); + + public static readonly TypeIdentity ErrorTypeAttribute = new("ErrorTypeAttribute", "ZodSharp.AspNetCore"); + } + + public static class Core + { + public static readonly TypeIdentity ValidationError = new("ValidationError", "ZodSharp.Core"); + + public static readonly TypeIdentity ZodException = new("ZodException", "ZodSharp.Core"); + } + + public static readonly TypeIdentity DoesNotReturnAttribute = new( + "DoesNotReturnAttribute", + "System.Diagnostics.CodeAnalysis" + ); +} diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs new file mode 100644 index 0000000..95b3266 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs @@ -0,0 +1,23 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Models; + +/// +/// Value-equatable pipeline model describing a static ErrorType field whose containing +/// type is a partial class. All Roslyn objects are removed before this point. +/// +sealed record ErrorTypeFieldModel( + string Namespace, + string ClassName, + TypeDeclarationAccessibility Accessibility, + bool IsStatic, + bool IsAbstract, + bool IsSealed, + string FieldName, + EquatableArray Parameters +) +{ + /// + /// Deterministic, per-field hint name. + /// + public string HintName => + Namespace.Length == 0 ? $"{ClassName}.{FieldName}.g.cs" : $"{Namespace}.{ClassName}.{FieldName}.g.cs"; +} diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGenerationModel.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGenerationModel.cs new file mode 100644 index 0000000..f214773 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGenerationModel.cs @@ -0,0 +1,9 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Models; + +/// +/// Value-equatable model combining the generation context with the discovered error type fields. +/// +sealed record ErrorTypeGenerationModel( + GenerationContext Context, + EquatableArray> Fields +); diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs new file mode 100644 index 0000000..2466354 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs @@ -0,0 +1,15 @@ +using Microsoft.CodeAnalysis; + +namespace ZodSharp.AspNetCore.SourceGenerators.Models; + +/// +/// Value-equatable capability facts for the . No Roslyn +/// objects are retained in this model. +/// +sealed record ErrorTypeGeneratorCapabilities(bool HasErrorType) : IGenerationCapabilities +{ + public static ErrorTypeGeneratorCapabilities Create(Compilation compilation) => + new(compilation.GetTypeByMetadataName(ErrorTypeMetadataName) is not null); + + const string ErrorTypeMetadataName = "ZodSharp.AspNetCore.ErrorType"; +} diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index f845917..11509f2 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -21,5 +21,11 @@ ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> + diff --git a/src/src/AspNetCore/ErrorType.cs b/src/src/AspNetCore/ErrorType.cs index 1ceb8f5..98f86f8 100644 --- a/src/src/AspNetCore/ErrorType.cs +++ b/src/src/AspNetCore/ErrorType.cs @@ -1,9 +1,12 @@ +using System.Globalization; +using System.Text; + 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 . +/// substituted from the error's . /// /// The validation error code this error type maps to. /// An optional human-readable description of the error. @@ -16,13 +19,14 @@ namespace ZodSharp.AspNetCore; /// /// An optional message template with named placeholders (for example /// "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save"). Placeholders are -/// substituted from ; placeholders without a +/// substituted from ; placeholders without a /// matching value are left as-is so templating gaps stay visible. /// /// /// -/// public static class ConcurrentErrorType +/// public static partial class ConcurrentErrorType /// { +/// [ErrorType] /// public static readonly ErrorType SaveFailed = new( /// Code: "aggregate_save_failed", /// Description: "The aggregate could not be saved.", @@ -33,6 +37,9 @@ namespace ZodSharp.AspNetCore; /// }; /// } /// +/// Marking the field with [ErrorType] and declaring the containing class partial +/// lets the bundled source generator add CreateSaveFailed/ThrowSaveFailed helpers +/// with one named parameter per declared parameter. /// public sealed record ErrorType( string Code, @@ -47,4 +54,92 @@ public sealed record ErrorType( /// The named placeholders expected by (for example "AggregateId"). /// public IReadOnlyList Parameters { get; init; } = []; + + /// + /// Formats using the supplied named parameter values. Placeholders + /// that have no matching value are left as-is so templating gaps stay visible. When + /// is null, falls back to and then + /// to . + /// + /// + /// The named parameter values (for example the error's + /// ). + /// + public string FormatMessage(IReadOnlyDictionary? parameters) + { + if (MessageFormat is null) + return Description ?? Code; + + if (parameters is null || parameters.Count == 0) + return MessageFormat; + + // 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 < MessageFormat.Length; i++) + { + if (MessageFormat[i] != '{' || i + 1 >= MessageFormat.Length || !IsIdentifierStart(MessageFormat[i + 1])) + continue; + + var close = MessageFormat.IndexOf('}', i + 1); + if (close < 0) + break; + + if (!IsIdentifierTail(MessageFormat, i + 2, close)) + { + i = close; + continue; + } + + var name = MessageFormat.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(MessageFormat.Length + 8); + builder.Append(MessageFormat, start, i - start); + builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + + start = close + 1; + i = close; + } + + if (builder is null) + return MessageFormat; + + builder.Append(MessageFormat, start, MessageFormat.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/ErrorTypeRegistry.cs b/src/src/AspNetCore/ErrorTypeRegistry.cs index 34cc932..58204f8 100644 --- a/src/src/AspNetCore/ErrorTypeRegistry.cs +++ b/src/src/AspNetCore/ErrorTypeRegistry.cs @@ -4,7 +4,7 @@ namespace ZodSharp.AspNetCore; /// /// A code-keyed registry of definitions that allows users to register -/// error types once and resolve them by when +/// error types once and resolve them by when /// building ProblemDetails responses. /// public sealed class ErrorTypeRegistry diff --git a/src/src/AspNetCore/ProblemDetailsMapper.cs b/src/src/AspNetCore/ProblemDetailsMapper.cs index a37db7e..7d7c37a 100644 --- a/src/src/AspNetCore/ProblemDetailsMapper.cs +++ b/src/src/AspNetCore/ProblemDetailsMapper.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Globalization; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; @@ -49,7 +48,7 @@ bool formatMessages var message = formatMessages && errorType?.MessageFormat is not null - ? FormatMessage(errorType.MessageFormat, error.Parameters) + ? errorType.FormatMessage(error.Parameters) : error.Message; messages.Add(message); @@ -131,79 +130,4 @@ static void MergeParameters(HttpValidationProblemDetails details, ImmutableArray } } } - - 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 51df6c9..352eb46 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -55,7 +55,7 @@ app.UseExceptionHandler(); Register an `ErrorType` and map error codes to HTTP statuses and formatted messages: ```csharp -public static class ConcurrentErrorType +public static partial class ConcurrentErrorType { public static readonly ErrorType SaveFailed = new( Code: "aggregate_save_failed", @@ -75,6 +75,31 @@ A `ValidationError` carrying `parameters` such as `["AggregateId"] = "agg-123"` analyzer `ZODSASP001` (bundled with the package) warns when a `MessageFormat` placeholder is not declared in `Parameters`. +### Generated `Create` / `Throw` helpers + +Because the class above is `partial`, the bundled source generator adds strongly typed helpers derived +from the declared `Parameters`: + +```csharp +// ValidationError with the code, the formatted message, and the named parameters: +var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); + +// ZodException carrying that ValidationError: +ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); + +// Path and structured issue metadata can be populated too: +var error = ConcurrentErrorType.CreateSaveFailed( + "agg-123", + "Invoice", + path: ["order", "items", "[0]"], + origin: "collection", + minimum: 1, + maximum: 10, + inclusive: true); +``` + +The analyzer `ZODSASP002` warns when an `ErrorType` field's containing class is not declared `partial`. + ## Documentation - [Homepage](https://purview.dev/projects/zodsharp/) diff --git a/src/src/AspNetCore/ZodExceptionExtensions.cs b/src/src/AspNetCore/ZodExceptionExtensions.cs index 518e542..45e589f 100644 --- a/src/src/AspNetCore/ZodExceptionExtensions.cs +++ b/src/src/AspNetCore/ZodExceptionExtensions.cs @@ -14,7 +14,7 @@ public static class ZodExceptionExtensions /// public static HttpValidationProblemDetails ToHttpValidationProblemDetails( this ZodException exception, - int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + int statusCode = StatusCodes.Status400BadRequest ) { ArgumentNullException.ThrowIfNull(exception); @@ -34,7 +34,7 @@ public static HttpValidationProblemDetails ToHttpValidationProblemDetails( public static HttpValidationProblemDetails ToHttpValidationProblemDetails( this ZodException exception, ErrorTypeRegistry registry, - int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + int statusCode = StatusCodes.Status400BadRequest ) { ArgumentNullException.ThrowIfNull(exception); @@ -49,7 +49,7 @@ public static HttpValidationProblemDetails ToHttpValidationProblemDetails( public static HttpValidationProblemDetails ToHttpValidationProblemDetails( this ZodException exception, Func lookup, - int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + int statusCode = StatusCodes.Status400BadRequest ) { ArgumentNullException.ThrowIfNull(exception); @@ -62,7 +62,7 @@ public static HttpValidationProblemDetails ToHttpValidationProblemDetails( /// public static ValidationProblemDetails ToValidationProblemDetails( this ZodException exception, - int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + int statusCode = StatusCodes.Status400BadRequest ) { var details = exception.ToHttpValidationProblemDetails(statusCode); @@ -76,7 +76,7 @@ public static ValidationProblemDetails ToValidationProblemDetails( public static ValidationProblemDetails ToValidationProblemDetails( this ZodException exception, ErrorTypeRegistry registry, - int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + int statusCode = StatusCodes.Status400BadRequest ) { var details = exception.ToHttpValidationProblemDetails(registry, statusCode); @@ -90,7 +90,7 @@ public static ValidationProblemDetails ToValidationProblemDetails( public static ValidationProblemDetails ToValidationProblemDetails( this ZodException exception, Func lookup, - int statusCode = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest + int statusCode = StatusCodes.Status400BadRequest ) { var details = exception.ToHttpValidationProblemDetails(lookup, statusCode); diff --git a/src/src/AspNetCore/ZodExceptionHandler.cs b/src/src/AspNetCore/ZodExceptionHandler.cs index 1206d9e..a4dc652 100644 --- a/src/src/AspNetCore/ZodExceptionHandler.cs +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -6,8 +6,8 @@ namespace ZodSharp.AspNetCore; /// -/// An that converts a thrown -/// into a standard response, +/// An that converts a thrown +/// into a standard response, /// resolving s from the configured . /// /// @@ -40,7 +40,7 @@ CancellationToken cancellationToken { ArgumentNullException.ThrowIfNull(httpContext); - if (exception is not ZodSharp.Core.ZodException zodException) + if (exception is not Core.ZodException zodException) return false; var defaultStatusCode = diff --git a/src/src/AspNetCore/ZodProblemDetailsOptions.cs b/src/src/AspNetCore/ZodProblemDetailsOptions.cs index c4a0e87..36e6cfc 100644 --- a/src/src/AspNetCore/ZodProblemDetailsOptions.cs +++ b/src/src/AspNetCore/ZodProblemDetailsOptions.cs @@ -15,7 +15,7 @@ public sealed class ZodProblemDetailsOptions /// /// When true, an error's message is formatted from the matched - /// using the error's . Defaults to true. + /// using the error's . Defaults to true. /// public bool FormatMessages { get; set; } = true; @@ -23,7 +23,7 @@ public sealed class ZodProblemDetailsOptions /// 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; } + public Func, int>? StatusCodeSelector { get; set; } /// /// Registers an with the configured . diff --git a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs index 1b5d60f..df88e72 100644 --- a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs +++ b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs @@ -7,7 +7,7 @@ namespace ZodSharp; /// /// Performance and allocation profile for converting a thrown into -/// . +/// . /// [MemoryDiagnoser] [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] diff --git a/src/src/Benchmarks/UuidPerformanceTests.cs b/src/src/Benchmarks/UuidPerformanceTests.cs index 3225e78..f5c7213 100644 --- a/src/src/Benchmarks/UuidPerformanceTests.cs +++ b/src/src/Benchmarks/UuidPerformanceTests.cs @@ -14,10 +14,10 @@ namespace ZodSharp; [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] public class UuidPerformanceTests { - static readonly string ValidUuidV4 = "550e8400-e29b-41d4-a716-446655440000"; - static readonly string ValidUuidV7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; - static readonly string InvalidUuid = "550e8400-e29b-41d4-a716"; - static readonly string NilUuid = "00000000-0000-0000-0000-000000000000"; + const string ValidUuidV4 = "550e8400-e29b-41d4-a716-446655440000"; + const string ValidUuidV7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + const string InvalidUuid = "550e8400-e29b-41d4-a716"; + const string NilUuid = "00000000-0000-0000-0000-000000000000"; static readonly Regex LegacyUuidRegex = new( @"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 892cdca..cb230d4 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -50,6 +50,7 @@ public bool IsValid(in string value) if (_version is UuidVersion version) return value[14] == (char)('0' + (int)version) && IsValidVariant(value[19]); + // No specific version required; just check that the version and variant are valid. return IsValidVersionless(value); } @@ -78,6 +79,7 @@ static bool IsValidVersionless(string value) if (version is < '1' or > '8') return false; + // Check that the variant is valid (8, 9, a, b). return IsValidVariant(value[19]); } diff --git a/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs b/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs index c8f66ac..df2f0ed 100644 --- a/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs +++ b/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs @@ -60,6 +60,7 @@ static bool SameValue(T left, T right) if (typeof(T).IsValueType) return EqualityComparer.Default.Equals(left, right); + // Reference types are interchangeable only when they are the same instance. return ReferenceEquals(left, right); } diff --git a/src/src/ZodSharp/Schemas/ZodArray.cs b/src/src/ZodSharp/Schemas/ZodArray.cs index 8c9d28c..72a4e98 100644 --- a/src/src/ZodSharp/Schemas/ZodArray.cs +++ b/src/src/ZodSharp/Schemas/ZodArray.cs @@ -105,6 +105,7 @@ static bool SameValue(T left, T right) if (typeof(T).IsValueType) return EqualityComparer.Default.Equals(left, right); + // Reference types are interchangeable only when they are the same instance. return ReferenceEquals(left, right); } diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj b/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj index dadeff4..0ce65a1 100644 --- a/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj +++ b/src/tests/AspNetCore.Analyzers.UnitTests/AspNetCore.Analyzers.UnitTests.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs new file mode 100644 index 0000000..c04efe6 --- /dev/null +++ b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs @@ -0,0 +1,103 @@ +using ZodSharp.AspNetCore.Analyzers.Infra; + +namespace ZodSharp.AspNetCore.Analyzers; + +public class ErrorTypePartialClassAnalyzerTests : ErrorTypePartialClassAnalyzerTestBase +{ + [Test] + public async Task GivenNonPartialContainingClass_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new(Code: "aggregate_save_failed") + { + Parameters = ["OrderId", "AggregateType"] + }; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(ErrorTypePartialClassAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenPartialContainingClass_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static partial class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new(Code: "aggregate_save_failed") + { + Parameters = ["OrderId", "AggregateType"] + }; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenNonStaticErrorTypeField_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public partial class ConcurrentErrorType + { + [ErrorType] + public readonly ErrorType SaveFailed = new(Code: "aggregate_save_failed"); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic("ZODSASP003"); + } + + [Test] + public async Task GivenUnattributedErrorTypeField_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + public static readonly ErrorType SaveFailed = new(Code: "aggregate_save_failed"); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenNonErrorTypeField_HasNoDiagnostics(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing; + + public sealed record Other(string Code); + + public static partial class SomeType + { + [ErrorType] + public static readonly Other Value = new("something"); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs index 70d0fcb..9ba302b 100644 --- a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs +++ b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs @@ -5,21 +5,16 @@ public sealed record ErrorTypeAnalyzerTestOptions : AnalyzerTestOptions public ErrorTypeAnalyzerTestOptions() { AdditionalNamespaces = ["ZodSharp.AspNetCore"]; + AdditionalAssemblyTypes = [typeof(ErrorType)]; + // The generator emits this attribute at compile time; the analyzer-only harness + // provides the same surface so the analyzer can resolve it. 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; } = []; - } + [global::System.AttributeUsage(global::System.AttributeTargets.Field, AllowMultiple = false, Inherited = false)] + public sealed class ErrorTypeAttribute : global::System.Attribute { } """, ]; } diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypePartialClassAnalyzerTestBase.cs b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypePartialClassAnalyzerTestBase.cs new file mode 100644 index 0000000..ccac7fe --- /dev/null +++ b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypePartialClassAnalyzerTestBase.cs @@ -0,0 +1,7 @@ +namespace ZodSharp.AspNetCore.Analyzers.Infra; + +public abstract class ErrorTypePartialClassAnalyzerTestBase + : TUnitDiagnosticAnalyzerTestBase +{ + // Empty +} diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/AspNetCore.SourceGenerators.UnitTests.csproj b/src/tests/AspNetCore.SourceGenerators.UnitTests/AspNetCore.SourceGenerators.UnitTests.csproj new file mode 100644 index 0000000..956021a --- /dev/null +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/AspNetCore.SourceGenerators.UnitTests.csproj @@ -0,0 +1,22 @@ + + + Purview.ZodSharp.AspNetCore.SourceGenerators.UnitTests + + + + + + + + + + + + + + diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs new file mode 100644 index 0000000..d68ec14 --- /dev/null +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs @@ -0,0 +1,118 @@ +using System.Collections.Immutable; +using ZodSharp.AspNetCore.SourceGenerators.Infra; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +namespace ZodSharp.AspNetCore.SourceGenerators; + +public class ErrorTypeGeneratorCacheTests : ErrorTypeGeneratorTestBase +{ + const string Source = """ + namespace Testing; + + public static partial class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + HttpStatus: 409) + { + Parameters = ["OrderId", "AggregateType"] + }; + } + """; + + const string ChangedSource = """ + namespace Testing; + + public static partial class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + HttpStatus: 409) + { + Parameters = ["OrderId"] + }; + } + """; + + static readonly string[] FrameworkStages = + [ + "GetGenerationContext_ErrorTypeGeneratorCapabilities", + "GetGenerationConfiguration", + "ForAttribute_ErrorTypeAttribute", + "CollectErrorTypeFields", + ]; + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) + { + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = + [ + .. pair.Value.SelectMany(static step => step.Outputs.Select(static output => output.Reason)), + ]; + + return builder.ToImmutable(); + } + + static bool IsCachedOrUnchanged(StepReason reason) => reason is StepReason.Cached or StepReason.Unchanged; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + // Arrange/Act + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + // Assert + var second = StepReasons(result.Runs[1]); + await Assert.That(second).IsNotEmpty(); + await Assert + .That( + FrameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) && reasons.All(IsCachedOrUnchanged) + ) + ) + .IsTrue(); + } + + [Test] + public async Task ChangedParameters_MarksAttributeStageModified_PropertyStagesStayCached( + CancellationToken cancellationToken + ) + { + // Arrange + IncrementalRunInput[] inputs = [new([Source]), new([ChangedSource])]; + + // Act + var result = await GenerateIncrementalAsync(inputs, cancellationToken: cancellationToken); + + // Assert + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_ErrorTypeAttribute"]).Contains(StepReason.Modified); + await Assert + .That(second["GetGenerationContext_ErrorTypeGeneratorCapabilities"].All(IsCachedOrUnchanged)) + .IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached( + CancellationToken cancellationToken + ) + { + // Arrange + IncrementalRunInput[] inputs = + [ + new([Source]), + new([Source], [("build_property.DisableAspNetCoreErrorTypeGenerator", "true")]), + ]; + + // Act + var result = await GenerateIncrementalAsync(inputs, cancellationToken: cancellationToken); + + // Assert + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetGenerationContext_ErrorTypeGeneratorCapabilities"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_ErrorTypeAttribute"].All(IsCachedOrUnchanged)).IsTrue(); + } +} diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs new file mode 100644 index 0000000..d54c829 --- /dev/null +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs @@ -0,0 +1,227 @@ +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework; +using ZodSharp.AspNetCore.SourceGenerators.Infra; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore.SourceGenerators; + +public class ErrorTypeGeneratorTests : ErrorTypeGeneratorTestBase +{ + const string Source = """ + namespace Testing; + + public static partial 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: 409, + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save") + { + Parameters = ["OrderId", "AggregateType"] + }; + } + """; + + [Test] + public async Task GivenErrorTypeFieldWithParameters_GeneratesCreateAndThrowMethods( + CancellationToken cancellationToken + ) + { + // Arrange + var source = Source; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + var query = result.Generated(); + + var nullableObject = query.MakeNullable(TypeReference.Create()); + var nullableString = query.MakeNullable(TypeReference.Create()); + var nullableStringArray = query.MakeNullable(TypeReference.Create().MakeArray()); + var nullableInt = query.MakeNullable(TypeReference.Create()); + var nullableBool = query.MakeNullable(TypeReference.Create()); + + await Assert + .That(query) + .HasGeneratedMethod( + "CreateSaveFailed", + [ + nullableObject, + nullableObject, + nullableStringArray, + nullableString, + nullableInt, + nullableInt, + nullableBool, + ] + ); + await Assert + .That(query) + .HasGeneratedMethodReturnType("CreateSaveFailed", TypeReference.Create()); + + await Assert + .That(query) + .HasGeneratedMethod( + "ThrowSaveFailed", + [ + nullableObject, + nullableObject, + nullableStringArray, + nullableString, + nullableInt, + nullableInt, + nullableBool, + ] + ); + + var throwMethod = query.GetMethod("ThrowSaveFailed").Node; + await Assert.That(throwMethod.AttributeLists.ToString()).Contains("DoesNotReturn"); + } + + [Test] + public async Task GivenErrorTypeField_GeneratedCreateCarriesParametersAndFormatsMessage( + CancellationToken cancellationToken + ) + { + // Arrange + var source = Source; + + // Act + var result = await GenerateAsync(source, cancellationToken); + var generated = result.GetSource(); + + // Assert + await Assert.That(generated).ContainsGeneratedCode("[\"OrderId\"] = orderId,"); + await Assert.That(generated).ContainsGeneratedCode("[\"AggregateType\"] = aggregateType,"); + await Assert.That(generated).ContainsGeneratedCode("errorType.FormatMessage("); + await Assert.That(generated).ContainsGeneratedCode("throw new ZodException([CreateSaveFailed("); + } + + [Test] + public async Task GivenErrorTypeFieldWithoutParameters_GeneratesParameterlessMethods( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + public static partial class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Description: "The order could not be saved.", + HttpStatus: 409); + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + var generated = result.GetSource(); + await Assert + .That(generated) + .ContainsGeneratedCode("public static global::ZodSharp.Core.ValidationError CreateSaveFailed("); + await Assert + .That(generated) + .ContainsGeneratedCode( + "global::System.Collections.Generic.Dictionary parameters = new(0)" + ); + } + + [Test] + public async Task GivenParametersThatCollideWithMetadataNames_MetadataParametersAreDisambiguated( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + public static partial class SizeErrorType + { + [ErrorType] + public static readonly ErrorType TooShort = new( + Code: "too_small", + MessageFormat: "'{Field}' must be at least {Minimum} characters.") + { + Parameters = ["Field", "Minimum"] + }; + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + var query = result.Generated(); + + var createMethod = query.GetMethod("CreateTooShort").Node; + var parameterNames = createMethod + .ParameterList.Parameters.Select(static p => p.Identifier.ValueText) + .ToImmutableArray(); + + await Assert.That(parameterNames).Contains("field"); + await Assert.That(parameterNames).Contains("minimumValue"); + await Assert.That(parameterNames).Contains("minimum"); + } + + [Test] + public async Task GivenNonPartialContainingClass_DoesNotGenerate(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + HttpStatus: 409) + { + Parameters = ["OrderId", "AggregateType"] + }; + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result.Generated().HasMethod("CreateSaveFailed")).IsFalse(); + } + + [Test] + public async Task GivenFieldOfAnotherType_DoesNotGenerate(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + public sealed record Other(string Code); + + public static partial class SomeType + { + [ErrorType] + public static readonly Other Value = new("something"); + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result.Generated().HasMethod("CreateValue")).IsFalse(); + } +} diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestBase.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestBase.cs new file mode 100644 index 0000000..b02c170 --- /dev/null +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestBase.cs @@ -0,0 +1,7 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Infra; + +public abstract class ErrorTypeGeneratorTestBase + : TUnitSourceGeneratorTestBase +{ + // Empty +} diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs new file mode 100644 index 0000000..11a8643 --- /dev/null +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; +using ZodSharp.AspNetCore.SourceGenerators.Helpers; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore.SourceGenerators.Infra; + +public sealed record ErrorTypeGeneratorTestOptions : SourceGeneratorTestOptions +{ + public ErrorTypeGeneratorTestOptions() + { + AdditionalNamespaces = ["ZodSharp.AspNetCore", "ZodSharp.Core"]; + AdditionalAssemblyTypes = + [ + typeof(ErrorType), + typeof(ValidationError), + typeof(ZodException), + typeof(ImmutableArray), + ]; + ExcludeGeneratedSourceHintNames = ["ErrorTypeAttribute", "EmbeddedAttribute"]; + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableAspNetCoreErrorTypeGeneratorProperty; + } +} diff --git a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj index 388be1d..e365d2f 100644 --- a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj +++ b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj @@ -17,5 +17,11 @@ ReferenceOutputAssembly="false" PrivateAssets="all" /> + diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs new file mode 100644 index 0000000..c076ce5 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Http; + +namespace ZodSharp.AspNetCore; + +public class ErrorTypeFormatMessageTests +{ + [Test] + public async Task GivenMessageFormatWithParameters_FormatsPlaceholders() + { + // Arrange + ErrorType errorType = new( + "aggregate_save_failed", + Description: "Save failed.", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = ["OrderId", "AggregateType"], + }; + + // Act + var message = errorType.FormatMessage( + new Dictionary { ["OrderId"] = "ord-1", ["AggregateType"] = "Order" } + ); + + // Assert + await Assert.That(message).IsEqualTo("Order 'ord-1' (of type Order) failed to save"); + } + + [Test] + public async Task GivenMessageFormat_MissingParameterLeavesPlaceholderAsIs() + { + // Arrange + ErrorType errorType = new( + "save_failed", + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = ["AggregateId", "AggregateType"], + }; + + // Act + var message = errorType.FormatMessage(new Dictionary { ["AggregateId"] = "agg-123" }); + + // Assert + await Assert.That(message).IsEqualTo("Aggregate 'agg-123' (of type {AggregateType}) failed to save"); + } + + [Test] + public async Task GivenNoMessageFormat_FallsBackToDescription() + { + // Arrange + ErrorType errorType = new( + "locked", + Description: "The resource is locked.", + HttpStatus: StatusCodes.Status423Locked + ); + + // Act + var message = errorType.FormatMessage(parameters: null); + + // Assert + await Assert.That(message).IsEqualTo("The resource is locked."); + } + + [Test] + public async Task GivenNoMessageFormatOrDescription_FallsBackToCode() + { + // Arrange + ErrorType errorType = new("conflict", HttpStatus: StatusCodes.Status409Conflict); + + // Act + var message = errorType.FormatMessage(parameters: null); + + // Assert + await Assert.That(message).IsEqualTo("conflict"); + } + + [Test] + public async Task GivenMessageFormatWithNullParameters_ReturnsFormatUnchanged() + { + // Arrange + ErrorType errorType = new("save_failed", MessageFormat: "Save of '{AggregateId}' failed."); + + // Act + var message = errorType.FormatMessage(parameters: null); + + // Assert + await Assert.That(message).IsEqualTo("Save of '{AggregateId}' failed."); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs index e58acfe..b86faed 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeRegistryTests.cs @@ -28,10 +28,10 @@ public async Task Register_DuplicateCode_Throws() registry.Register(new ErrorType("duplicate")); // Act - var act = () => registry.Register(new ErrorType("duplicate")); + void IAct() => registry.Register(new ErrorType("duplicate")); // Assert - await Assert.That(act).Throws(); + await Assert.That(IAct).Throws(); } [Test] diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs new file mode 100644 index 0000000..9eac11d --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +public static partial 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" + ) + { + Parameters = ["OrderId", "AggregateType"], + }; +} + +public class ErrorTypeSourceGeneratorIntegrationTests +{ + [Test] + public async Task GivenGeneratedCreate_PopulatesCodeParametersAndPath() + { + // Arrange/Act + var error = ConcurrentErrorType.CreateSaveFailed("ord-42", "Order", path: ["orderId"]); + + // Assert + await Assert.That(error.Code).IsEqualTo("aggregate_save_failed"); + await Assert.That(error.Path).IsEquivalentTo(["orderId"]); + await Assert.That(error.Parameters).IsNotNull(); + await Assert.That(error.Parameters!["OrderId"]).IsEqualTo("ord-42"); + await Assert.That(error.Parameters!["AggregateType"]).IsEqualTo("Order"); + await Assert.That(error.Message).IsEqualTo("Order 'ord-42' (of type Order) failed to save"); + } + + [Test] + public async Task GivenGeneratedCreate_MapsToConflictProblemDetails() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(ConcurrentErrorType.SaveFailed); + + // Act + var error = ConcurrentErrorType.CreateSaveFailed("ord-42", "Order"); + var details = new ZodException([error]).ToHttpValidationProblemDetails(registry); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status409Conflict); + await Assert + .That(details.Errors[string.Empty]) + .IsEquivalentTo(["Order 'ord-42' (of type Order) failed to save"]); + } + + [Test] + public async Task GivenGeneratedThrow_ThrowsZodExceptionWithTheError() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(ConcurrentErrorType.SaveFailed); + + // Act + ZodException? thrown = null; + try + { + ConcurrentErrorType.ThrowSaveFailed("ord-42", "Order"); + } + catch (ZodException ex) + { + thrown = ex; + } + + // Assert + await Assert.That(thrown).IsNotNull(); + await Assert.That(thrown!.Errors).HasSingleItem(); + await Assert.That(thrown!.Errors[0].Parameters!["OrderId"]).IsEqualTo("ord-42"); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs index ccaf0df..4c265e8 100644 --- a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs @@ -119,8 +119,7 @@ public async Task TryHandleAsync_GivenOtherException_ReturnsFalse(CancellationTo static DefaultHttpContext NewContext() { - DefaultHttpContext httpContext = new(); - httpContext.TraceIdentifier = "trace-1"; + DefaultHttpContext httpContext = new() { TraceIdentifier = "trace-1" }; httpContext.Response.Body = new MemoryStream(); return httpContext; }