Skip to content

Commit 0df79fb

Browse files
committed
Make Validate methods virtual and support CustomValidationAttribute
1 parent 95b4d5d commit 0df79fb

16 files changed

+559
-163
lines changed

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.IsRequired.get ->
2727
Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.Name.get -> string!
2828
Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.ParameterType.get -> System.Type!
2929
Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.ValidatableParameterInfo(System.Type! parameterType, string! name, string! displayName, bool isNullable, bool isRequired, bool isEnumerable) -> void
30-
Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.Validate(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context) -> System.Threading.Tasks.Task!
3130
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo
3231
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.DeclaringType.get -> System.Type!
3332
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.DisplayName.get -> string!
@@ -38,7 +37,6 @@ Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.IsRequired.get -> b
3837
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.Name.get -> string!
3938
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.PropertyType.get -> System.Type!
4039
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.ValidatablePropertyInfo(System.Type! declaringType, System.Type! propertyType, string! name, string! displayName, bool isEnumerable, bool isNullable, bool isRequired, bool hasValidatableType) -> void
41-
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.Validate(object! obj, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context) -> System.Threading.Tasks.Task!
4240
Microsoft.AspNetCore.Http.Validation.ValidatableTypeAttribute
4341
Microsoft.AspNetCore.Http.Validation.ValidatableTypeAttribute.ValidatableTypeAttribute() -> void
4442
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo
@@ -47,7 +45,6 @@ Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.Members.get -> System.C
4745
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.Type.get -> System.Type!
4846
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.ValidatableSubTypes.get -> System.Collections.Generic.IReadOnlyList<System.Type!>?
4947
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.ValidatableTypeInfo(System.Type! type, System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo!>! members, bool implementsIValidatableObject, System.Collections.Generic.IReadOnlyList<System.Type!>? validatableSubTypes = null) -> void
50-
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.Validate(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context) -> System.Threading.Tasks.Task!
5148
Microsoft.AspNetCore.Http.Validation.ValidationOptions
5249
Microsoft.AspNetCore.Http.Validation.ValidationOptions.MaxDepth.get -> int
5350
Microsoft.AspNetCore.Http.Validation.ValidationOptions.MaxDepth.set -> void
@@ -57,3 +54,6 @@ Microsoft.AspNetCore.Http.Validation.ValidationOptions.TryGetValidatableTypeInfo
5754
Microsoft.AspNetCore.Http.Validation.ValidationOptions.ValidationOptions() -> void
5855
Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions
5956
static Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Http.Validation.ValidationOptions!>? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
57+
virtual Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.Validate(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context) -> System.Threading.Tasks.Task!
58+
virtual Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.Validate(object! obj, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context) -> System.Threading.Tasks.Task!
59+
virtual Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.Validate(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context) -> System.Threading.Tasks.Task!

src/Http/Http.Abstractions/src/Validation/RuntimeValidatableParameterInfoResolver.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal class RuntimeValidatableParameterInfoResolver : IValidatableInfoResolve
1212
{
1313
public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo)
1414
{
15-
Debug.Assert(parameterInfo.Name != null, "Parameter must have name defined.");
15+
Debug.Assert(parameterInfo.Name != null, "Parameter must have name");
1616
var validationAttributes = parameterInfo
1717
.GetCustomAttributes<ValidationAttribute>()
1818
.ToArray();

src/Http/Http.Abstractions/src/Validation/ValidatableParameterInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public ValidatableParameterInfo(
8484
/// If the parameter is a collection, each item in the collection will be validated.
8585
/// If the parameter is not a collection but has a validatable type, the single value will be validated.
8686
/// </remarks>
87-
public Task Validate(object? value, ValidatableContext context)
87+
public virtual Task Validate(object? value, ValidatableContext context)
8888
{
8989
Debug.Assert(context.ValidationContext is not null);
9090

src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public ValidatablePropertyInfo(
9090
/// <param name="obj">The object containing the member to validate.</param>
9191
/// <param name="context">The context for the validation.</param>
9292
/// <returns>A task representing the asynchronous operation.</returns>
93-
public Task Validate(object obj, ValidatableContext context)
93+
public virtual Task Validate(object obj, ValidatableContext context)
9494
{
9595
Debug.Assert(context.ValidationContext is not null);
9696

src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public ValidatableTypeInfo(
5656
/// </summary>
5757
/// <param name="value">The value to validate.</param>
5858
/// <param name="context">The validation context.</param>
59-
public Task Validate(object? value, ValidatableContext context)
59+
public virtual Task Validate(object? value, ValidatableContext context)
6060
{
6161
Debug.Assert(context.ValidationContext is not null);
6262
if (value == null)

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs

Lines changed: 166 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
using System.Linq;
99
using Microsoft.CodeAnalysis.CSharp;
1010
using System.IO;
11+
using System.Collections.Generic;
12+
using System;
13+
using System.Globalization;
1114

1215
namespace Microsoft.AspNetCore.Http.ValidationsGenerator;
1316

@@ -90,14 +93,14 @@ public GeneratedValidatableTypeInfo(
9093
{{GeneratedCodeAttribute}}
9194
file class GeneratedValidatableInfoResolver : global::Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver
9295
{
93-
public ValidatableTypeInfo? GetValidatableTypeInfo(Type type)
96+
public global::Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo? GetValidatableTypeInfo(Type type)
9497
{
9598
{{EmitTypeChecks(validatableTypes)}}
9699
return null;
97100
}
98101
99102
// No-ops, rely on runtime code for ParameterInfo-based resolution
100-
public ValidatableParameterInfo? GetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo)
103+
public global::Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo? GetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo)
101104
{
102105
return null;
103106
}
@@ -126,13 +129,13 @@ public static IServiceCollection AddValidation(this IServiceCollection services,
126129
{{GeneratedCodeAttribute}}
127130
file static class ValidationAttributeCache
128131
{
129-
private sealed record CacheKey(Type AttributeType, string[] Arguments, IReadOnlyDictionary<string, string> NamedArguments);
132+
private sealed record CacheKey(Type AttributeType, object[] Arguments, IReadOnlyDictionary<string, object> NamedArguments);
130133
private static readonly ConcurrentDictionary<CacheKey, ValidationAttribute> _cache = new();
131134
132135
public static ValidationAttribute? GetOrCreateValidationAttribute(
133136
Type attributeType,
134-
string[] arguments,
135-
IReadOnlyDictionary<string, string> namedArguments)
137+
object[] arguments,
138+
IReadOnlyDictionary<string, object> namedArguments)
136139
{
137140
var key = new CacheKey(attributeType, arguments, namedArguments);
138141
return _cache.GetOrAdd(key, static k =>
@@ -155,40 +158,62 @@ _ when typeof(ValidationAttribute).IsAssignableFrom(type) =>
155158
(ValidationAttribute)Activator.CreateInstance(type)!
156159
};
157160
}
161+
else if (type == typeof(CustomValidationAttribute) && args.Length == 2)
162+
{
163+
// CustomValidationAttribute requires special handling
164+
// First argument is a type, second is a method name
165+
if (args[0] is Type validatingType && args[1] is string methodName)
166+
{
167+
attribute = new CustomValidationAttribute(validatingType, methodName);
168+
}
169+
else
170+
{
171+
throw new ArgumentException($"Invalid arguments for CustomValidationAttribute: Type and method name required");
172+
}
173+
}
158174
else if (type == typeof(StringLengthAttribute))
159175
{
160-
if (!int.TryParse(args[0], out var maxLength))
176+
if (args[0] is int maxLength)
177+
attribute = new StringLengthAttribute(maxLength);
178+
else
161179
throw new ArgumentException($"Invalid maxLength value for StringLengthAttribute: {args[0]}");
162-
attribute = new StringLengthAttribute(maxLength);
163180
}
164181
else if (type == typeof(MinLengthAttribute))
165182
{
166-
if (!int.TryParse(args[0], out var length))
183+
if (args[0] is int length)
184+
attribute = new MinLengthAttribute(length);
185+
else
167186
throw new ArgumentException($"Invalid length value for MinLengthAttribute: {args[0]}");
168-
attribute = new MinLengthAttribute(length);
169187
}
170188
else if (type == typeof(MaxLengthAttribute))
171189
{
172-
if (!int.TryParse(args[0], out var length))
190+
if (args[0] is int length)
191+
attribute = new MaxLengthAttribute(length);
192+
else
173193
throw new ArgumentException($"Invalid length value for MaxLengthAttribute: {args[0]}");
174-
attribute = new MaxLengthAttribute(length);
175194
}
176195
else if (type == typeof(RangeAttribute) && args.Length == 2)
177196
{
178-
if (int.TryParse(args[0], out var min) && int.TryParse(args[1], out var max))
197+
if (args[0] is int min && args[1] is int max)
179198
attribute = new RangeAttribute(min, max);
180-
else if (double.TryParse(args[0], out var dmin) && double.TryParse(args[1], out var dmax))
199+
else if (args[0] is double dmin && args[1] is double dmax)
181200
attribute = new RangeAttribute(dmin, dmax);
182201
else
183202
throw new ArgumentException($"Invalid range values for RangeAttribute: {args[0]}, {args[1]}");
184203
}
185204
else if (type == typeof(RegularExpressionAttribute))
186205
{
187-
attribute = new RegularExpressionAttribute(args[0]);
206+
if (args[0] is string pattern)
207+
attribute = new RegularExpressionAttribute(pattern);
208+
else
209+
throw new ArgumentException($"Invalid pattern for RegularExpressionAttribute: {args[0]}");
188210
}
189211
else if (type == typeof(CompareAttribute))
190212
{
191-
attribute = new CompareAttribute(args[0]);
213+
if (args[0] is string otherProperty)
214+
attribute = new CompareAttribute(otherProperty);
215+
else
216+
throw new ArgumentException($"Invalid otherProperty for CompareAttribute: {args[0]}");
192217
}
193218
else if (typeof(ValidationAttribute).IsAssignableFrom(type))
194219
{
@@ -209,7 +234,16 @@ _ when typeof(ValidationAttribute).IsAssignableFrom(type) =>
209234
{
210235
try
211236
{
212-
convertedArgs[i] = Convert.ChangeType(args[i], parameters[i].ParameterType);
237+
if (args[i] != null && args[i].GetType() == parameters[i].ParameterType)
238+
{
239+
// Type already matches, use as-is
240+
convertedArgs[i] = args[i];
241+
}
242+
else
243+
{
244+
// Try to convert
245+
convertedArgs[i] = Convert.ChangeType(args[i], parameters[i].ParameterType);
246+
}
213247
}
214248
catch
215249
{
@@ -244,8 +278,16 @@ _ when typeof(ValidationAttribute).IsAssignableFrom(type) =>
244278
{
245279
try
246280
{
247-
var convertedValue = Convert.ChangeType(namedArg.Value, prop.PropertyType);
248-
prop.SetValue(attribute, convertedValue);
281+
if (namedArg.Value != null && namedArg.Value.GetType() == prop.PropertyType)
282+
{
283+
// Type already matches, use as-is
284+
prop.SetValue(attribute, namedArg.Value);
285+
}
286+
else
287+
{
288+
// Try to convert
289+
prop.SetValue(attribute, Convert.ChangeType(namedArg.Value, prop.PropertyType));
290+
}
249291
}
250292
catch (Exception ex)
251293
{
@@ -260,6 +302,112 @@ _ when typeof(ValidationAttribute).IsAssignableFrom(type) =>
260302
}
261303
}
262304
""";
305+
306+
private static string EmitValidationAttributeForCreate(ValidationAttribute attr)
307+
{
308+
// Process constructor arguments - convert to appropriate typed objects
309+
var processedArgs = new List<string>(attr.Arguments.Count);
310+
311+
foreach (var arg in attr.Arguments)
312+
{
313+
// Handle different types of arguments
314+
if (arg.StartsWith("\"", StringComparison.OrdinalIgnoreCase) && arg.EndsWith("\"", StringComparison.OrdinalIgnoreCase))
315+
{
316+
// String literal - remove quotes and pass as object
317+
var stringValue = arg.Substring(1, arg.Length - 2).Replace("\\\"", "\"");
318+
processedArgs.Add($"\"{stringValue}\"");
319+
}
320+
else if (arg.StartsWith("typeof(", StringComparison.OrdinalIgnoreCase) && arg.EndsWith(")", StringComparison.OrdinalIgnoreCase))
321+
{
322+
// Type argument - pass directly
323+
processedArgs.Add(arg);
324+
}
325+
else if (int.TryParse(arg, out var intValue))
326+
{
327+
// Integer
328+
processedArgs.Add(intValue.ToString(CultureInfo.InvariantCulture));
329+
}
330+
else if (double.TryParse(arg, out var doubleValue))
331+
{
332+
// Double
333+
processedArgs.Add(doubleValue.ToString(CultureInfo.InvariantCulture) + "d");
334+
}
335+
else if (bool.TryParse(arg, out var boolValue))
336+
{
337+
// Boolean
338+
processedArgs.Add(boolValue.ToString().ToLowerInvariant());
339+
}
340+
else if (arg == "null")
341+
{
342+
// Null
343+
processedArgs.Add("null");
344+
}
345+
else
346+
{
347+
// Default to string for anything else
348+
processedArgs.Add($"\"{arg.Replace("\"", "\\\"")}\"");
349+
}
350+
}
351+
352+
var args = attr.Arguments.Count > 0
353+
? $"new object[] {{ {string.Join(", ", processedArgs)} }}"
354+
: "Array.Empty<object>()";
355+
356+
// Process named arguments - ensure proper formatting for object dictionary
357+
var namedArgsParts = new List<string>(attr.NamedArguments.Count);
358+
foreach (var pair in attr.NamedArguments)
359+
{
360+
// Convert the value based on its format
361+
var valueStr = pair.Value;
362+
string objectValue;
363+
364+
if (valueStr.StartsWith("\"", StringComparison.OrdinalIgnoreCase) && valueStr.EndsWith("\"", StringComparison.OrdinalIgnoreCase))
365+
{
366+
// String literal
367+
objectValue = valueStr;
368+
}
369+
else if (valueStr.StartsWith("typeof(", StringComparison.OrdinalIgnoreCase) && valueStr.EndsWith(")", StringComparison.OrdinalIgnoreCase))
370+
{
371+
// Type argument
372+
objectValue = valueStr;
373+
}
374+
else if (int.TryParse(valueStr, out _))
375+
{
376+
// Integer
377+
objectValue = valueStr;
378+
}
379+
else if (double.TryParse(valueStr, out _))
380+
{
381+
// Double
382+
objectValue = valueStr + "d";
383+
}
384+
else if (bool.TryParse(valueStr, out var boolVal))
385+
{
386+
// Boolean
387+
objectValue = boolVal.ToString().ToLowerInvariant();
388+
}
389+
else if (valueStr == "null")
390+
{
391+
// Null
392+
objectValue = "null";
393+
}
394+
else
395+
{
396+
// Default to string for anything else
397+
objectValue = $"\"{valueStr.Replace("\"", "\\\"")}\"";
398+
}
399+
400+
namedArgsParts.Add($"{{ \"{pair.Key}\", {objectValue} }}");
401+
}
402+
403+
var namedArgs = attr.NamedArguments.Count > 0
404+
? $"new Dictionary<string, object> {{ {string.Join(", ", namedArgsParts)} }}"
405+
: "new Dictionary<string, object>()";
406+
407+
// Use string interpolation with @ to prevent escaping issues in the error message
408+
return $@"ValidationAttributeCache.GetOrCreateValidationAttribute(typeof({attr.ClassName}), {args}, {namedArgs}) ?? throw new InvalidOperationException(@""Failed to create validation attribute {attr.ClassName}"")";
409+
}
410+
263411
private static string EmitTypeChecks(ImmutableArray<ValidatableType> validatableTypes)
264412
{
265413
var sw = new StringWriter();
@@ -318,19 +466,6 @@ private static string EmitValidatableMemberForCreate(ValidatableProperty member)
318466
""";
319467
}
320468

321-
private static string EmitValidationAttributeForCreate(ValidationAttribute attr)
322-
{
323-
var args = attr.Arguments.Count > 0
324-
? $"new string[] {{ {string.Join(", ", attr.Arguments.Select(a => $@"""{a}"""))} }}"
325-
: "Array.Empty<string>()";
326-
327-
var namedArgs = attr.NamedArguments.Count > 0
328-
? $"new Dictionary<string, string> {{ {string.Join(", ", attr.NamedArguments.Select(x => $@"{{ ""{x.Key}"", {x.Value} }}"))} }}"
329-
: "new Dictionary<string, string>()";
330-
331-
return $"ValidationAttributeCache.GetOrCreateValidationAttribute(typeof({attr.ClassName}), {args}, {namedArgs}) ?? throw new InvalidOperationException(\"Failed to create validation attribute {attr.ClassName}\")";
332-
}
333-
334469
private static string SanitizeTypeName(string typeName)
335470
{
336471
// Replace invalid characters with underscores and remove generic notation

src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Models/RequiredSymbols.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ internal sealed record class RequiredSymbols(
1111
INamedTypeSymbol IEnumerable,
1212
INamedTypeSymbol IValidatableObject,
1313
INamedTypeSymbol JsonDerivedTypeAttribute,
14-
INamedTypeSymbol RequiredAttribute
14+
INamedTypeSymbol RequiredAttribute,
15+
INamedTypeSymbol CustomValidationAttribute
1516
);

0 commit comments

Comments
 (0)