diff --git a/.gitignore b/.gitignore index 3100a06..1a5096a 100644 --- a/.gitignore +++ b/.gitignore @@ -660,3 +660,4 @@ sketch BenchmarkDotNet.Artifacts/ .tools/purview-build/** +src/tests/cross-platform/ diff --git a/Directory.Packages.props b/Directory.Packages.props index f323841..d272ab1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,7 @@ 5.9.0 5.9.0 1.68.4 - 1.0.0-prerelease.42 + 1.0.0-prerelease.44 10.0.12 diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 28c26a8..15b6563 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -101,16 +101,32 @@ public static partial class ConcurrentErrorType 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"] - }; + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new("AggregateId", typeof(string)), + new("AggregateType", typeof(string)) + ]); } // Register once at startup: ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); ``` +Parameters can also be declared with the `ErrorType.Param("Name")` helper instead of an explicit +`typeof(...)`: + +```csharp +Parameters: +[ + new("AggregateId", typeof(string)), + ErrorType.Param("AggregateType") +] +``` + +Both the bundled `ZODSASP001` analyzer and the source generator treat `ErrorType.Param` entries +exactly like any other declared parameter, so placeholder checking and helper generation are unchanged. + When an error carries that code, the response status, title, and message are derived automatically: ```csharp @@ -132,10 +148,11 @@ throw new ZodException([ 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`: +strongly typed parameter per entry in `Parameters` (the declared `typeof(...)` type, or the +`ErrorType.Param` generic type argument): ```csharp -// Returns a ValidationError with the code, the formatted message, and the named parameters. +// Returns a ValidationError with the code, the formatted message, and the typed parameters. var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); // Throws a ZodException carrying the same ValidationError. @@ -152,7 +169,8 @@ var error = ConcurrentErrorType.CreateSaveFailed( inclusive: true); ``` -The generated `Create` builds the `parameters` dictionary and sets the message from +The generated `Create` builds a typed `ErrorTypeParameters` instance (validated against the declared +parameter types and exposed through `ValidationError.Parameters`) 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` diff --git a/package.json b/package.json index 7e37c75..e7ae7ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.11", + "version": "2.0.0-prerelease.12", "private": true, "license": "MIT", "author": { diff --git a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs index bdb82fd..fe331f2 100644 --- a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs +++ b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs @@ -116,6 +116,17 @@ static void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSy /// static HashSet? GetDeclaredParameters(IObjectCreationOperation creation) { + // `Parameters` may be supplied as a constructor argument (the canonical form) or assigned + // in an object initializer. + foreach (var argument in creation.Arguments) + { + if (argument.ArgumentKind != ArgumentKind.Explicit) + continue; + + if (argument.Parameter?.Name == "Parameters") + return ExtractStrings(argument.Value); + } + if (creation.Initializer is null) return []; @@ -143,10 +154,7 @@ initializer is ISimpleAssignmentOperation { HashSet names = new(StringComparer.Ordinal); foreach (var element in array.Initializer.ElementValues) - { - if (UnwrapConversion(element).ConstantValue.Value is string value) - names.Add(value); - } + AddParameterName(element, names); return names; } @@ -155,13 +163,7 @@ initializer is ISimpleAssignmentOperation { 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); - } + AddParameterName(element, names); return names; } @@ -171,6 +173,38 @@ initializer is ISimpleAssignmentOperation } } + static void AddParameterName(IOperation element, HashSet names) + { + if (element is ISpreadOperation) + return; + + // Target-typed `new(...)` collection elements arrive wrapped in an implicit conversion. + element = UnwrapConversion(element); + + // A declared parameter is `new ErrorTypeParameter("OrderId", typeof(string))`; the name is + // the first constructor argument. + if ( + element is IObjectCreationOperation creation + && creation.Arguments.Length > 0 + && creation.Arguments[0] is { Parameter.Name: "Name" } nameArgument + && UnwrapConversion(nameArgument.Value).ConstantValue.Value is string name + ) + names.Add(name); + else if ( + element is IInvocationOperation { TargetMethod.Name: "Param" } invocation + && IsErrorTypeParam(invocation.TargetMethod) + && invocation.Arguments.Length > 0 + && UnwrapConversion(invocation.Arguments[0].Value).ConstantValue.Value is string paramName + ) + names.Add(paramName); + else if (element.ConstantValue.Value is string legacy) + names.Add(legacy); + } + + static bool IsErrorTypeParam(IMethodSymbol method) => + method.ContainingType?.Name == "ErrorType" + && method.ContainingType?.ContainingNamespace?.ToDisplayString() == "ZodSharp.AspNetCore"; + static IOperation UnwrapConversion(IOperation operation) => operation is IConversionOperation { IsImplicit: true } conversion ? UnwrapConversion(conversion.Operand) diff --git a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs index 8d2a2c5..9ab5b76 100644 --- a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs +++ b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using ZodSharp.AspNetCore.SourceGenerators.Helpers; using ZodSharp.AspNetCore.SourceGenerators.Models; namespace ZodSharp.AspNetCore.SourceGenerators; @@ -9,7 +8,7 @@ partial class ErrorTypeGenerator static void Emit(CodeWriter writer, ErrorTypeFieldModel model) { var parameterNames = BuildParameterNames(model.Parameters); - var parameters = BuildMethodParameters(parameterNames, writer); + var parameters = BuildMethodParameters(model, parameterNames, writer); writer .AutoGeneratedHeader() @@ -22,7 +21,7 @@ static void Emit(CodeWriter writer, ErrorTypeFieldModel model) { using ( writer.ClassScope( - new TypeDeclarationOptions(new TypeIdentity(model.ClassName, model.Namespace), model.Accessibility) + new(new(model.ClassName, model.Namespace), model.Accessibility) { IsPartial = true, IsStatic = model.IsStatic, @@ -46,14 +45,14 @@ static void EmitCreateMethod( ImmutableArray parameters ) { - var dictionaryName = BuildDictionaryName(parameterNames); + var parametersName = BuildDictionaryName(parameterNames); writer .XmlSummary("Creates a ValidationError for this error type.") .Method( - new MethodDeclarationOptions( + new( "Create" + PascalCase(model.FieldName), - TypeLibrary.Core.ValidationError.AsTypeReference(), + TypeLibrary.ZodSharp.Core.ValidationError.AsTypeReference(), TypeDeclarationAccessibility.Public ) { @@ -62,21 +61,23 @@ ImmutableArray parameters }, method => { + method.Assignment("ErrorType", "errorType", model.FieldName); + method.Line(); + method.OpenDelimitedBlock( - $"global::System.Collections.Generic.Dictionary {dictionaryName} = new({model.Parameters.Count})", + $"var {parametersName} = global::ZodSharp.Core.ErrorTypeParameters.Create(errorType.Parameters, new global::System.Collections.Generic.Dictionary", "{", - "};", + "});", body => { for (var i = 0; i < model.Parameters.Count; i++) - body.Line($"[{model.Parameters[i].Surround()}] = {parameterNames[i]},"); + body.Line($"[{model.Parameters[i].Name.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)" + $"ValidationError.Create(errorType.Code, errorType.FormatMessage({parametersName}), path is null ? [] : [.. path], {parametersName}, origin, minimum, maximum, inclusive)" ); } ); @@ -92,10 +93,10 @@ ImmutableArray parameters writer .XmlSummary("Throws a ZodException for this error type.") .Method( - new MethodDeclarationOptions("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public) + new("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public) { IsStatic = true, - Attributes = [new AttributeDeclarationOptions(TypeLibrary.DoesNotReturnAttribute)], + Attributes = [new(TypeLibrary.System.Diagnostics.CodeAnalysis.DoesNotReturnAttribute)], Parameters = parameters, }, method => @@ -115,25 +116,35 @@ ImmutableArray parameters } static ImmutableArray BuildMethodParameters( + ErrorTypeFieldModel model, 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)); + for (var i = 0; i < model.Parameters.Count; i++) + { + var parameter = model.Parameters[i]; + var reference = parameter.Type.AsTypeReference(); + if (parameter.IsNullable) + reference = reference.Nullable(writer); + + if (parameter.ArrayRank > 0) + reference = reference.MakeArray(parameter.ArrayRank); + + builder.Add(new ParameterDeclarationOptions(parameterNames[i], reference)); + } - 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" }); + builder.Add(new("path", stringArrayType) { DefaultValue = "null" }); + builder.Add(new("origin", stringType) { DefaultValue = "null" }); + builder.Add(new("minimum", intType) { DefaultValue = "null" }); + builder.Add(new("maximum", intType) { DefaultValue = "null" }); + builder.Add(new("inclusive", boolType) { DefaultValue = "null" }); return builder.ToImmutable(); } @@ -142,14 +153,14 @@ CodeWriter writer /// 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) + static ImmutableArray BuildParameterNames(EquatableArray declaredParameters) { HashSet used = ["path", "origin", "minimum", "maximum", "inclusive", "parameters"]; - var builder = ImmutableArray.CreateBuilder(declaredNames.Count); - foreach (var declaredName in declaredNames) + var builder = ImmutableArray.CreateBuilder(declaredParameters.Count); + foreach (var parameter in declaredParameters) { - var candidate = CamelCase(declaredName); + var candidate = CamelCase(parameter.Name); while (!used.Add(candidate)) candidate += "Value"; diff --git a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs index a674861..782af49 100644 --- a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs +++ b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs @@ -35,7 +35,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var errorTypeFields = IncrementalPipeline.ForAttributeWithMetadataName( context, - TypeLibrary.AspNetCore.ErrorTypeAttribute, + TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute, predicate: static (node, _) => node is VariableDeclaratorSyntax, transform: static (attributeContext, cancellationToken) => ErrorTypeGeneratorLibrary.CreateModel(attributeContext, cancellationToken), @@ -56,7 +56,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (model.Context.Settings.IsSourceGeneratorDisabled) return; - if (!model.Context.Capabilities.HasErrorType) + if ( + !model.Context.Capabilities.HasErrorType + || !model.Context.Capabilities.HasErrorTypeParameter + || !model.Context.Capabilities.HasErrorTypeParameters + ) return; foreach (var result in model.Fields) @@ -94,7 +98,7 @@ static SourceText ErrorTypeAttributeSource() { CodeWriter writer = new(GenerationSettings.Create()); - writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.AspNetCore.ErrorTypeAttribute); + writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute); writer .XmlSummary( @@ -102,7 +106,7 @@ static SourceText ErrorTypeAttributeSource() "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), + new TypeDeclarationOptions(TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute), AttributeTargets.Field, static _ => { }, inherited: false, diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs index 3f53911..1083433 100644 --- a/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -21,7 +21,7 @@ static class DiagnosticLibrary 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", + messageFormat: "The Parameters of ErrorType field '{0}' in '{1}' could not be extracted; only ErrorTypeParameter collection literals with a constant name and typeof, or ErrorType.Param invocations, 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 index 0600149..28abf58 100644 --- a/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs +++ b/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs @@ -76,7 +76,7 @@ classDeclaration is null containing.IsAbstract && !containing.IsStatic, containing.IsSealed && !containing.IsStatic, field.Name, - new EquatableArray(parameters) + new EquatableArray(parameters) ) ); } @@ -85,26 +85,20 @@ classDeclaration is null /// Extracts the declared Parameters from the field initializer. Returns null /// when a Parameters member is present but cannot be statically analysed. /// - static ImmutableArray? ExtractParameters( + static ImmutableArray? ExtractParameters( ExpressionSyntax? initializer, GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken ) { - if (initializer is null) + if (initializer is not BaseObjectCreationExpressionSyntax creation) return []; - var objectInitializer = initializer switch - { - ObjectCreationExpressionSyntax objectCreation => objectCreation.Initializer, - ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.Initializer, - _ => null, - }; - - if (objectInitializer is null) - return []; - - var parametersExpression = FindParametersExpression(objectInitializer); + // `Parameters` may be supplied as a constructor argument (the canonical form) or assigned + // in an object initializer. + var parametersExpression = + FindParametersArgument(creation, context, cancellationToken)?.Expression + ?? FindParametersExpression(creation.Initializer); if (parametersExpression is null) return []; @@ -112,19 +106,247 @@ CancellationToken cancellationToken if (elements is null) return null; - var builder = ImmutableArray.CreateBuilder(); + 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); + if (!TryExtractParameter(element, context, cancellationToken, out var parameter)) + return null; + + builder.Add(parameter); } return builder.ToImmutable(); } - static ExpressionSyntax? FindParametersExpression(InitializerExpressionSyntax objectInitializer) + /// + /// Returns the constructor argument bound to the Parameters parameter, or null + /// when none is supplied. Matches both the named (Parameters:) and positional forms. + /// + static ArgumentSyntax? FindParametersArgument( + BaseObjectCreationExpressionSyntax creation, + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + var argumentList = creation.ArgumentList; + if (argumentList is null) + return null; + + if (context.SemanticModel.GetSymbolInfo(creation, cancellationToken).Symbol is not IMethodSymbol constructor) + return null; + + var positionalIndex = 0; + foreach (var argument in argumentList.Arguments) + { + if (argument.NameColon is null) + { + if ( + positionalIndex < constructor.Parameters.Length + && constructor.Parameters[positionalIndex] is { Name: "Parameters" } + ) + return argument; + + positionalIndex++; + continue; + } + + foreach (var parameter in constructor.Parameters) + { + if (parameter.Name == argument.NameColon.Name.Identifier.ValueText && parameter.Name == "Parameters") + return argument; + } + } + + return null; + } + + /// + /// Parses a single parameter declaration into a value-equatable . + /// Supports new ErrorTypeParameter("Name", typeof(T)), target-typed + /// new("Name", typeof(T)), and ErrorType.Param<T>("Name"). Returns + /// false when the element is not a statically-analysable declaration. + /// + static bool TryExtractParameter( + ExpressionSyntax element, + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken, + out ErrorTypeParameterModel parameter + ) + { + if (!TryExtractParameterNameAndType(element, context, cancellationToken, out var name, out var type)) + { + parameter = default; + return false; + } + + if (!TryGetParameterType(type, out var identity, out var isNullable, out var arrayRank)) + { + parameter = default; + return false; + } + + parameter = new ErrorTypeParameterModel(name, identity, isNullable, arrayRank); + return true; + } + + /// + /// Resolves the declared name and type for a recognised parameter declaration form. + /// + static bool TryExtractParameterNameAndType( + ExpressionSyntax element, + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken, + out string name, + out ITypeSymbol type + ) + { + switch (element) + { + case ObjectCreationExpressionSyntax { ArgumentList: { } named } creation + when IsErrorTypeParameter(creation.Type, context): + return TryExtractConstructorArguments(named.Arguments, context, cancellationToken, out name, out type); + + case ImplicitObjectCreationExpressionSyntax { ArgumentList: { } implicitNamed }: + return TryExtractConstructorArguments( + implicitNamed.Arguments, + context, + cancellationToken, + out name, + out type + ); + + case InvocationExpressionSyntax invocation: + return TryExtractParamInvocation(invocation, context, cancellationToken, out name, out type); + + default: + name = null!; + type = null!; + return false; + } + } + + /// + /// Resolves new ErrorTypeParameter("Name", typeof(T)) from its constructor arguments. + /// + static bool TryExtractConstructorArguments( + SeparatedSyntaxList arguments, + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken, + out string name, + out ITypeSymbol type + ) + { + name = null!; + type = null!; + + if (arguments is not { Count: >= 2 } args) + return false; + + var nameConstant = context.SemanticModel.GetConstantValue(args[0].Expression, cancellationToken); + if (!nameConstant.HasValue || nameConstant.Value is not string nameValue) + return false; + + if (args[1].Expression is not TypeOfExpressionSyntax typeOf) + return false; + + var typeInfo = context.SemanticModel.GetTypeInfo(typeOf.Type, cancellationToken); + if (typeInfo.Type is null) + return false; + + name = nameValue; + type = typeInfo.Type; + return true; + } + + /// + /// Resolves ErrorType.Param<T>("Name") from its generic type argument. + /// + static bool TryExtractParamInvocation( + InvocationExpressionSyntax invocation, + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken, + out string name, + out ITypeSymbol type + ) + { + name = null!; + type = null!; + + if (context.SemanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol method) + return false; + + var errorType = context.SemanticModel.Compilation.GetTypeByMetadataName(ErrorTypeMetadataName); + if ( + errorType is null + || method.Name != "Param" + || !SymbolEqualityComparer.Default.Equals(method.ContainingType, errorType) + ) + return false; + + if (invocation.ArgumentList.Arguments is not { Count: >= 1 } args) + return false; + + var nameConstant = context.SemanticModel.GetConstantValue(args[0].Expression, cancellationToken); + if (!nameConstant.HasValue || nameConstant.Value is not string nameValue) + return false; + + if (method.TypeArguments.Length != 1) + return false; + + name = nameValue; + type = method.TypeArguments[0]; + return true; + } + + static bool IsErrorTypeParameter(TypeSyntax typeSyntax, GeneratorAttributeSyntaxContext context) { + var type = context.SemanticModel.GetTypeInfo(typeSyntax).Type as INamedTypeSymbol; + return type is not null + && type.Name == "ErrorTypeParameter" + && type.ContainingNamespace?.ToDisplayString() == "ZodSharp.Core"; + } + + /// + /// Resolves a typeof(...) argument into the value-equatable components needed to emit its + /// type reference: the underlying (nullable value types unwrapped), + /// whether it is Nullable<T>, and the array rank. Returns false when the + /// symbol cannot be represented. + /// + static bool TryGetParameterType(ITypeSymbol type, out TypeIdentity identity, out bool isNullable, out int arrayRank) + { + isNullable = false; + arrayRank = 0; + + if (type is IArrayTypeSymbol arrayType) + { + if (!TryGetParameterType(arrayType.ElementType, out identity, out isNullable, out _)) + return false; + + arrayRank = arrayType.Rank; + return true; + } + + if ( + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + && nullable.TypeArguments[0] is INamedTypeSymbol underlying + ) + { + isNullable = true; + type = underlying; + } + + if (!TypeIdentity.TryCreate(type, out identity)) + return false; + + // We don't support nullable reference types because they are not represented in the emitted + return true; + } + + static ExpressionSyntax? FindParametersExpression(InitializerExpressionSyntax? objectInitializer) + { + if (objectInitializer is null) + return null; + foreach (var element in objectInitializer.Expressions) { if ( @@ -150,6 +372,9 @@ CancellationToken cancellationToken .Select(static element => element.Expression), ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions, ArrayCreationExpressionSyntax array => array.Initializer?.Expressions ?? [], + ObjectCreationExpressionSyntax { Initializer: { } initializer } => initializer.Expressions, + ImplicitObjectCreationExpressionSyntax { Initializer: { } implicitInitializer } => + implicitInitializer.Expressions, _ => null, }; } diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs deleted file mode 100644 index 2eab0f0..0000000 --- a/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs +++ /dev/null @@ -1,26 +0,0 @@ -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/Helpers/TypeLibraryGenerator.cs b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibraryGenerator.cs new file mode 100644 index 0000000..c4cc5fd --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibraryGenerator.cs @@ -0,0 +1,20 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Helpers; + +[GenerateTypeLibrary] +static partial class TypeLibraryGenerator +{ + [TypeRef("ZodSharp.AspNetCore")] + static readonly TypeIdentity ErrorType = default; + + [TypeRef("ZodSharp.AspNetCore")] + static readonly TypeIdentity ErrorTypeAttribute = default; + + [TypeRef("ZodSharp.Core")] + static readonly TypeIdentity ValidationError = default; + + [TypeRef("ZodSharp.Core")] + static readonly TypeIdentity ZodException = default; + + [TypeRef("System.Diagnostics.CodeAnalysis")] + static readonly TypeIdentity DoesNotReturnAttribute = default; +} diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs index 95b3266..182e4b9 100644 --- a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs +++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs @@ -12,7 +12,7 @@ sealed record ErrorTypeFieldModel( bool IsAbstract, bool IsSealed, string FieldName, - EquatableArray Parameters + EquatableArray Parameters ) { /// diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs index 2466354..d968ad3 100644 --- a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs +++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs @@ -6,10 +6,19 @@ 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 +sealed record ErrorTypeGeneratorCapabilities(bool HasErrorType, bool HasErrorTypeParameter, bool HasErrorTypeParameters) + : IGenerationCapabilities { public static ErrorTypeGeneratorCapabilities Create(Compilation compilation) => - new(compilation.GetTypeByMetadataName(ErrorTypeMetadataName) is not null); + new( + compilation.GetTypeByMetadataName(ErrorTypeMetadataName) is not null, + compilation.GetTypeByMetadataName(ErrorTypeParameterMetadataName) is not null, + compilation.GetTypeByMetadataName(ErrorTypeParametersMetadataName) is not null + ); const string ErrorTypeMetadataName = "ZodSharp.AspNetCore.ErrorType"; + + const string ErrorTypeParameterMetadataName = "ZodSharp.Core.ErrorTypeParameter"; + + const string ErrorTypeParametersMetadataName = "ZodSharp.Core.ErrorTypeParameters"; } diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeParameterModel.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeParameterModel.cs new file mode 100644 index 0000000..d2aff59 --- /dev/null +++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeParameterModel.cs @@ -0,0 +1,13 @@ +namespace ZodSharp.AspNetCore.SourceGenerators.Models; + +/// +/// Value-equatable pipeline model describing one declared ErrorType parameter: the +/// placeholder name and the components needed to emit its strongly typed parameter type. All +/// Roslyn objects are removed before this point; array and nullable modifiers are applied by the +/// emitter so the target compilation's nullable context is honoured. +/// +/// The declared placeholder name. +/// The underlying type identity (nullable value types are unwrapped). +/// Whether the type is Nullable<T>. +/// The array rank, or 0 when the type is not an array. +readonly record struct ErrorTypeParameterModel(string Name, TypeIdentity Type, bool IsNullable, int ArrayRank); diff --git a/src/src/AspNetCore/ErrorType.cs b/src/src/AspNetCore/ErrorType.cs index 98f86f8..32c5219 100644 --- a/src/src/AspNetCore/ErrorType.cs +++ b/src/src/AspNetCore/ErrorType.cs @@ -1,12 +1,13 @@ using System.Globalization; using System.Text; +using ZodSharp.Core; 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. @@ -19,9 +20,13 @@ 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. /// +/// +/// The named placeholders expected by +/// together with their expected value types (for example new("AggregateId", typeof(string))). +/// /// /// /// public static partial class ConcurrentErrorType @@ -31,15 +36,17 @@ namespace ZodSharp.AspNetCore; /// 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"] -/// }; +/// MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", +/// Parameters: +/// [ +/// new("AggregateId", typeof(string)), +/// new("AggregateType", typeof(string)) +/// ]); /// } /// /// 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. +/// with one strongly typed parameter per declared parameter. /// public sealed record ErrorType( string Code, @@ -47,13 +54,34 @@ public sealed record ErrorType( int HttpStatus = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest, string? Title = null, string? Type = null, - string? MessageFormat = null + string? MessageFormat = null, + IReadOnlyList? Parameters = null ) { /// - /// The named placeholders expected by (for example "AggregateId"). + /// Creates an error type parameter with the specified name and type. + /// + /// The type represented by the parameter. + /// The name of the parameter. + /// The created error type parameter. + public static ErrorTypeParameter Param(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return new(name, typeof(T)); + } + + /// + /// Formats using the supplied typed 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 . /// - public IReadOnlyList Parameters { get; init; } = []; + /// + /// The typed parameter values (for example the error's ). + /// + public string FormatMessage(ErrorTypeParameters? parameters) => + FormatMessage((IReadOnlyDictionary?)parameters); /// /// Formats using the supplied named parameter values. Placeholders @@ -62,8 +90,7 @@ public sealed record ErrorType( /// to . /// /// - /// The named parameter values (for example the error's - /// ). + /// The named parameter values (for example the error's ). /// public string FormatMessage(IReadOnlyDictionary? parameters) { diff --git a/src/src/AspNetCore/ProblemDetailsExtensions.cs b/src/src/AspNetCore/ProblemDetailsExtensions.cs index 437c0e8..b127aa7 100644 --- a/src/src/AspNetCore/ProblemDetailsExtensions.cs +++ b/src/src/AspNetCore/ProblemDetailsExtensions.cs @@ -163,5 +163,5 @@ public readonly record struct ValidationIssue [System.Text.Json.Serialization.JsonIgnore( Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull )] - public IReadOnlyDictionary? Parameters { get; init; } + public ErrorTypeParameters? Parameters { get; init; } } diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 352eb46..6a57e15 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -61,15 +61,28 @@ public static partial class ConcurrentErrorType 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"] - }; + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new("AggregateId", typeof(string)), + new("AggregateType", typeof(string)) + ]); } ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); ``` +Parameters can also be declared with the `ErrorType.Param("Name")` helper instead of an explicit +`typeof(...)`; the analyzer and source generator handle both forms the same way: + +```csharp +Parameters: +[ + new("AggregateId", typeof(string)), + ErrorType.Param("AggregateType") +] +``` + 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 @@ -78,10 +91,11 @@ 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`: +from the declared `Parameters` — each parameter is emitted with its declared `typeof(...)` type, or the +`ErrorType.Param` generic type argument: ```csharp -// ValidationError with the code, the formatted message, and the named parameters: +// ValidationError with the code, the formatted message, and the typed parameters: var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); // ZodException carrying that ValidationError: @@ -98,6 +112,10 @@ var error = ConcurrentErrorType.CreateSaveFailed( inclusive: true); ``` +The generated helpers construct a typed `ErrorTypeParameters` instance (exposed through +`ValidationError.Parameters`) whose values are validated against the declared types and can be read back +through `Get(name)`. + The analyzer `ZODSASP002` warns when an `ErrorType` field's containing class is not declared `partial`. ## Documentation diff --git a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs index df88e72..c34e1a8 100644 --- a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs +++ b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs @@ -61,7 +61,11 @@ public ProblemDetailsMappingPerformanceTests() MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" ) { - Parameters = ["AggregateId", "AggregateType"], + Parameters = + [ + new ErrorTypeParameter("AggregateId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)), + ], } ); diff --git a/src/src/ZodSharp/Core/ErrorTypeParameter.cs b/src/src/ZodSharp/Core/ErrorTypeParameter.cs new file mode 100644 index 0000000..89597f2 --- /dev/null +++ b/src/src/ZodSharp/Core/ErrorTypeParameter.cs @@ -0,0 +1,9 @@ +namespace ZodSharp.Core; + +/// +/// Declares a named parameter of an ErrorType message template, together with the CLR +/// the value is expected to have. +/// +/// The named placeholder this parameter maps to. +/// The expected value type (for example typeof(string)). +public sealed record ErrorTypeParameter(string Name, Type Type); diff --git a/src/src/ZodSharp/Core/ErrorTypeParameters.cs b/src/src/ZodSharp/Core/ErrorTypeParameters.cs new file mode 100644 index 0000000..60e3407 --- /dev/null +++ b/src/src/ZodSharp/Core/ErrorTypeParameters.cs @@ -0,0 +1,155 @@ +using System.Collections; + +namespace ZodSharp.Core; + +/// +/// A typed, read-only set of error parameters. Carries the declared +/// metadata alongside the values so consumers can read values +/// through the strongly typed accessor and format messages with knowledge of +/// each parameter's type. Implements so existing +/// dictionary-based consumers keep working unchanged. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1710:Identifiers should have correct suffix", + Justification = "Real name" +)] +public sealed class ErrorTypeParameters : IReadOnlyDictionary +{ + static readonly IReadOnlyDictionary s_emptyDeclarations = + new Dictionary(); + + readonly IReadOnlyDictionary _values; + readonly IReadOnlyDictionary _declarations; + + ErrorTypeParameters( + IReadOnlyDictionary values, + IReadOnlyDictionary declarations + ) + { + _values = values; + _declarations = declarations; + } + + /// + /// Creates a typed parameter set from the declared parameters and their values. Each value's + /// runtime type is validated against its declaration. + /// + /// The declared parameters (names and expected types). + /// The parameter values keyed by name. + /// A value's type does not match its declared type. + public static ErrorTypeParameters Create( + IReadOnlyList? declarations, + IEnumerable> values + ) + { + ArgumentNullException.ThrowIfNull(values); + + Dictionary declarationMap = new(StringComparer.Ordinal); + if (declarations is not null) + { + foreach (var declaration in declarations) + declarationMap[declaration.Name] = declaration; + } + + Dictionary valueMap = new(StringComparer.Ordinal); + foreach (var pair in values) + { + if ( + declarationMap.TryGetValue(pair.Key, out var declaration) && !IsCompatible(declaration.Type, pair.Value) + ) + { + throw new ArgumentException( + $"Parameter '{pair.Key}' has value of type '{pair.Value?.GetType().FullName ?? "null"}' but was declared as '{declaration.Type.FullName}'.", + nameof(values) + ); + } + + valueMap[pair.Key] = pair.Value; + } + + return new ErrorTypeParameters(valueMap, declarationMap); + } + + /// + /// Creates a typed parameter set from values only, without declared types. + /// + /// The parameter values keyed by name. + public static ErrorTypeParameters Create(IEnumerable> values) + { + ArgumentNullException.ThrowIfNull(values); + + Dictionary valueMap = new(StringComparer.Ordinal); + foreach (var pair in values) + valueMap[pair.Key] = pair.Value; + + return new ErrorTypeParameters(valueMap, s_emptyDeclarations); + } + + /// + /// Returns the value for cast to . Returns the + /// default value for when the value is absent, is null, does not + /// match the declared type, or is not assignable to . + /// + /// The parameter name. + public T? Get(string name) + { + ArgumentNullException.ThrowIfNull(name); + + if (!_values.TryGetValue(name, out var value)) + return default; + + if (value is null) + return default; + + if (_declarations.TryGetValue(name, out var declaration) && !IsCompatible(declaration.Type, value)) + return default; + + // The value is compatible with the declared type (if any), but may not be assignable to T. + return value is T typed ? typed : default; + } + + /// + /// Returns the declared type for , or null when the value was + /// created without declarations. + /// + /// The parameter name. + public Type? GetDeclaredType(string name) + { + ArgumentNullException.ThrowIfNull(name); + + return _declarations.TryGetValue(name, out var declaration) ? declaration.Type : null; + } + + static bool IsCompatible(Type type, object? value) + { + if (value is null) + return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + + // The value is non-null, so check if it is assignable to the declared type. + return type.IsInstanceOfType(value); + } + + /// + public object? this[string key] => _values[key]; + + /// + public IEnumerable Keys => _values.Keys; + + /// + public IEnumerable Values => _values.Values; + + /// + public int Count => _values.Count; + + /// + public bool ContainsKey(string key) => _values.ContainsKey(key); + + /// + public bool TryGetValue(string key, out object? value) => _values.TryGetValue(key, out value); + + /// + public IEnumerator> GetEnumerator() => _values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _values.GetEnumerator(); +} diff --git a/src/src/ZodSharp/Core/ValidationError.cs b/src/src/ZodSharp/Core/ValidationError.cs index ef01a42..9fae06f 100644 --- a/src/src/ZodSharp/Core/ValidationError.cs +++ b/src/src/ZodSharp/Core/ValidationError.cs @@ -44,9 +44,10 @@ public readonly record struct ValidationError public bool? Inclusive { get; } /// - /// Additional error parameters + /// Additional error parameters. Typed container that also exposes the raw values through + /// and IReadOnlyDictionary<string, object?>. /// - public IReadOnlyDictionary? Parameters { get; } + public ErrorTypeParameters? Parameters { get; } /// /// Initializes a new instance of the ValidationError struct. @@ -73,7 +74,7 @@ public ValidationError( Code = code; Message = message; Path = path is null ? [] : ImmutableArray.Create(path); - Parameters = parameters; + Parameters = Wrap(parameters); Origin = origin; Minimum = minimum; Maximum = maximum; @@ -116,10 +117,15 @@ public static ValidationError Create( Code = code; Message = message; Path = path; - Parameters = parameters; + Parameters = Wrap(parameters); Origin = origin; Minimum = minimum; Maximum = maximum; Inclusive = inclusive; } + + static ErrorTypeParameters? Wrap(IReadOnlyDictionary? parameters) => + parameters is ErrorTypeParameters typed ? typed + : parameters is null ? null + : ErrorTypeParameters.Create(parameters); } diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs index b94b818..f0df493 100644 --- a/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs +++ b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypeMessageFormatAnalyzerTests.cs @@ -17,10 +17,8 @@ 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"] - }; + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: [new ErrorTypeParameter("AggregateId", typeof(string))]); } """; @@ -40,10 +38,63 @@ 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"] - }; + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new ErrorTypeParameter("AggregateId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)) + ]); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenMessageFormat_WithTargetTypedParametersDeclared_HasNoDiagnostics( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing; + + public static class ConcurrentErrorType + { + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Description: "The order could not be saved because it was modified concurrently.", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save", + Parameters: [new("OrderId", typeof(string)), new("AggregateType", typeof(string))]); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task GivenMessageFormat_WithParamInvocationParametersDeclared_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: "Order '{OrderId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new ErrorTypeParameter("OrderId", typeof(string)), + ErrorType.Param("AggregateType") + ]); } """; @@ -82,7 +133,7 @@ public static class Escaped Code: "saved", MessageFormat: "The value {{Value}} is fine.") { - Parameters = ["Value"] + Parameters = [new ErrorTypeParameter("Value", typeof(string))] }; } """; diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs index c04efe6..2f769bd 100644 --- a/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs +++ b/src/tests/AspNetCore.Analyzers.UnitTests/ErrorTypePartialClassAnalyzerTests.cs @@ -15,7 +15,11 @@ public static class ConcurrentErrorType [ErrorType] public static readonly ErrorType SaveFailed = new(Code: "aggregate_save_failed") { - Parameters = ["OrderId", "AggregateType"] + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)) + ] }; } """; @@ -36,7 +40,11 @@ public static partial class ConcurrentErrorType [ErrorType] public static readonly ErrorType SaveFailed = new(Code: "aggregate_save_failed") { - Parameters = ["OrderId", "AggregateType"] + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)) + ] }; } """; diff --git a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs index 9ba302b..1d9b87f 100644 --- a/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs +++ b/src/tests/AspNetCore.Analyzers.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs @@ -1,11 +1,13 @@ +using ZodSharp.Core; + namespace ZodSharp.AspNetCore.Analyzers.Infra; public sealed record ErrorTypeAnalyzerTestOptions : AnalyzerTestOptions { public ErrorTypeAnalyzerTestOptions() { - AdditionalNamespaces = ["ZodSharp.AspNetCore"]; - AdditionalAssemblyTypes = [typeof(ErrorType)]; + AdditionalNamespaces = ["ZodSharp.AspNetCore", "ZodSharp.Core"]; + AdditionalAssemblyTypes = [typeof(ErrorType), typeof(ErrorTypeParameter)]; // The generator emits this attribute at compile time; the analyzer-only harness // provides the same surface so the analyzer can resolve it. AdditionalSources = diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs index d68ec14..d2ebd45 100644 --- a/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorCacheTests.cs @@ -16,7 +16,11 @@ public static partial class ConcurrentErrorType Code: "aggregate_save_failed", HttpStatus: 409) { - Parameters = ["OrderId", "AggregateType"] + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)) + ] }; } """; @@ -31,7 +35,10 @@ public static partial class ConcurrentErrorType Code: "aggregate_save_failed", HttpStatus: 409) { - Parameters = ["OrderId"] + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)) + ] }; } """; diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs index d54c829..8917852 100644 --- a/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs @@ -17,10 +17,12 @@ public static partial class ConcurrentErrorType 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"] - }; + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)) + ]); } """; @@ -39,7 +41,7 @@ CancellationToken cancellationToken await Assert.That(result).HasNoErrorDiagnostics(); var query = result.Generated(); - var nullableObject = query.MakeNullable(TypeReference.Create()); + var stringType = TypeReference.Create(); var nullableString = query.MakeNullable(TypeReference.Create()); var nullableStringArray = query.MakeNullable(TypeReference.Create().MakeArray()); var nullableInt = query.MakeNullable(TypeReference.Create()); @@ -49,15 +51,7 @@ await Assert .That(query) .HasGeneratedMethod( "CreateSaveFailed", - [ - nullableObject, - nullableObject, - nullableStringArray, - nullableString, - nullableInt, - nullableInt, - nullableBool, - ] + [stringType, stringType, nullableStringArray, nullableString, nullableInt, nullableInt, nullableBool] ); await Assert .That(query) @@ -67,15 +61,7 @@ await Assert .That(query) .HasGeneratedMethod( "ThrowSaveFailed", - [ - nullableObject, - nullableObject, - nullableStringArray, - nullableString, - nullableInt, - nullableInt, - nullableBool, - ] + [stringType, stringType, nullableStringArray, nullableString, nullableInt, nullableInt, nullableBool] ); var throwMethod = query.GetMethod("ThrowSaveFailed").Node; @@ -97,6 +83,7 @@ CancellationToken cancellationToken // Assert await Assert.That(generated).ContainsGeneratedCode("[\"OrderId\"] = orderId,"); await Assert.That(generated).ContainsGeneratedCode("[\"AggregateType\"] = aggregateType,"); + await Assert.That(generated).ContainsGeneratedCode("ErrorTypeParameters.Create(errorType.Parameters,"); await Assert.That(generated).ContainsGeneratedCode("errorType.FormatMessage("); await Assert.That(generated).ContainsGeneratedCode("throw new ZodException([CreateSaveFailed("); } @@ -132,7 +119,60 @@ await Assert await Assert .That(generated) .ContainsGeneratedCode( - "global::System.Collections.Generic.Dictionary parameters = new(0)" + "ErrorTypeParameters.Create(errorType.Parameters, new global::System.Collections.Generic.Dictionary" + ); + } + + [Test] + public async Task GivenTypedParameters_GeneratesStronglyTypedSignature(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + + namespace Testing; + + public static partial class SizedErrorType + { + [ErrorType] + public static readonly ErrorType OutOfRange = new( + Code: "out_of_range", + MessageFormat: "'{Field}' must be between {Minimum} and {Maximum} but got {Actual} (tags: {Tags})", + Parameters: + [ + new ErrorTypeParameter("Field", typeof(string)), + new ErrorTypeParameter("Minimum", typeof(int?)), + new ErrorTypeParameter("Actual", typeof(decimal)), + new ErrorTypeParameter("Tags", typeof(string[])), + new ErrorTypeParameter("Ids", typeof(List)) + ]); + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + var query = result.Generated(); + var nullableInt = query.MakeNullable(TypeReference.Create()); + + await Assert + .That(query) + .HasGeneratedMethod( + "CreateOutOfRange", + [ + TypeReference.Create(), + nullableInt, + TypeReference.Create(), + TypeReference.Create().MakeArray(), + TypeReference.Create>(), + query.MakeNullable(TypeReference.Create().MakeArray()), + query.MakeNullable(TypeReference.Create()), + nullableInt, + nullableInt, + query.MakeNullable(TypeReference.Create()), + ] ); } @@ -150,10 +190,12 @@ 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"] - }; + MessageFormat: "'{Field}' must be at least {Minimum} characters.", + Parameters: + [ + new ErrorTypeParameter("Field", typeof(string)), + new ErrorTypeParameter("Minimum", typeof(int)) + ]); } """; @@ -174,6 +216,92 @@ public static partial class SizeErrorType await Assert.That(parameterNames).Contains("minimum"); } + [Test] + public async Task GivenPositionalParametersArgument_GeneratesCreateAndThrowMethods( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + public static partial class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new( + "aggregate_save_failed", + null, + 409, + null, + null, + "Order '{OrderId}' failed to save", + [new ErrorTypeParameter("OrderId", typeof(string))]); + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + var query = result.Generated(); + var stringType = TypeReference.Create(); + var nullableString = query.MakeNullable(stringType); + var nullableStringArray = query.MakeNullable(stringType.MakeArray()); + var nullableInt = query.MakeNullable(TypeReference.Create()); + var nullableBool = query.MakeNullable(TypeReference.Create()); + + await Assert + .That(query) + .HasGeneratedMethod( + "CreateSaveFailed", + [stringType, nullableStringArray, nullableString, nullableInt, nullableInt, nullableBool] + ); + await Assert.That(query).HasGeneratedMethod("ThrowSaveFailed"); + } + + [Test] + public async Task GivenParamInvocationParameters_GeneratesCreateAndThrowMethods(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + public static partial class ConcurrentErrorType + { + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new ErrorTypeParameter("OrderId", typeof(string)), + ErrorType.Param("AggregateType") + ]); + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + var query = result.Generated(); + var stringType = TypeReference.Create(); + var nullableString = query.MakeNullable(stringType); + var nullableStringArray = query.MakeNullable(stringType.MakeArray()); + var nullableInt = query.MakeNullable(TypeReference.Create()); + var nullableBool = query.MakeNullable(TypeReference.Create()); + + await Assert + .That(query) + .HasGeneratedMethod( + "CreateSaveFailed", + [stringType, stringType, nullableStringArray, nullableString, nullableInt, nullableInt, nullableBool] + ); + await Assert.That(query).HasGeneratedMethod("ThrowSaveFailed"); + } + [Test] public async Task GivenNonPartialContainingClass_DoesNotGenerate(CancellationToken cancellationToken) { @@ -188,7 +316,11 @@ public static class ConcurrentErrorType Code: "aggregate_save_failed", HttpStatus: 409) { - Parameters = ["OrderId", "AggregateType"] + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)) + ] }; } """; diff --git a/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs b/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs index 11a8643..8780ce2 100644 --- a/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs +++ b/src/tests/AspNetCore.SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs @@ -12,6 +12,8 @@ public ErrorTypeGeneratorTestOptions() AdditionalAssemblyTypes = [ typeof(ErrorType), + typeof(ErrorTypeParameter), + typeof(ErrorTypeParameters), typeof(ValidationError), typeof(ZodException), typeof(ImmutableArray), diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs index c076ce5..de7d116 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeFormatMessageTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using ZodSharp.Core; namespace ZodSharp.AspNetCore; @@ -15,7 +16,11 @@ public async Task GivenMessageFormatWithParameters_FormatsPlaceholders() MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save" ) { - Parameters = ["OrderId", "AggregateType"], + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)), + ], }; // Act @@ -27,6 +32,37 @@ public async Task GivenMessageFormatWithParameters_FormatsPlaceholders() await Assert.That(message).IsEqualTo("Order 'ord-1' (of type Order) failed to save"); } + [Test] + public async Task GivenMessageFormat_TypedParametersOverload_FormatsPlaceholders() + { + // Arrange + ErrorType errorType = new( + "aggregate_save_failed", + Description: "Save failed.", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save" + ) + { + Parameters = + [ + new ErrorTypeParameter("OrderId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)), + ], + }; + var parameters = ErrorTypeParameters.Create( + errorType.Parameters, + new Dictionary { ["OrderId"] = "ord-1", ["AggregateType"] = "Order" } + ); + + // Act + var message = errorType.FormatMessage(parameters); + + // Assert + await Assert.That(message).IsEqualTo("Order 'ord-1' (of type Order) failed to save"); + await Assert.That(parameters.Get("OrderId")).IsEqualTo("ord-1"); + await Assert.That(parameters.GetDeclaredType("AggregateType")).IsEqualTo(typeof(string)); + } + [Test] public async Task GivenMessageFormat_MissingParameterLeavesPlaceholderAsIs() { @@ -36,7 +72,11 @@ public async Task GivenMessageFormat_MissingParameterLeavesPlaceholderAsIs() MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" ) { - Parameters = ["AggregateId", "AggregateType"], + Parameters = + [ + new ErrorTypeParameter("AggregateId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)), + ], }; // Act diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs index 9b25b49..e57f995 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs @@ -18,7 +18,11 @@ public async Task GivenMappedCode_ReturnsErrorTypeStatusAndFormattedMessage() MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" ) { - Parameters = ["AggregateId", "AggregateType"], + Parameters = + [ + new ErrorTypeParameter("AggregateId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)), + ], } ); ZodException exception = new([ @@ -123,7 +127,11 @@ public async Task GivenMessageFormat_MissingPlaceholderIsLeftAsIs() MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save" ) { - Parameters = ["AggregateId", "AggregateType"], + Parameters = + [ + new ErrorTypeParameter("AggregateId", typeof(string)), + new ErrorTypeParameter("AggregateType", typeof(string)), + ], } ); ZodException exception = new([ @@ -156,7 +164,11 @@ public async Task GivenValidationResultWithRegistry_FormatsMessages() MessageFormat: "'{Field}' must be at least {Minimum} characters." ) { - Parameters = ["Field", "Minimum"], + Parameters = + [ + new ErrorTypeParameter("Field", typeof(string)), + new ErrorTypeParameter("Minimum", typeof(int)), + ], } ); var result = ValidationResult.Failure( diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs index 9eac11d..8e8e65a 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs @@ -10,11 +10,14 @@ public static partial class ConcurrentErrorType 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"], - }; + MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save", + Parameters: new List + { + new("OrderId", typeof(string)), + ErrorType.Param("AggregateType"), + //new ("AggregateType", typeof(string)), + } + ); } public class ErrorTypeSourceGeneratorIntegrationTests @@ -31,6 +34,8 @@ public async Task GivenGeneratedCreate_PopulatesCodeParametersAndPath() 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.Parameters!.Get("OrderId")).IsEqualTo("ord-42"); + await Assert.That(error.Parameters!.GetDeclaredType("OrderId")).IsEqualTo(typeof(string)); await Assert.That(error.Message).IsEqualTo("Order 'ord-42' (of type Order) failed to save"); } diff --git a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs index 4c265e8..9900a04 100644 --- a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs @@ -48,7 +48,7 @@ CancellationToken cancellationToken MessageFormat: "Aggregate '{AggregateId}' failed to save" ) { - Parameters = ["AggregateId"], + Parameters = [new ErrorTypeParameter("AggregateId", typeof(string))], } ); var options = Microsoft.Extensions.Options.Options.Create(new ZodProblemDetailsOptions { Registry = registry }); @@ -78,6 +78,9 @@ 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"); + await Assert + .That(root.GetProperty("issues")[0].GetProperty("parameters").GetProperty("AggregateId").GetString()) + .IsEqualTo("agg-123"); } [Test] diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftCrossPlatformTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftCrossPlatformTests.cs index 9fcbde6..45fc8b8 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftCrossPlatformTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftCrossPlatformTests.cs @@ -35,7 +35,8 @@ public class NewtonsoftCrossPlatformTests "src", "tests", "cross-platform", - "output" + "output", + $"{Guid.NewGuid()}" ); static readonly string ManifestPath = Path.Combine(FixturesDir, "manifest.json"); diff --git a/src/tests/SystemTextJson.UnitTests/Json/CrossPlatformFixtures.cs b/src/tests/SystemTextJson.UnitTests/Json/CrossPlatformFixtures.cs index e040c24..4b1f64f 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/CrossPlatformFixtures.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/CrossPlatformFixtures.cs @@ -9,8 +9,11 @@ namespace ZodSharp.Json; public sealed class CrossPlatformUser { public string? Name { get; set; } + public int Age { get; set; } + public string? Email { get; set; } + public List Tags { get; set; } = []; } diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextCrossPlatformTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextCrossPlatformTests.cs index f7a55a7..ea471a5 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/SystemTextCrossPlatformTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextCrossPlatformTests.cs @@ -20,7 +20,8 @@ public class SystemTextCrossPlatformTests "src", "tests", "cross-platform", - "output" + "output", + $"{Guid.NewGuid()}" ); static readonly string ManifestPath = Path.Combine(FixturesDir, "manifest.json"); diff --git a/src/tests/cross-platform/output/newtonsoft-valid.json b/src/tests/cross-platform/output/newtonsoft-valid.json deleted file mode 100644 index d9f01f1..0000000 --- a/src/tests/cross-platform/output/newtonsoft-valid.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"CSharp Export","age":42,"email":"csharp@example.com","tags":["newtonsoft","export"]} \ No newline at end of file diff --git a/src/tests/cross-platform/output/systemtext-valid.json b/src/tests/cross-platform/output/systemtext-valid.json deleted file mode 100644 index 6f04a8a..0000000 --- a/src/tests/cross-platform/output/systemtext-valid.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"CSharp Export","age":42,"email":"csharp@example.com","tags":["systemtext","export"]} \ No newline at end of file