Conversation
Introduce ResultObjectOptions for flexible configuration of result object registration, including conditional ProblemDetailsFactory registration and client error mapping customization. Add UseEndpointModules as the preferred method for mapping endpoint modules, marking MapEndpointModules as obsolete. Update XML docs accordingly.
Added Arcturus.Extensions.Validation.AspNetCore and Arcturus.Validation.CodeGenerator projects to the solution, including initial project files and solution structure. Implemented stubs for a Roslyn source generator and ASP.NET Core route handler validation extension methods and filters. These changes lay the groundwork for endpoint parameter validation via source generation and middleware.
Enhanced ValidateParametersFilter to use generated validation methods for endpoint parameter validation, skipping primitives and framework types. Added EndpointValidationGenerator source generator to scan endpoints and generate internal validation extensions using DataAnnotations and C# 11 required properties. Introduced internal ValidatableTypeAttribute marker. Updated Sdk.props formatting and added packaging metadata (license, tags, icon, description, XML docs) for Arcturus.Extensions.Validation.AspNetCore and Arcturus.Validation.CodeGenerator. Added README files for both projects.
Replaces reflection-based parameter validation with a source-generated ValidationHelper class. The generator now skips primitive/framework types and emits efficient type checks and TryValidate calls for user-defined types. Introduces ValidationHelper.cs to centralize validation logic and initialization, improving performance and maintainability.
- Package source generator DLL as analyzer for automatic use - Add build props file and clean up .csproj XML - Replace ProjectReference with direct analyzer DLL inclusion - Add detailed README with usage, features, and troubleshooting - Remove unnecessary generic from ValidateParameters extension - Suppress CS0649 warning for generated field in ValidationHelper
Added a .props file to disable emission of compiler-generated files in consuming projects. Updated the .csproj to exclude this .props file from the NuGet package.
There was a problem hiding this comment.
Pull request overview
This PR introduces a source generator-based validation framework for ASP.NET Core Minimal API endpoints. The implementation generates validation code at compile-time to eliminate reflection overhead and provide type-safe validation with improved performance.
Changes:
- Added
Arcturus.Validation.CodeGeneratorpackage containing a Roslyn source generator that detects endpoints using validation filters and generates type-specific validation extension methods - Added
Arcturus.Extensions.Validation.AspNetCorepackage providing runtime support includingValidateParametersFilter,ValidationHelper, and extension methods for endpoint validation - Enhanced
ResultObjectOptionswith configurableProblemDetailsFactoryregistration and client error mappings customization
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Arcturus.Validation.CodeGenerator/EndpointValidationGenerator.cs | Core source generator that analyzes endpoint syntax and generates validation code |
| src/Arcturus.Validation.CodeGenerator/ValidatableTypeAttribute.cs | Marker attribute (currently unused) intended for tracking validated types |
| src/Arcturus.Validation.CodeGenerator/Arcturus.Validation.CodeGenerator.csproj | Source generator project configuration targeting netstandard2.0 |
| src/Arcturus.Extensions.Validation.AspNetCore/ValidateParametersFilter.cs | Endpoint filter implementation that performs validation (contains critical bug) |
| src/Arcturus.Extensions.Validation.AspNetCore/ValidationHelper.cs | Partial class that bridges runtime and generated validation code |
| src/Arcturus.Extensions.Validation.AspNetCore/RouteHandlerBuilderExtensions.cs | Extension method to add validation to endpoint builders |
| src/Arcturus.Extensions.Validation.AspNetCore/README.md | Comprehensive documentation of the validation framework |
| src/Arcturus.Extensions.Validation.AspNetCore/Arcturus.Extensions.Validation.AspNetCore.csproj | Main package configuration including source generator packaging |
| src/Arcturus.Extensions.Validation.AspNetCore/build/Arcturus.Extensions.Validation.AspNetCore.props | Build configuration (not currently packaged) |
| src/Arcturus.Extensions.ResultObjects.AspNetCore/ResultObjectOptions.cs | New options class for configuring result object behavior |
| src/Arcturus.Extensions.ResultObjects.AspNetCore/ServiceExtensions.cs | Enhanced with configurable options and client mappings |
| src/Arcturus.AspNetCore.Endpoints/ServiceCollectionExtensions.cs | Added UseEndpointModules as replacement for deprecated MapEndpointModules |
| src/Arcturus.DevHost/Arcturus.DevHost.Sdk/Sdk/Sdk.props | Formatting cleanup (whitespace changes only) |
| Arcturus.sln | Added new validation projects to solution |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // This method is called by ValidateParametersFilter | ||
| internal static object? ValidateArguments(IList<object?> arguments) | ||
| { | ||
| return _validateFunc?.Invoke(arguments); |
There was a problem hiding this comment.
If the source generator doesn't run or fails to generate the InitializeValidation implementation, _validateFunc will remain null and validation will silently be skipped. This could create a false sense of security. Consider adding diagnostics or logging to detect when the generated code is not available, or throw an exception if validation is attempted but _validateFunc is null.
| return _validateFunc?.Invoke(arguments); | |
| if (_validateFunc is null) | |
| { | |
| throw new InvalidOperationException( | |
| "Validation function has not been initialized. Ensure the source generator has run and InitializeValidation is correctly implemented."); | |
| } | |
| return _validateFunc(arguments); |
| @@ -0,0 +1,244 @@ | |||
| # Arcturus.Extensions.Validation.AspNetCore | |||
There was a problem hiding this comment.
The PR description contains only a generic template text ("A brief description of the changes in this pull request explaining why these changes are necessary. Please delete this paragraph.") instead of actual details about what this PR introduces. The description should explain the purpose, design decisions, and usage of the new validation system.
| sb.AppendLine(" var propertyName = validationResult.MemberNames.FirstOrDefault() ?? \"Unknown\";"); | ||
| sb.AppendLine(" errors[propertyName] = new[] { validationResult.ErrorMessage ?? \"Validation failed\" };"); | ||
| sb.AppendLine(" }"); |
There was a problem hiding this comment.
When a property has multiple validation errors, only the last error message will be retained because the code overwrites the dictionary entry for each validation result. Consider grouping validation results by property name and combining all error messages into the array, or at minimum, check if the key exists before overwriting it.
| sb.AppendLine($" if (parameters.{property.Name} == null)"); | ||
| sb.AppendLine(" {"); | ||
| sb.AppendLine($" errors[\"{property.Name}\"] = new[] {{ \"The {property.Name} field is required.\" }};"); | ||
| sb.AppendLine(" }"); | ||
| sb.AppendLine(); |
There was a problem hiding this comment.
The required property validation can overwrite errors already added by DataAnnotations validation. If a property already failed validation through DataAnnotations (e.g., with RequiredAttribute), this code will replace that error message with a generic "field is required" message. Either check if the property already has an error before adding, or skip this check entirely since DataAnnotations should already validate required properties.
| var hasAsParameters = paramSymbol.GetAttributes() | ||
| .Any(a => a.AttributeClass?.Name == "AsParametersAttribute"); | ||
|
|
||
| parameters.Add(new ParameterTypeInfo( | ||
| paramSymbol.Name, | ||
| typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), | ||
| typeSymbol, | ||
| hasAsParameters)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return parameters; | ||
| } | ||
|
|
||
| private static void Execute(Compilation compilation, ImmutableArray<EndpointParameterInfo> endpoints, SourceProductionContext context) | ||
| { | ||
| if (endpoints.IsDefaultOrEmpty) | ||
| return; | ||
|
|
||
| // Group all unique parameter types that need validation | ||
| var typesToValidate = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default); | ||
|
|
||
| foreach (var endpoint in endpoints) | ||
| { | ||
| foreach (var param in endpoint.Parameters) | ||
| { | ||
| if (!IsPrimitiveOrFrameworkType(param.TypeSymbol)) | ||
| { | ||
| typesToValidate.Add(param.TypeSymbol); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (typesToValidate.Count == 0) | ||
| return; | ||
|
|
||
| // Generate validation extension class | ||
| var sourceBuilder = new StringBuilder(); | ||
|
|
||
| // Determine namespace - use the first compilation assembly's default namespace or global | ||
| var rootNamespace = compilation.AssemblyName ?? "Generated"; | ||
|
|
||
| sourceBuilder.AppendLine("// <auto-generated/>"); | ||
| sourceBuilder.AppendLine("#nullable enable"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine("using System;"); | ||
| sourceBuilder.AppendLine("using System.Collections.Generic;"); | ||
| sourceBuilder.AppendLine("using System.ComponentModel.DataAnnotations;"); | ||
| sourceBuilder.AppendLine("using System.Linq;"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine($"namespace {rootNamespace};"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine("internal static partial class ValidationExtensions"); | ||
| sourceBuilder.AppendLine("{"); | ||
|
|
||
| // Generate validation method for each unique type | ||
| foreach (var typeSymbol in typesToValidate) | ||
| { | ||
| GenerateValidationMethod(sourceBuilder, typeSymbol); | ||
| } | ||
|
|
||
| sourceBuilder.AppendLine("}"); | ||
|
|
||
| context.AddSource("ValidationExtensions.g.cs", sourceBuilder.ToString()); | ||
|
|
||
| // Generate the partial ValidateParametersFilter implementation | ||
| GenerateValidateParametersFilter(compilation, typesToValidate, context); | ||
| } | ||
|
|
||
| private static void GenerateValidateParametersFilter(Compilation compilation, HashSet<INamedTypeSymbol> typesToValidate, SourceProductionContext context) | ||
| { | ||
| var sourceBuilder = new StringBuilder(); | ||
|
|
||
| sourceBuilder.AppendLine("// <auto-generated/>"); | ||
| sourceBuilder.AppendLine("#nullable enable"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine("using Microsoft.AspNetCore.Http;"); | ||
| sourceBuilder.AppendLine("using System;"); | ||
| sourceBuilder.AppendLine("using System.Collections.Generic;"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine("namespace Arcturus.Validation;"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine("internal static partial class ValidationHelper"); | ||
| sourceBuilder.AppendLine("{"); | ||
| sourceBuilder.AppendLine(" static partial void InitializeValidation()"); | ||
| sourceBuilder.AppendLine(" {"); | ||
| sourceBuilder.AppendLine(" _validateFunc = ValidateArgumentsImpl;"); | ||
| sourceBuilder.AppendLine(" }"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine(" private static object? ValidateArgumentsImpl(IList<object?> arguments)"); | ||
| sourceBuilder.AppendLine(" {"); | ||
| sourceBuilder.AppendLine(" foreach (var argument in arguments)"); | ||
| sourceBuilder.AppendLine(" {"); | ||
| sourceBuilder.AppendLine(" if (argument is null)"); | ||
| sourceBuilder.AppendLine(" continue;"); | ||
| sourceBuilder.AppendLine(); | ||
|
|
||
| // Generate type checks for each validatable type | ||
| var typesList = typesToValidate.ToList(); | ||
| for (int i = 0; i < typesList.Count; i++) | ||
| { | ||
| var typeSymbol = typesList[i]; | ||
| var typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); | ||
|
|
||
| var ifKeyword = i == 0 ? "if" : "else if"; | ||
|
|
||
| sourceBuilder.AppendLine($" {ifKeyword} (argument is {typeName} param{i})"); | ||
| sourceBuilder.AppendLine(" {"); | ||
| sourceBuilder.AppendLine($" if (!param{i}.TryValidate(out var errors{i}))"); | ||
| sourceBuilder.AppendLine(" {"); | ||
| sourceBuilder.AppendLine($" return Results.ValidationProblem(errors{i});"); | ||
| sourceBuilder.AppendLine(" }"); | ||
| sourceBuilder.AppendLine(" }"); | ||
| } | ||
|
|
||
| sourceBuilder.AppendLine(" }"); | ||
| sourceBuilder.AppendLine(); | ||
| sourceBuilder.AppendLine(" return null;"); | ||
| sourceBuilder.AppendLine(" }"); | ||
| sourceBuilder.AppendLine("}"); | ||
|
|
||
| context.AddSource("ValidationHelper.g.cs", sourceBuilder.ToString()); | ||
| } | ||
|
|
||
| private static bool IsPrimitiveOrFrameworkType(ITypeSymbol typeSymbol) | ||
| { | ||
| if (typeSymbol.TypeKind == TypeKind.Enum) | ||
| return true; | ||
|
|
||
| var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); | ||
|
|
||
| return typeSymbol.SpecialType != SpecialType.None | ||
| || fullName.StartsWith("global::System.String") | ||
| || fullName.StartsWith("global::System.DateTime") | ||
| || fullName.StartsWith("global::System.DateTimeOffset") | ||
| || fullName.StartsWith("global::System.TimeSpan") | ||
| || fullName.StartsWith("global::System.Guid") | ||
| || fullName.StartsWith("global::System.Decimal") | ||
| || fullName.StartsWith("global::Microsoft.") | ||
| || (fullName.StartsWith("global::System.") && !fullName.StartsWith("global::System.ComponentModel.DataAnnotations")); | ||
| } | ||
|
|
||
| private static void GenerateValidationMethod(StringBuilder sb, INamedTypeSymbol typeSymbol) | ||
| { | ||
| var typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); | ||
| var simpleTypeName = typeSymbol.Name; | ||
|
|
||
| sb.AppendLine($" internal static bool TryValidate(this {typeName} parameters, out Dictionary<string, string[]> errors)"); | ||
| sb.AppendLine(" {"); | ||
| sb.AppendLine(" errors = new Dictionary<string, string[]>();"); | ||
| sb.AppendLine(); | ||
|
|
||
| // Use DataAnnotations validation | ||
| sb.AppendLine(" var validationContext = new ValidationContext(parameters);"); | ||
| sb.AppendLine(" var validationResults = new List<ValidationResult>();"); | ||
| sb.AppendLine(); | ||
| sb.AppendLine(" if (!Validator.TryValidateObject(parameters, validationContext, validationResults, validateAllProperties: true))"); | ||
| sb.AppendLine(" {"); | ||
| sb.AppendLine(" foreach (var validationResult in validationResults)"); | ||
| sb.AppendLine(" {"); | ||
| sb.AppendLine(" var propertyName = validationResult.MemberNames.FirstOrDefault() ?? \"Unknown\";"); | ||
| sb.AppendLine(" errors[propertyName] = new[] { validationResult.ErrorMessage ?? \"Validation failed\" };"); | ||
| sb.AppendLine(" }"); | ||
| sb.AppendLine(" }"); | ||
| sb.AppendLine(); | ||
|
|
||
| // Check for required properties on records/classes | ||
| var properties = typeSymbol.GetMembers().OfType<IPropertySymbol>().ToList(); | ||
| foreach (var property in properties) | ||
| { | ||
| // Check if property is marked as required (C# 11 feature) | ||
| if (property.IsRequired) | ||
| { | ||
| sb.AppendLine($" // Check required property: {property.Name}"); | ||
| sb.AppendLine($" if (parameters.{property.Name} == null)"); | ||
| sb.AppendLine(" {"); | ||
| sb.AppendLine($" errors[\"{property.Name}\"] = new[] {{ \"The {property.Name} field is required.\" }};"); | ||
| sb.AppendLine(" }"); | ||
| sb.AppendLine(); | ||
| } | ||
| } | ||
|
|
||
| sb.AppendLine(" return errors.Count == 0;"); | ||
| sb.AppendLine(" }"); | ||
| sb.AppendLine(); | ||
| } | ||
|
|
||
| private class EndpointParameterInfo | ||
| { | ||
| public List<ParameterTypeInfo> Parameters { get; } | ||
|
|
||
| public EndpointParameterInfo(List<ParameterTypeInfo> parameters) | ||
| { | ||
| Parameters = parameters; | ||
| } | ||
| } | ||
|
|
||
| private class ParameterTypeInfo | ||
| { | ||
| public string Name { get; } | ||
| public string TypeName { get; } | ||
| public INamedTypeSymbol TypeSymbol { get; } | ||
| public bool HasAsParameters { get; } | ||
|
|
||
| public ParameterTypeInfo( | ||
| string name, | ||
| string typeName, | ||
| INamedTypeSymbol typeSymbol, | ||
| bool hasAsParameters) | ||
| { | ||
| Name = name; | ||
| TypeName = typeName; | ||
| TypeSymbol = typeSymbol; | ||
| HasAsParameters = hasAsParameters; |
There was a problem hiding this comment.
The HasAsParameters property is extracted and stored but never used anywhere in the code. If this is intended for future use, add a TODO comment. Otherwise, remove this unused code to avoid confusion and reduce maintenance burden.
| using System; | ||
|
|
||
| namespace Arcturus.Validation; | ||
|
|
||
| /// <summary> | ||
| /// Internal marker attribute used by the source generator to track types that need validation. | ||
| /// This attribute is automatically applied during code generation. | ||
| /// </summary> | ||
| [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] | ||
| internal sealed class ValidatableTypeAttribute : Attribute | ||
| { | ||
| } |
There was a problem hiding this comment.
ValidatableTypeAttribute is defined with a comment stating it's "automatically applied during code generation," but there's no code in the generator that actually applies this attribute to generated types. Either implement the attribute application in the generator, or remove this unused attribute and its file to avoid confusion.
| if (context.HttpContext.Response.StatusCode == StatusCodes.Status400BadRequest) | ||
| { | ||
| // Validate all arguments in the endpoint using generated validation method | ||
| var validationResult = ValidationHelper.ValidateArguments(context.Arguments); | ||
| if (validationResult is not null) | ||
| { | ||
| return validationResult; | ||
| } |
There was a problem hiding this comment.
The validation logic is inverted. Validation should execute BEFORE calling the endpoint handler, not after checking if the response status is 400. This condition means validation will never run because the status code is 200 by default before the handler executes.
The filter should validate the arguments first, return early if validation fails, and only call next(context) if validation passes. Remove the StatusCode check and validate unconditionally before calling next.
| if (context.HttpContext.Response.StatusCode == StatusCodes.Status400BadRequest) | |
| { | |
| // Validate all arguments in the endpoint using generated validation method | |
| var validationResult = ValidationHelper.ValidateArguments(context.Arguments); | |
| if (validationResult is not null) | |
| { | |
| return validationResult; | |
| } | |
| // Validate all arguments in the endpoint using generated validation method | |
| var validationResult = ValidationHelper.ValidateArguments(context.Arguments); | |
| if (validationResult is not null) | |
| { | |
| return validationResult; |
…tor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ptions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Summary & Motivation
A brief description of the changes in this pull request explaining why these changes are necessary. Please delete this paragraph.
Checklist