Skip to content

139 arcturusvalidation introduce validation for minimal api using source generators#140

Merged
cloudfy merged 9 commits intomainfrom
139-arcturusvalidation-introduce-validation-for-minimal-api-using-source-generators
Feb 25, 2026
Merged

139 arcturusvalidation introduce validation for minimal api using source generators#140
cloudfy merged 9 commits intomainfrom
139-arcturusvalidation-introduce-validation-for-minimal-api-using-source-generators

Conversation

@cloudfy
Copy link
Copy Markdown
Owner

@cloudfy cloudfy commented Feb 25, 2026

Summary & Motivation

A brief description of the changes in this pull request explaining why these changes are necessary. Please delete this paragraph.

Checklist

  • I have added tests, or done manual regression tests
  • I have updated the documentation, if necessary

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.
@cloudfy cloudfy requested a review from Copilot February 25, 2026 12:58
@cloudfy cloudfy self-assigned this Feb 25, 2026
@cloudfy cloudfy added the enhancement New feature or request label Feb 25, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.CodeGenerator package containing a Roslyn source generator that detects endpoints using validation filters and generates type-specific validation extension methods
  • Added Arcturus.Extensions.Validation.AspNetCore package providing runtime support including ValidateParametersFilter, ValidationHelper, and extension methods for endpoint validation
  • Enhanced ResultObjectOptions with configurable ProblemDetailsFactory registration 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);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,244 @@
# Arcturus.Extensions.Validation.AspNetCore
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +277 to +279
sb.AppendLine(" var propertyName = validationResult.MemberNames.FirstOrDefault() ?? \"Unknown\";");
sb.AppendLine(" errors[propertyName] = new[] { validationResult.ErrorMessage ?? \"Validation failed\" };");
sb.AppendLine(" }");
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +295
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();
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Arcturus.Extensions.Validation.AspNetCore/README.md Outdated
Comment thread src/Arcturus.Validation.CodeGenerator/EndpointValidationGenerator.cs Outdated
Comment on lines +116 to +330
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;
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +12
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
{
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +18
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;
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment thread src/Arcturus.Extensions.ResultObjects.AspNetCore/ResultObjectOptions.cs Outdated
cloudfy and others added 3 commits February 25, 2026 14:09
…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>
@cloudfy cloudfy merged commit 54f9df3 into main Feb 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Arcturus.Validation] Introduce Validation for minimal API using source generators

2 participants