From 68376fbc947aac76ac7869d3111e196f6cf58fa0 Mon Sep 17 00:00:00 2001 From: Nazem Date: Fri, 28 Aug 2026 19:23:45 +0300 Subject: [PATCH 01/10] feat(http): add API description model contributor extension point --- .../Mvc/AspNetCoreApiDescriptionModelProvider.cs | 11 +++++++++-- .../IPropertyApiDescriptionModelContributor.cs | 8 ++++++++ .../Abp/Http/Modeling/TypeApiDescriptionModel.cs | 16 ++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index bd5e1e82a04..cbb42f78229 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -34,7 +35,7 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions; private readonly AbpApiDescriptionModelOptions _modelOptions; private readonly IXmlDocumentationProvider _xmlDocProvider; - + public IAbpLazyServiceProvider LazyServiceProvider { get; set; } = default!; public AspNetCoreApiDescriptionModelProvider( IOptions options, IApiDescriptionGroupCollectionProvider descriptionProvider, @@ -314,7 +315,13 @@ private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel app return; } - applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type); + var contributors = + LazyServiceProvider + .GetServices() + .ToHashSet(); + var typeModel = TypeApiDescriptionModel.Create(type, contributors); + applicationModel.Types[typeName] = typeModel; + if (includeDescriptions) { diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs new file mode 100644 index 00000000000..5aece849917 --- /dev/null +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Volo.Abp.Http.Modeling; + +public interface IPropertyApiDescriptionModelContributor +{ + void Contribute(PropertyApiDescriptionModel model, PropertyInfo propertyInfo); +} diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs index 703c5a8583d..ce4f13c2ee6 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using Volo.Abp.Reflection; @@ -33,7 +34,7 @@ public TypeApiDescriptionModel() } - public static TypeApiDescriptionModel Create(Type type) + public static TypeApiDescriptionModel Create(Type type, IEnumerable? contributors = default) { var baseType = type.BaseType; if (baseType == typeof(object)) @@ -57,7 +58,18 @@ public static TypeApiDescriptionModel Create(Type type) typeModel.Properties = type .GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(p => p.DeclaringType == type) - .Select(PropertyApiDescriptionModel.Create) + .Select(p => + { + var model = PropertyApiDescriptionModel.Create(p); + if (contributors != null && contributors.Any()) + { + foreach (var contributor in contributors) + { + contributor.Contribute(model, p); + } + } + return model; + }) .ToArray(); if (type.IsGenericTypeDefinition) From f7dde2959428a2feba6c93407d09a44680704b54 Mon Sep 17 00:00:00 2001 From: Nazem Date: Fri, 28 Aug 2026 19:23:54 +0300 Subject: [PATCH 02/10] feat(fluentvalidation): add API description model contributor --- .../Volo.Abp.FluentValidation.csproj | 1 + .../AbpFluentValidationModule.cs | 11 +- ...alidationApiDescriptionModelContributor.cs | 377 ++++++++++++++++++ 3 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs diff --git a/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj b/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj index 563ef49f801..0f2eb70d444 100644 --- a/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj +++ b/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj @@ -22,6 +22,7 @@ + diff --git a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs index 1753e76351e..fa2fc0885a7 100644 --- a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs +++ b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs @@ -1,11 +1,14 @@ using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Http; +using Volo.Abp.Http.Modeling; using Volo.Abp.Modularity; using Volo.Abp.Validation; namespace Volo.Abp.FluentValidation; [DependsOn( - typeof(AbpValidationModule) + typeof(AbpValidationModule), + typeof(AbpHttpModule) )] public class AbpFluentValidationModule : AbpModule { @@ -13,4 +16,10 @@ public override void PreConfigureServices(ServiceConfigurationContext context) { context.Services.AddConventionalRegistrar(new AbpFluentValidationConventionalRegistrar()); } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + base.ConfigureServices(context); + context.Services.AddTransient(); + } } diff --git a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs new file mode 100644 index 00000000000..7116ef4706f --- /dev/null +++ b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs @@ -0,0 +1,377 @@ +using FluentValidation; +using FluentValidation.Validators; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Http.Modeling; + +namespace Volo.Abp.FluentValidation; + +public class FluentValidationApiDescriptionModelContributor : IPropertyApiDescriptionModelContributor +{ + public IAbpLazyServiceProvider LazyServiceProvider { get; set; } = default!; + public virtual void Contribute( + PropertyApiDescriptionModel model, + PropertyInfo propertyInfo) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(propertyInfo); + + var declaringType = propertyInfo.DeclaringType; + + if (declaringType == null) + { + return; + } + + // Guard against open generic types (e.g. a property inherited from + // a generic base DTO like ExtensibleFullAuditedEntityDto + // where TPrimaryKey hasn't been substituted with a concrete type). + // There is no real IValidator<> for an open generic, so DI can never + // have it registered — attempting the lookup would either throw or + // be meaningless. + if (declaringType.ContainsGenericParameters) + { + return; + } + + var validatorType = + typeof(IValidator<>).MakeGenericType(declaringType); + + var validators = + LazyServiceProvider + .GetServices(validatorType) + .ToArray(); + + if (validators.Length == 0) + { + return; + } + + foreach (var validator in validators) + { + + if (validator is IValidator typedValidator) + { + + ApplyValidatorRules( + model, + propertyInfo, + typedValidator); + } + } + } + + protected virtual void ApplyValidatorRules( + PropertyApiDescriptionModel model, + PropertyInfo propertyInfo, + IValidator validator) + { + var descriptor = validator.CreateDescriptor(); + + var rule = descriptor + .GetMembersWithValidators() + .FirstOrDefault(x => + string.Equals( + x.Key, + propertyInfo.Name, + StringComparison.Ordinal)); + + if (rule == null) + { + return; + } + + foreach (var (Validator, Options) in rule) + { + /* + * Only statically-evaluable validators are reflected here. + * + * FluentValidation conditions such as: + * + * When(...) + * Unless(...) + * + * are intentionally ignored. They depend on the runtime state + * of the object being validated (other property values, external + * context, etc.), so there is no single correct answer to + * "is this required?" at the type/schema level — the same + * property could be required in one instance and optional in + * another. Baking a conditional rule into a static schema would + * misrepresent it either way, so we skip it rather than guess. + * + * If the validator is a custom/unsupported type, it simply + * won't match one of the cases below and is ignored. + */ + if (Options.HasCondition || Options.HasAsyncCondition) + continue; + ApplyValidator( + model, + Validator); + } + } + + protected virtual void ApplyValidator( + PropertyApiDescriptionModel model, + IPropertyValidator validator) + { + switch (validator) + { + // ============================================================ + // Required + // + // NotNull() -> value must not be null (empty string/whitespace + // still pass) + // NotEmpty() -> value must not be null AND not the "empty" + // value for its type (empty/whitespace string, + // default(T), empty collection, all fail) + // + // Both are stricter-or-equal to "must be present," so both + // map to IsRequired = true. NotEmpty is the stronger check; + // if both happen to be applied, keep IsRequired true either way. + // ============================================================ + + case INotEmptyValidator: + case INotNullValidator: + + model.IsRequired = true; + + break; + + // ============================================================ + // Length + // + // Length(min, max) + // MinimumLength(min) + // MaximumLength(max) + // ============================================================ + + case ILengthValidator lengthValidator: + + ApplyLength( + model, + lengthValidator); + + break; + + // ============================================================ + // Regex + // + // Matches(...) + // ============================================================ + + case IRegularExpressionValidator regexValidator: + + ApplyRegex( + model, + regexValidator); + + break; + + // ============================================================ + // Comparisons + // + // GreaterThan(...) + // GreaterThanOrEqualTo(...) + // LessThan(...) + // LessThanOrEqualTo(...) + // ============================================================ + + case IComparisonValidator comparisonValidator: + + ApplyComparison( + model, + comparisonValidator); + + break; + + // ============================================================ + // Range shortcuts + // + // InclusiveBetween(min, max) + // ExclusiveBetween(min, max) + // + // FluentValidation implements these as a single validator + // exposing both bounds, rather than as two IComparisonValidator + // instances, so they need their own case. + // ============================================================ + + case IBetweenValidator betweenValidator: + + ApplyBetween( + model, + betweenValidator); + + break; + } + } + + protected virtual void ApplyLength( + PropertyApiDescriptionModel model, + ILengthValidator validator) + { + if (validator.Min > 0) + { + model.MinLength = + model.MinLength.HasValue + ? Math.Max( + model.MinLength.Value, + validator.Min) + : validator.Min; + } + + if (validator.Max > 0) + { + model.MaxLength = + model.MaxLength.HasValue + ? Math.Min( + model.MaxLength.Value, + validator.Max) + : validator.Max; + } + } + + protected virtual void ApplyRegex( + PropertyApiDescriptionModel model, + IRegularExpressionValidator validator) + { + if (!string.IsNullOrWhiteSpace( + validator.Expression)) + { + model.Regex = + validator.Expression; + } + } + + protected virtual void ApplyComparison( + PropertyApiDescriptionModel model, + IComparisonValidator validator) + { + var value = FormatComparisonValue(validator.ValueToCompare); + + if (value == null) + { + return; + } + + switch (validator.Comparison) + { + case Comparison.GreaterThan: + case Comparison.GreaterThanOrEqual: + + ApplyMinimum( + model, + value); + + break; + + case Comparison.LessThan: + case Comparison.LessThanOrEqual: + + ApplyMaximum( + model, + value); + + break; + } + } + + protected virtual void ApplyBetween( + PropertyApiDescriptionModel model, + IBetweenValidator validator) + { + var from = FormatComparisonValue(validator.From); + var to = FormatComparisonValue(validator.To); + + if (from != null) + { + ApplyMinimum( + model, + from); + } + + if (to != null) + { + ApplyMaximum( + model, + to); + } + } + + protected virtual string? FormatComparisonValue(object? rawValue) + { + if (rawValue == null) + { + return null; + } + + var value = Convert.ToString( + rawValue, + CultureInfo.InvariantCulture); + + return string.IsNullOrWhiteSpace(value) + ? null + : value; + } + + protected virtual void ApplyMinimum( + PropertyApiDescriptionModel model, + string value) + { + if (!decimal.TryParse( + value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var minimum)) + { + return; + } + + if (decimal.TryParse( + model.Minimum, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var existingMinimum)) + { + minimum = Math.Max( + minimum, + existingMinimum); + } + + model.Minimum = + minimum.ToString( + CultureInfo.InvariantCulture); + } + + protected virtual void ApplyMaximum( + PropertyApiDescriptionModel model, + string value) + { + if (!decimal.TryParse( + value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var maximum)) + { + return; + } + + if (decimal.TryParse( + model.Maximum, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var existingMaximum)) + { + maximum = Math.Min( + maximum, + existingMaximum); + } + + model.Maximum = + maximum.ToString( + CultureInfo.InvariantCulture); + } +} From 37d5880b6532595d1f50661026185ffd7851ef84 Mon Sep 17 00:00:00 2001 From: maliming Date: Sat, 29 Aug 2026 17:45:41 +0800 Subject: [PATCH 03/10] Report the exclusive range bounds in the property api description The RangeAttribute supports exclusive bounds since .NET 8, but they were lost in the api definition. The bounds are also written with the invariant culture now, so the api definition does not depend on the culture of the request. --- .../Modeling/PropertyApiDescriptionModel.cs | 40 ++++++++++- .../PropertyApiDescriptionModel_Tests.cs | 70 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs index d0bf430546c..7f80468702c 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Linq; using System.Reflection; using Volo.Abp.Http.ProxyScripting.Configuration; @@ -28,6 +29,10 @@ public class PropertyApiDescriptionModel public string? Maximum { get; set; } + public bool? MinimumIsExclusive { get; set; } + + public bool? MaximumIsExclusive { get; set; } + public string? Regex { get; set; } public bool IsNullable { get; set; } @@ -41,6 +46,7 @@ public class PropertyApiDescriptionModel public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) { var customAttributes = propertyInfo.GetCustomAttributes(true); + var rangeAttribute = customAttributes.OfType().FirstOrDefault(); return new PropertyApiDescriptionModel { Name = propertyInfo.Name, @@ -49,11 +55,41 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(propertyInfo.PropertyType), IsRequired = customAttributes.OfType().Any() || propertyInfo.GetCustomAttributesData().Any(attr => attr.AttributeType.Name == "RequiredMemberAttribute"), IsNullable = ReflectionHelper.IsNullable(propertyInfo), - Minimum = customAttributes.OfType().Select(x => x.Minimum).FirstOrDefault()?.ToString(), - Maximum = customAttributes.OfType().Select(x => x.Maximum).FirstOrDefault()?.ToString(), + Minimum = rangeAttribute != null ? Convert.ToString(rangeAttribute.Minimum, CultureInfo.InvariantCulture) : null, + Maximum = rangeAttribute != null ? Convert.ToString(rangeAttribute.Maximum, CultureInfo.InvariantCulture) : null, + MinimumIsExclusive = GetMinimumIsExclusive(rangeAttribute), + MaximumIsExclusive = GetMaximumIsExclusive(rangeAttribute), MinLength = customAttributes.OfType().FirstOrDefault()?.Length ?? customAttributes.OfType().FirstOrDefault()?.MinimumLength, MaxLength = customAttributes.OfType().FirstOrDefault()?.Length ?? customAttributes.OfType().FirstOrDefault()?.MaximumLength, Regex= customAttributes.OfType().Select(x => x.Pattern).FirstOrDefault() }; } + + private static bool? GetMinimumIsExclusive(RangeAttribute? rangeAttribute) + { + if (rangeAttribute == null) + { + return null; + } + +#if NET8_0_OR_GREATER + return rangeAttribute.MinimumIsExclusive; +#else + return false; +#endif + } + + private static bool? GetMaximumIsExclusive(RangeAttribute? rangeAttribute) + { + if (rangeAttribute == null) + { + return null; + } + +#if NET8_0_OR_GREATER + return rangeAttribute.MaximumIsExclusive; +#else + return false; +#endif + } } diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs new file mode 100644 index 00000000000..546782178f3 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs @@ -0,0 +1,70 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Shouldly; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.Http.Modeling; + +public class PropertyApiDescriptionModel_Tests +{ + [Fact] + public void Create_Should_Write_Range_Bounds_With_The_Invariant_Culture() + { + using (CultureHelper.Use(CultureInfo.GetCultureInfo("de-DE"))) + { + var model = CreateModel(nameof(TestClass.DecimalRangeValue)); + + model.Minimum.ShouldBe("1.5"); + model.Maximum.ShouldBe("9.5"); + } + } + + [Fact] + public void Create_Should_Read_The_Exclusive_Bounds_Of_The_Range_Attribute() + { + var model = CreateModel(nameof(TestClass.ExclusiveRangeValue)); + + model.Minimum.ShouldBe("1"); + model.Maximum.ShouldBe("100"); + model.MinimumIsExclusive.ShouldBe(true); + model.MaximumIsExclusive.ShouldBe(true); + } + + [Fact] + public void Create_Should_Report_An_Inclusive_Range_Attribute_As_Inclusive() + { + var model = CreateModel(nameof(TestClass.DecimalRangeValue)); + + model.MinimumIsExclusive.ShouldBe(false); + model.MaximumIsExclusive.ShouldBe(false); + } + + [Fact] + public void Create_Should_Leave_The_Exclusive_Bounds_Null_Without_A_Range_Attribute() + { + var model = CreateModel(nameof(TestClass.UnconstrainedValue)); + + model.Minimum.ShouldBeNull(); + model.Maximum.ShouldBeNull(); + model.MinimumIsExclusive.ShouldBeNull(); + model.MaximumIsExclusive.ShouldBeNull(); + } + + private static PropertyApiDescriptionModel CreateModel(string propertyName) + { + return PropertyApiDescriptionModel.Create(typeof(TestClass).GetProperty(propertyName)!); + } + + public class TestClass + { + [Range(1.5, 9.5)] + public double DecimalRangeValue { get; set; } + + [Range(1, 100, MinimumIsExclusive = true, MaximumIsExclusive = true)] + public int ExclusiveRangeValue { get; set; } + + public string? UnconstrainedValue { get; set; } + } +} From 22ebf24aedc7ecf3fba3760072c3c3c0dd158b04 Mon Sep 17 00:00:00 2001 From: maliming Date: Sat, 29 Aug 2026 17:45:53 +0800 Subject: [PATCH 04/10] Add a contributor extension point to the property api description IPropertyApiDescriptionModelContributor lets a package enrich the properties of a type after they are created from the attributes. The contributors are applied by AspNetCoreApiDescriptionModelProvider, so TypeApiDescriptionModel stays a plain model. --- .../AspNetCoreApiDescriptionModelProvider.cs | 35 +- .../Volo.Abp.FluentValidation.csproj | 1 - .../AbpFluentValidationModule.cs | 11 +- ...alidationApiDescriptionModelContributor.cs | 377 ------------------ ...IPropertyApiDescriptionModelContributor.cs | 4 +- ...yApiDescriptionModelContributionContext.cs | 21 + .../Http/Modeling/TypeApiDescriptionModel.cs | 16 +- 7 files changed, 51 insertions(+), 414 deletions(-) delete mode 100644 framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs create mode 100644 framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index cbb42f78229..4c8f0a5bec1 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -35,19 +34,22 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions; private readonly AbpApiDescriptionModelOptions _modelOptions; private readonly IXmlDocumentationProvider _xmlDocProvider; - public IAbpLazyServiceProvider LazyServiceProvider { get; set; } = default!; + private readonly IPropertyApiDescriptionModelContributor[] _propertyContributors; + public AspNetCoreApiDescriptionModelProvider( IOptions options, IApiDescriptionGroupCollectionProvider descriptionProvider, IOptions abpAspNetCoreMvcOptions, IOptions modelOptions, - IXmlDocumentationProvider xmlDocProvider) + IXmlDocumentationProvider xmlDocProvider, + IEnumerable propertyContributors) { _options = options.Value; _descriptionProvider = descriptionProvider; _abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value; _modelOptions = modelOptions.Value; _xmlDocProvider = xmlDocProvider; + _propertyContributors = propertyContributors.ToArray(); Logger = NullLogger.Instance; } @@ -315,19 +317,15 @@ private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel app return; } - var contributors = - LazyServiceProvider - .GetServices() - .ToHashSet(); - var typeModel = TypeApiDescriptionModel.Create(type, contributors); - applicationModel.Types[typeName] = typeModel; - + applicationModel.Types[typeName] = TypeApiDescriptionModel.Create(type); if (includeDescriptions) { await PopulateTypeDescriptionsAsync(applicationModel.Types[typeName], type); } + await ContributeToPropertiesAsync(applicationModel.Types[typeName], type); + await AddCustomTypesToModelAsync(applicationModel, type.BaseType, includeDescriptions); foreach (var propertyInfo in type.GetProperties().Where(p => p.DeclaringType == type)) @@ -336,6 +334,23 @@ private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel app } } + protected virtual async Task ContributeToPropertiesAsync(TypeApiDescriptionModel typeModel, Type type) + { + if (_propertyContributors.IsNullOrEmpty() || typeModel.Properties.IsNullOrEmpty()) + { + return; + } + + foreach (var propertyModel in typeModel.Properties!) + { + var context = new PropertyApiDescriptionModelContributionContext(propertyModel, type); + foreach (var contributor in _propertyContributors) + { + await contributor.ContributeAsync(context); + } + } + } + private static string CalculateTypeName(Type type) { if (!type.IsGenericTypeDefinition) diff --git a/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj b/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj index 0f2eb70d444..563ef49f801 100644 --- a/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj +++ b/framework/src/Volo.Abp.FluentValidation/Volo.Abp.FluentValidation.csproj @@ -22,7 +22,6 @@ - diff --git a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs index fa2fc0885a7..1753e76351e 100644 --- a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs +++ b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/AbpFluentValidationModule.cs @@ -1,14 +1,11 @@ using Microsoft.Extensions.DependencyInjection; -using Volo.Abp.Http; -using Volo.Abp.Http.Modeling; using Volo.Abp.Modularity; using Volo.Abp.Validation; namespace Volo.Abp.FluentValidation; [DependsOn( - typeof(AbpValidationModule), - typeof(AbpHttpModule) + typeof(AbpValidationModule) )] public class AbpFluentValidationModule : AbpModule { @@ -16,10 +13,4 @@ public override void PreConfigureServices(ServiceConfigurationContext context) { context.Services.AddConventionalRegistrar(new AbpFluentValidationConventionalRegistrar()); } - - public override void ConfigureServices(ServiceConfigurationContext context) - { - base.ConfigureServices(context); - context.Services.AddTransient(); - } } diff --git a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs deleted file mode 100644 index 7116ef4706f..00000000000 --- a/framework/src/Volo.Abp.FluentValidation/Volo/Abp/FluentValidation/FluentValidationApiDescriptionModelContributor.cs +++ /dev/null @@ -1,377 +0,0 @@ -using FluentValidation; -using FluentValidation.Validators; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Globalization; -using System.Linq; -using System.Reflection; -using Volo.Abp.DependencyInjection; -using Volo.Abp.Http.Modeling; - -namespace Volo.Abp.FluentValidation; - -public class FluentValidationApiDescriptionModelContributor : IPropertyApiDescriptionModelContributor -{ - public IAbpLazyServiceProvider LazyServiceProvider { get; set; } = default!; - public virtual void Contribute( - PropertyApiDescriptionModel model, - PropertyInfo propertyInfo) - { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(propertyInfo); - - var declaringType = propertyInfo.DeclaringType; - - if (declaringType == null) - { - return; - } - - // Guard against open generic types (e.g. a property inherited from - // a generic base DTO like ExtensibleFullAuditedEntityDto - // where TPrimaryKey hasn't been substituted with a concrete type). - // There is no real IValidator<> for an open generic, so DI can never - // have it registered — attempting the lookup would either throw or - // be meaningless. - if (declaringType.ContainsGenericParameters) - { - return; - } - - var validatorType = - typeof(IValidator<>).MakeGenericType(declaringType); - - var validators = - LazyServiceProvider - .GetServices(validatorType) - .ToArray(); - - if (validators.Length == 0) - { - return; - } - - foreach (var validator in validators) - { - - if (validator is IValidator typedValidator) - { - - ApplyValidatorRules( - model, - propertyInfo, - typedValidator); - } - } - } - - protected virtual void ApplyValidatorRules( - PropertyApiDescriptionModel model, - PropertyInfo propertyInfo, - IValidator validator) - { - var descriptor = validator.CreateDescriptor(); - - var rule = descriptor - .GetMembersWithValidators() - .FirstOrDefault(x => - string.Equals( - x.Key, - propertyInfo.Name, - StringComparison.Ordinal)); - - if (rule == null) - { - return; - } - - foreach (var (Validator, Options) in rule) - { - /* - * Only statically-evaluable validators are reflected here. - * - * FluentValidation conditions such as: - * - * When(...) - * Unless(...) - * - * are intentionally ignored. They depend on the runtime state - * of the object being validated (other property values, external - * context, etc.), so there is no single correct answer to - * "is this required?" at the type/schema level — the same - * property could be required in one instance and optional in - * another. Baking a conditional rule into a static schema would - * misrepresent it either way, so we skip it rather than guess. - * - * If the validator is a custom/unsupported type, it simply - * won't match one of the cases below and is ignored. - */ - if (Options.HasCondition || Options.HasAsyncCondition) - continue; - ApplyValidator( - model, - Validator); - } - } - - protected virtual void ApplyValidator( - PropertyApiDescriptionModel model, - IPropertyValidator validator) - { - switch (validator) - { - // ============================================================ - // Required - // - // NotNull() -> value must not be null (empty string/whitespace - // still pass) - // NotEmpty() -> value must not be null AND not the "empty" - // value for its type (empty/whitespace string, - // default(T), empty collection, all fail) - // - // Both are stricter-or-equal to "must be present," so both - // map to IsRequired = true. NotEmpty is the stronger check; - // if both happen to be applied, keep IsRequired true either way. - // ============================================================ - - case INotEmptyValidator: - case INotNullValidator: - - model.IsRequired = true; - - break; - - // ============================================================ - // Length - // - // Length(min, max) - // MinimumLength(min) - // MaximumLength(max) - // ============================================================ - - case ILengthValidator lengthValidator: - - ApplyLength( - model, - lengthValidator); - - break; - - // ============================================================ - // Regex - // - // Matches(...) - // ============================================================ - - case IRegularExpressionValidator regexValidator: - - ApplyRegex( - model, - regexValidator); - - break; - - // ============================================================ - // Comparisons - // - // GreaterThan(...) - // GreaterThanOrEqualTo(...) - // LessThan(...) - // LessThanOrEqualTo(...) - // ============================================================ - - case IComparisonValidator comparisonValidator: - - ApplyComparison( - model, - comparisonValidator); - - break; - - // ============================================================ - // Range shortcuts - // - // InclusiveBetween(min, max) - // ExclusiveBetween(min, max) - // - // FluentValidation implements these as a single validator - // exposing both bounds, rather than as two IComparisonValidator - // instances, so they need their own case. - // ============================================================ - - case IBetweenValidator betweenValidator: - - ApplyBetween( - model, - betweenValidator); - - break; - } - } - - protected virtual void ApplyLength( - PropertyApiDescriptionModel model, - ILengthValidator validator) - { - if (validator.Min > 0) - { - model.MinLength = - model.MinLength.HasValue - ? Math.Max( - model.MinLength.Value, - validator.Min) - : validator.Min; - } - - if (validator.Max > 0) - { - model.MaxLength = - model.MaxLength.HasValue - ? Math.Min( - model.MaxLength.Value, - validator.Max) - : validator.Max; - } - } - - protected virtual void ApplyRegex( - PropertyApiDescriptionModel model, - IRegularExpressionValidator validator) - { - if (!string.IsNullOrWhiteSpace( - validator.Expression)) - { - model.Regex = - validator.Expression; - } - } - - protected virtual void ApplyComparison( - PropertyApiDescriptionModel model, - IComparisonValidator validator) - { - var value = FormatComparisonValue(validator.ValueToCompare); - - if (value == null) - { - return; - } - - switch (validator.Comparison) - { - case Comparison.GreaterThan: - case Comparison.GreaterThanOrEqual: - - ApplyMinimum( - model, - value); - - break; - - case Comparison.LessThan: - case Comparison.LessThanOrEqual: - - ApplyMaximum( - model, - value); - - break; - } - } - - protected virtual void ApplyBetween( - PropertyApiDescriptionModel model, - IBetweenValidator validator) - { - var from = FormatComparisonValue(validator.From); - var to = FormatComparisonValue(validator.To); - - if (from != null) - { - ApplyMinimum( - model, - from); - } - - if (to != null) - { - ApplyMaximum( - model, - to); - } - } - - protected virtual string? FormatComparisonValue(object? rawValue) - { - if (rawValue == null) - { - return null; - } - - var value = Convert.ToString( - rawValue, - CultureInfo.InvariantCulture); - - return string.IsNullOrWhiteSpace(value) - ? null - : value; - } - - protected virtual void ApplyMinimum( - PropertyApiDescriptionModel model, - string value) - { - if (!decimal.TryParse( - value, - NumberStyles.Number, - CultureInfo.InvariantCulture, - out var minimum)) - { - return; - } - - if (decimal.TryParse( - model.Minimum, - NumberStyles.Number, - CultureInfo.InvariantCulture, - out var existingMinimum)) - { - minimum = Math.Max( - minimum, - existingMinimum); - } - - model.Minimum = - minimum.ToString( - CultureInfo.InvariantCulture); - } - - protected virtual void ApplyMaximum( - PropertyApiDescriptionModel model, - string value) - { - if (!decimal.TryParse( - value, - NumberStyles.Number, - CultureInfo.InvariantCulture, - out var maximum)) - { - return; - } - - if (decimal.TryParse( - model.Maximum, - NumberStyles.Number, - CultureInfo.InvariantCulture, - out var existingMaximum)) - { - maximum = Math.Min( - maximum, - existingMaximum); - } - - model.Maximum = - maximum.ToString( - CultureInfo.InvariantCulture); - } -} diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs index 5aece849917..a802b564093 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs @@ -1,8 +1,8 @@ -using System.Reflection; +using System.Threading.Tasks; namespace Volo.Abp.Http.Modeling; public interface IPropertyApiDescriptionModelContributor { - void Contribute(PropertyApiDescriptionModel model, PropertyInfo propertyInfo); + Task ContributeAsync(PropertyApiDescriptionModelContributionContext context); } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs new file mode 100644 index 00000000000..a7bda9890c6 --- /dev/null +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs @@ -0,0 +1,21 @@ +using System; +using JetBrains.Annotations; + +namespace Volo.Abp.Http.Modeling; + +public class PropertyApiDescriptionModelContributionContext +{ + [NotNull] + public PropertyApiDescriptionModel Model { get; } + + [NotNull] + public Type DeclaringType { get; } + + public PropertyApiDescriptionModelContributionContext( + [NotNull] PropertyApiDescriptionModel model, + [NotNull] Type declaringType) + { + Model = Check.NotNull(model, nameof(model)); + DeclaringType = Check.NotNull(declaringType, nameof(declaringType)); + } +} diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs index ce4f13c2ee6..703c5a8583d 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/TypeApiDescriptionModel.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Reflection; using Volo.Abp.Reflection; @@ -34,7 +33,7 @@ public TypeApiDescriptionModel() } - public static TypeApiDescriptionModel Create(Type type, IEnumerable? contributors = default) + public static TypeApiDescriptionModel Create(Type type) { var baseType = type.BaseType; if (baseType == typeof(object)) @@ -58,18 +57,7 @@ public static TypeApiDescriptionModel Create(Type type, IEnumerable p.DeclaringType == type) - .Select(p => - { - var model = PropertyApiDescriptionModel.Create(p); - if (contributors != null && contributors.Any()) - { - foreach (var contributor in contributors) - { - contributor.Contribute(model, p); - } - } - return model; - }) + .Select(PropertyApiDescriptionModel.Create) .ToArray(); if (type.IsGenericTypeDefinition) From 3b011f7703407b9a68524015d6c515efaa260b55 Mon Sep 17 00:00:00 2001 From: maliming Date: Sat, 29 Aug 2026 17:46:04 +0800 Subject: [PATCH 05/10] Add the Volo.Abp.Http.FluentValidation package It reflects the statically evaluable FluentValidation rules of a DTO into the api definition, so the client proxy generators see the same constraints the attributes already provide. A rule is only published when it applies to every instance of the type, which leaves out the conditional rules, the rules of a non-default rule set and the rules of a RuleForEach. Volo.Abp.FluentValidation keeps its own dependencies, it does not depend on Volo.Abp.Http. --- .../fundamentals/fluent-validation.md | 65 ++++ framework/Volo.Abp.slnx | 2 + .../Properties/AssemblyInfo.cs | 3 + .../Volo.Abp.Http.FluentValidation.abppkg | 3 + ....Http.FluentValidation.abppkg.analyze.json | 68 ++++ .../Volo.Abp.Http.FluentValidation.csproj | 24 ++ .../AbpHttpFluentValidationModule.cs | 12 + ...nPropertyApiDescriptionModelContributor.cs | 254 +++++++++++++++ .../Volo.Abp.AspNetCore.Mvc.Tests.csproj | 2 +- .../Mvc/AbpAspNetCoreMvcTestModule.cs | 4 +- .../AbpApiDefinitionController_Tests.cs | 15 + ...olo.Abp.Http.FluentValidation.Tests.abppkg | 3 + ...olo.Abp.Http.FluentValidation.Tests.csproj | 17 + .../AbpHttpFluentValidationTestBase.cs | 36 +++ .../AbpHttpFluentValidationTestModule.cs | 12 + .../FluentValidationApiDescription_Tests.cs | 302 ++++++++++++++++++ ...tionApiDescription_WithoutAutofac_Tests.cs | 25 ++ .../TestObjects/ConditionalTestDto.cs | 41 +++ .../TestObjects/ConstraintTestDto.cs | 74 +++++ .../TestObjects/CultureTestDto.cs | 22 ++ .../TestObjects/DataAnnotationTestDto.cs | 38 +++ .../TestObjects/GenericTestDto.cs | 18 ++ .../TestObjects/InheritanceTestDto.cs | 22 ++ .../TestObjects/PropertyNameTestDto.cs | 26 ++ .../TestObjects/RuleSetTestDto.cs | 26 ++ .../TestObjects/UnvalidatedTestDto.cs | 6 + .../proxy/volo/abp/http/modeling/models.ts | 2 + nupkg/common.ps1 | 1 + 28 files changed, 1120 insertions(+), 3 deletions(-) create mode 100644 framework/src/Volo.Abp.Http.FluentValidation/Properties/AssemblyInfo.cs create mode 100644 framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg create mode 100644 framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg.analyze.json create mode 100644 framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.csproj create mode 100644 framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationModule.cs create mode 100644 framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.abppkg create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestModule.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_WithoutAutofac_Tests.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConditionalTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/GenericTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/InheritanceTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/PropertyNameTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/RuleSetTestDto.cs create mode 100644 framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/UnvalidatedTestDto.cs diff --git a/docs/en/framework/fundamentals/fluent-validation.md b/docs/en/framework/fundamentals/fluent-validation.md index 8270e65b724..c1dfd41a101 100644 --- a/docs/en/framework/fundamentals/fluent-validation.md +++ b/docs/en/framework/fundamentals/fluent-validation.md @@ -60,6 +60,71 @@ public class CreateUpdateBookDtoValidator : AbstractValidator` form that reports the same zero on the descriptor, so the two can not be told apart. +* Rules that come from an `Include(...)` call are not published, because FluentValidation does not expose the included validator on its descriptor. +* A validator of a derived DTO can not add rules to a property declared by its base class, because each type describes only its own properties. +* A rule on a nested object, like `RuleFor(x => x.Address.City)`, is not published either. The nested type is described on its own, with its own validator, and its model is shared by every DTO that uses it. +* A validator of a closed generic DTO is not used, because the API definition describes the generic type definition, which is shared by all of its instantiations. +* `Matches(pattern, RegexOptions)` publishes the pattern without the options. This is the one case where a client can be stricter than the server, so avoid the overload if the client should not reject what the server accepts. + +> The API definition describes a type, while the server runs the validation per action. So, a DTO that is only used as a return value, or that is sent to an action which doesn't validate its parameters, still declares its constraints here. This is also how the data annotation attributes have always been reported. + ## See Also * [Validation System](./validation.md) \ No newline at end of file diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 6b9ad8af615..cc0a1dcf8ed 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -121,6 +121,7 @@ + @@ -229,6 +230,7 @@ + diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Properties/AssemblyInfo.cs b/framework/src/Volo.Abp.Http.FluentValidation/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..1c556d5b2ee --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Volo.Abp.Http.FluentValidation.Tests")] diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg b/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg new file mode 100644 index 00000000000..f4bad072d26 --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg @@ -0,0 +1,3 @@ +{ + "role": "lib.framework" +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg.analyze.json b/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg.analyze.json new file mode 100644 index 00000000000..cdcde838c35 --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.abppkg.analyze.json @@ -0,0 +1,68 @@ +{ + "name": "Volo.Abp.Http.FluentValidation", + "hash": "", + "contents": [ + { + "namespace": "Volo.Abp.Http.FluentValidation", + "dependsOnModules": [ + { + "declaringAssemblyName": "Volo.Abp.Http", + "namespace": "Volo.Abp.Http", + "name": "AbpHttpModule" + }, + { + "declaringAssemblyName": "Volo.Abp.FluentValidation", + "namespace": "Volo.Abp.FluentValidation", + "name": "AbpFluentValidationModule" + } + ], + "implementingInterfaces": [ + { + "name": "IAbpModule", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IAbpModule" + }, + { + "name": "IOnPreApplicationInitialization", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IOnPreApplicationInitialization" + }, + { + "name": "IOnApplicationInitialization", + "namespace": "Volo.Abp", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.IOnApplicationInitialization" + }, + { + "name": "IOnPostApplicationInitialization", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IOnPostApplicationInitialization" + }, + { + "name": "IOnApplicationShutdown", + "namespace": "Volo.Abp", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.IOnApplicationShutdown" + }, + { + "name": "IPreConfigureServices", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IPreConfigureServices" + }, + { + "name": "IPostConfigureServices", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IPostConfigureServices" + } + ], + "contentType": "abpModule", + "name": "AbpHttpFluentValidationModule", + "summary": null + } + ] +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.csproj b/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.csproj new file mode 100644 index 00000000000..5c44a554201 --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.csproj @@ -0,0 +1,24 @@ + + + + + + + net8.0;net9.0;net10.0 + enable + Nullable + Volo.Abp.Http.FluentValidation + Volo.Abp.Http.FluentValidation + $(AssetTargetFallback);portable-net45+win8+wp8+wpa81; + false + false + false + + + + + + + + + diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationModule.cs b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationModule.cs new file mode 100644 index 00000000000..b81f0406200 --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationModule.cs @@ -0,0 +1,12 @@ +using Volo.Abp.FluentValidation; +using Volo.Abp.Modularity; + +namespace Volo.Abp.Http.FluentValidation; + +[DependsOn( + typeof(AbpHttpModule), + typeof(AbpFluentValidationModule) + )] +public class AbpHttpFluentValidationModule : AbpModule +{ +} diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs new file mode 100644 index 00000000000..d611af03b9d --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using FluentValidation; +using FluentValidation.Internal; +using FluentValidation.Validators; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Http.Modeling; + +namespace Volo.Abp.Http.FluentValidation; + +[ExposeServices(typeof(IPropertyApiDescriptionModelContributor))] +public class FluentValidationPropertyApiDescriptionModelContributor : IPropertyApiDescriptionModelContributor, ITransientDependency +{ + protected IServiceProvider ServiceProvider { get; } + + protected ConcurrentDictionary?> RuleCache { get; } + + public FluentValidationPropertyApiDescriptionModelContributor(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + RuleCache = new ConcurrentDictionary?>(); + } + + public virtual Task ContributeAsync(PropertyApiDescriptionModelContributionContext context) + { + Check.NotNull(context, nameof(context)); + + var rules = RuleCache.GetOrAdd(context.DeclaringType, GetUnconditionalRules); + if (rules == null) + { + return Task.CompletedTask; + } + + foreach (var validator in rules[context.Model.Name]) + { + ApplyValidator(context.Model, validator); + } + + return Task.CompletedTask; + } + + protected virtual ILookup? GetUnconditionalRules(Type declaringType) + { + if (declaringType.ContainsGenericParameters) + { + return null; + } + + var validator = ServiceProvider.GetService(typeof(IValidator<>).MakeGenericType(declaringType)) as IValidator; + if (validator == null) + { + return null; + } + + return validator + .CreateDescriptor() + .Rules + .Where(rule => IsUnconditional(rule) && DescribesPropertyOf(rule, declaringType)) + .SelectMany(rule => rule + .Components + .Where(component => !component.HasCondition && !component.HasAsyncCondition) + .Select(component => new KeyValuePair(rule.Member.Name, component.Validator))) + .ToLookup(x => x.Key, x => x.Value, StringComparer.Ordinal); + } + + protected virtual bool DescribesPropertyOf(IValidationRule rule, Type declaringType) + { + // The member is matched instead of the rule's property name, which OverridePropertyName + // and a custom PropertyNameResolver can change. A rule on a nested object declares its + // member on that object, so it must not be attributed to a property of this type. + return rule.Member != null && rule.Member.DeclaringType!.IsAssignableFrom(declaringType); + } + + protected virtual bool IsUnconditional(IValidationRule rule) + { + + // RuleForEach constrains the items, not the collection property itself. + if (IsCollectionRule(rule)) + { + return false; + } + + // A When(...)/Unless(...) block sets the condition on the rule, the chained form sets it on the components. + if (rule.HasCondition || rule.HasAsyncCondition) + { + return false; + } + + // Mirrors DefaultValidatorSelector: ABP never runs a rule that only belongs to a named rule set. + return rule.RuleSets.IsNullOrEmpty() || + rule.RuleSets!.Contains(RulesetValidatorSelector.DefaultRuleSetName, StringComparer.OrdinalIgnoreCase); + } + + protected virtual bool IsCollectionRule(IValidationRule rule) + { + return rule + .GetType() + .GetInterfaces() + .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ICollectionRule<,>)); + } + + protected virtual void ApplyValidator(PropertyApiDescriptionModel model, IPropertyValidator validator) + { + switch (validator) + { + case INotNullValidator: + case INotEmptyValidator: + model.IsRequired = true; + break; + case ILengthValidator lengthValidator: + ApplyLength(model, lengthValidator); + break; + case IRegularExpressionValidator regularExpressionValidator: + ApplyRegularExpression(model, regularExpressionValidator); + break; + case IBetweenValidator betweenValidator: + ApplyBetween(model, betweenValidator); + break; + case IComparisonValidator comparisonValidator: + ApplyComparison(model, comparisonValidator); + break; + } + } + + protected virtual void ApplyLength(PropertyApiDescriptionModel model, ILengthValidator validator) + { + // Every length validator has a Func form that leaves the bound at zero on the + // descriptor, so a zero is not distinguishable from a literal one and is left out. + if (validator.Min > 0) + { + model.MinLength = model.MinLength.HasValue + ? Math.Max(model.MinLength.Value, validator.Min) + : validator.Min; + } + + if (validator.Max > 0) + { + model.MaxLength = model.MaxLength.HasValue + ? Math.Min(model.MaxLength.Value, validator.Max) + : validator.Max; + } + } + + protected virtual void ApplyRegularExpression(PropertyApiDescriptionModel model, IRegularExpressionValidator validator) + { + // A single Regex field cannot express "must match all of them", so the first pattern wins. + if (!model.Regex.IsNullOrWhiteSpace() || validator.Expression.IsNullOrWhiteSpace()) + { + return; + } + + model.Regex = validator.Expression; + } + + protected virtual void ApplyComparison(PropertyApiDescriptionModel model, IComparisonValidator validator) + { + switch (validator.Comparison) + { + case Comparison.GreaterThan: + ApplyMinimum(model, validator.ValueToCompare, isExclusive: true); + break; + case Comparison.GreaterThanOrEqual: + ApplyMinimum(model, validator.ValueToCompare, isExclusive: false); + break; + case Comparison.LessThan: + ApplyMaximum(model, validator.ValueToCompare, isExclusive: true); + break; + case Comparison.LessThanOrEqual: + ApplyMaximum(model, validator.ValueToCompare, isExclusive: false); + break; + } + } + + protected virtual void ApplyBetween(PropertyApiDescriptionModel model, IBetweenValidator validator) + { + var isExclusive = validator is not IInclusiveBetweenValidator; + + ApplyMinimum(model, validator.From, isExclusive); + ApplyMaximum(model, validator.To, isExclusive); + } + + protected virtual void ApplyMinimum(PropertyApiDescriptionModel model, object? value, bool isExclusive) + { + // Minimum and Maximum are ordered bounds, so a value that is not a number, such as a + // DateTime, has nothing meaningful to publish there. + if (!TryGetNumber(value, out var minimum)) + { + return; + } + + if (model.Minimum != null) + { + if (!TryParseNumber(model.Minimum, out var existingMinimum)) + { + return; + } + + // The flag follows the winning bound instead of being combined, otherwise ">= 10" merged with "> 5" would become "> 10". + var existingIsExclusive = model.MinimumIsExclusive == true; + if (existingMinimum > minimum || (existingMinimum == minimum && existingIsExclusive)) + { + minimum = existingMinimum; + isExclusive = existingIsExclusive; + } + } + + model.Minimum = minimum.ToString(CultureInfo.InvariantCulture); + model.MinimumIsExclusive = isExclusive; + } + + protected virtual void ApplyMaximum(PropertyApiDescriptionModel model, object? value, bool isExclusive) + { + if (!TryGetNumber(value, out var maximum)) + { + return; + } + + if (model.Maximum != null) + { + if (!TryParseNumber(model.Maximum, out var existingMaximum)) + { + return; + } + + var existingIsExclusive = model.MaximumIsExclusive == true; + if (existingMaximum < maximum || (existingMaximum == maximum && existingIsExclusive)) + { + maximum = existingMaximum; + isExclusive = existingIsExclusive; + } + } + + model.Maximum = maximum.ToString(CultureInfo.InvariantCulture); + model.MaximumIsExclusive = isExclusive; + } + + protected virtual bool TryGetNumber(object? value, out decimal number) + { + // A comparison against another property has no value to read. + var bound = value != null ? Convert.ToString(value, CultureInfo.InvariantCulture) : null; + return TryParseNumber(bound, out number); + } + + protected virtual bool TryParseNumber(string? value, out decimal number) + { + // Float allows the exponent notation but not the group separators, which a Range bound + // rendered by a decimal-comma culture would otherwise smuggle in as "1,5" meaning 15. + return decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj index 496dba12edf..7eb833e09a6 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj @@ -27,7 +27,7 @@ - + diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs index 4339104b301..a8707a8c38f 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs @@ -24,7 +24,7 @@ using Volo.Abp.TestApp.Application; using Volo.Abp.Threading; using Volo.Abp.Validation.Localization; -using Volo.Abp.FluentValidation; +using Volo.Abp.Http.FluentValidation; using Volo.Abp.VirtualFileSystem; namespace Volo.Abp.AspNetCore.Mvc; @@ -34,7 +34,7 @@ namespace Volo.Abp.AspNetCore.Mvc; typeof(AbpMemoryDbTestModule), typeof(AbpAspNetCoreMvcModule), typeof(AbpAutofacModule), - typeof(AbpFluentValidationModule) + typeof(AbpHttpFluentValidationModule) )] public class AbpAspNetCoreMvcTestModule : AbpModule { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Tests.cs index 5404555ffb7..3ed8f1c7ad6 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ApiExploring/AbpApiDefinitionController_Tests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Shouldly; using Volo.Abp.Http.Modeling; +using Volo.Abp.TestApp.Application; using Xunit; namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; @@ -75,6 +76,20 @@ public async Task Should_Contain_Policy_And_Roles_In_AuthorizeDatas() action.AuthorizeDatas.ShouldContain(a => a.Policy == "TestPolicy2" && a.Roles == "Manager"); } + [Fact] + public async Task Should_Include_FluentValidation_Rules_In_Types() + { + var model = await GetResponseAsObjectAsync("/api/abp/api-definition?includeTypes=true"); + + var property = model.Types[typeof(FluentValidationTestInput).FullName!] + .Properties! + .Single(p => p.Name == nameof(FluentValidationTestInput.Name)); + + property.IsRequired.ShouldBeTrue(); + property.MinLength.ShouldBe(3); + property.MaxLength.ShouldBe(10); + } + private static ControllerApiDescriptionModel GetPeopleController(ApplicationApiDescriptionModel model) { return model.Modules.Values diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.abppkg b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.abppkg new file mode 100644 index 00000000000..a686451fbc0 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.abppkg @@ -0,0 +1,3 @@ +{ + "role": "lib.test" +} \ No newline at end of file diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj new file mode 100644 index 00000000000..dd3639df09c --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + net10.0 + + + + + + + + + + + diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs new file mode 100644 index 00000000000..76a2e1306c5 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Modularity; +using Volo.Abp.Testing; + +namespace Volo.Abp.Http.FluentValidation; + +public abstract class AbpHttpFluentValidationTestBase : AbpIntegratedTest + where TStartupModule : IAbpModule +{ + protected virtual async Task GetPropertyAsync(string propertyName) + { + var typeModel = await CreateTypeModelAsync(typeof(TDto)); + return typeModel.Properties!.Single(x => x.Name == propertyName); + } + + protected virtual async Task CreateTypeModelAsync(Type type) + { + var typeModel = TypeApiDescriptionModel.Create(type); + var contributors = ServiceProvider.GetServices().ToArray(); + + foreach (var propertyModel in typeModel.Properties!) + { + var context = new PropertyApiDescriptionModelContributionContext(propertyModel, type); + foreach (var contributor in contributors) + { + await contributor.ContributeAsync(context); + } + } + + return typeModel; + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestModule.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestModule.cs new file mode 100644 index 00000000000..93f54d5afae --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestModule.cs @@ -0,0 +1,12 @@ +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.Http.FluentValidation; + +[DependsOn( + typeof(AbpAutofacModule), + typeof(AbpHttpFluentValidationModule) + )] +public class AbpHttpFluentValidationTestModule : AbpModule +{ +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs new file mode 100644 index 00000000000..b06f556d928 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs @@ -0,0 +1,302 @@ +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Autofac; +using Volo.Abp.Http.FluentValidation.TestObjects; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.Http.FluentValidation; + +public class FluentValidationApiDescription_Tests : AbpHttpFluentValidationTestBase +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public void Should_Register_The_Contributor() + { + ServiceProvider + .GetServices() + .ShouldContain(x => x is FluentValidationPropertyApiDescriptionModelContributor); + } + + [Fact] + public async Task Should_Map_Required_Rules() + { + (await GetPropertyAsync(nameof(ConstraintTestDto.NotEmptyValue))).IsRequired.ShouldBeTrue(); + (await GetPropertyAsync(nameof(ConstraintTestDto.NotNullValue))).IsRequired.ShouldBeTrue(); + (await GetPropertyAsync(nameof(ConstraintTestDto.UnconstrainedValue))).IsRequired.ShouldBeFalse(); + } + + [Fact] + public async Task Should_Map_Length_Rules() + { + var length = await GetPropertyAsync(nameof(ConstraintTestDto.LengthValue)); + length.MinLength.ShouldBe(3); + length.MaxLength.ShouldBe(10); + + var minimumLength = await GetPropertyAsync(nameof(ConstraintTestDto.MinimumLengthValue)); + minimumLength.MinLength.ShouldBe(4); + minimumLength.MaxLength.ShouldBeNull(); + + var maximumLength = await GetPropertyAsync(nameof(ConstraintTestDto.MaximumLengthValue)); + maximumLength.MinLength.ShouldBeNull(); + maximumLength.MaxLength.ShouldBe(12); + } + + [Fact] + public async Task Should_Not_Map_Zero_Length_Rules() + { + // A Func bound is reported as a zero too, so a zero can not be published safely. + (await GetPropertyAsync(nameof(ConstraintTestDto.EmptyOnlyValue))).MaxLength.ShouldBeNull(); + (await GetPropertyAsync(nameof(ConstraintTestDto.ZeroLengthValue))).MaxLength.ShouldBeNull(); + + var dynamicLength = await GetPropertyAsync(nameof(ConstraintTestDto.DynamicLengthValue)); + dynamicLength.MinLength.ShouldBeNull(); + dynamicLength.MaxLength.ShouldBeNull(); + } + + [Fact] + public async Task Should_Not_Map_A_Comparison_Bound_That_Is_Not_A_Number() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.DateComparisonValue)); + + property.Minimum.ShouldBeNull(); + property.MinimumIsExclusive.ShouldBeNull(); + } + + [Fact] + public async Task Should_Map_A_Bound_Written_In_The_Exponent_Notation() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.SmallExponentValue)); + + property.Minimum.ShouldBe("0.00000000000000000001"); + } + + [Fact] + public async Task Should_Map_Regular_Expression_Rules() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.RegexValue)); + + property.Regex.ShouldBe("^[a-z]+$"); + } + + [Fact] + public async Task Should_Map_Inclusive_Comparison_Rules() + { + var minimum = await GetPropertyAsync(nameof(ConstraintTestDto.InclusiveMinimumValue)); + minimum.Minimum.ShouldBe("5"); + minimum.MinimumIsExclusive.ShouldBe(false); + + var maximum = await GetPropertyAsync(nameof(ConstraintTestDto.InclusiveMaximumValue)); + maximum.Maximum.ShouldBe("50"); + maximum.MaximumIsExclusive.ShouldBe(false); + + var between = await GetPropertyAsync(nameof(ConstraintTestDto.InclusiveBetweenValue)); + between.Minimum.ShouldBe("1"); + between.Maximum.ShouldBe("10"); + between.MinimumIsExclusive.ShouldBe(false); + between.MaximumIsExclusive.ShouldBe(false); + } + + [Fact] + public async Task Should_Map_Exclusive_Comparison_Rules() + { + var minimum = await GetPropertyAsync(nameof(ConstraintTestDto.ExclusiveMinimumValue)); + minimum.Minimum.ShouldBe("5"); + minimum.MinimumIsExclusive.ShouldBe(true); + + var maximum = await GetPropertyAsync(nameof(ConstraintTestDto.ExclusiveMaximumValue)); + maximum.Maximum.ShouldBe("50"); + maximum.MaximumIsExclusive.ShouldBe(true); + + var between = await GetPropertyAsync(nameof(ConstraintTestDto.ExclusiveBetweenValue)); + between.Minimum.ShouldBe("1"); + between.Maximum.ShouldBe("10"); + between.MinimumIsExclusive.ShouldBe(true); + between.MaximumIsExclusive.ShouldBe(true); + } + + [Fact] + public async Task Should_Not_Map_Comparison_Against_Another_Property() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.ComparedToOtherPropertyValue)); + + property.Minimum.ShouldBeNull(); + property.Maximum.ShouldBeNull(); + } + + [Fact] + public async Task Should_Not_Map_RuleForEach_Rules_To_The_Collection_Property() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.CollectionValue)); + + property.IsRequired.ShouldBeFalse(); + } + + [Fact] + public async Task Should_Not_Map_Conditional_Rules() + { + (await GetPropertyAsync(nameof(ConditionalTestDto.ChainedConditionValue))).IsRequired.ShouldBeFalse(); + (await GetPropertyAsync(nameof(ConditionalTestDto.UnlessConditionValue))).IsRequired.ShouldBeFalse(); + (await GetPropertyAsync(nameof(ConditionalTestDto.AsyncConditionValue))).IsRequired.ShouldBeFalse(); + (await GetPropertyAsync(nameof(ConditionalTestDto.UnconditionalValue))).IsRequired.ShouldBeTrue(); + + var blockCondition = await GetPropertyAsync(nameof(ConditionalTestDto.BlockConditionValue)); + blockCondition.IsRequired.ShouldBeFalse(); + blockCondition.MaxLength.ShouldBeNull(); + } + + [Fact] + public async Task Should_Not_Map_Rules_Of_Named_Rule_Sets() + { + var namedRuleSet = await GetPropertyAsync(nameof(RuleSetTestDto.NamedRuleSetValue)); + namedRuleSet.IsRequired.ShouldBeFalse(); + namedRuleSet.MinLength.ShouldBeNull(); + + var defaultRuleSet = await GetPropertyAsync(nameof(RuleSetTestDto.DefaultRuleSetValue)); + defaultRuleSet.IsRequired.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Keep_The_Stricter_Bound_When_Merging_With_Data_Annotations() + { + var length = await GetPropertyAsync(nameof(DataAnnotationTestDto.MergedLengthValue)); + length.MinLength.ShouldBe(5); + length.MaxLength.ShouldBe(10); + + var range = await GetPropertyAsync(nameof(DataAnnotationTestDto.MergedRangeValue)); + range.Minimum.ShouldBe("10"); + range.Maximum.ShouldBe("90"); + range.MinimumIsExclusive.ShouldBe(false); + range.MaximumIsExclusive.ShouldBe(false); + } + + [Fact] + public async Task Should_Read_Exclusive_Bounds_From_The_Range_Attribute() + { + var property = await GetPropertyAsync(nameof(DataAnnotationTestDto.ExclusiveAttributeValue)); + + property.Minimum.ShouldBe("1"); + property.Maximum.ShouldBe("100"); + property.MinimumIsExclusive.ShouldBe(true); + property.MaximumIsExclusive.ShouldBe(true); + } + + [Fact] + public async Task Should_Keep_The_Exclusivity_Of_The_Winning_Bound() + { + // The attribute bounds are stricter, so the exclusive flags must not leak onto them. + var looser = await GetPropertyAsync(nameof(DataAnnotationTestDto.LooserFluentBoundValue)); + looser.Minimum.ShouldBe("0"); + looser.Maximum.ShouldBe("100"); + looser.MinimumIsExclusive.ShouldBe(false); + looser.MaximumIsExclusive.ShouldBe(false); + + // On an equal bound the exclusive rule is the stricter one and wins. + var same = await GetPropertyAsync(nameof(DataAnnotationTestDto.SameBoundValue)); + same.Minimum.ShouldBe("10"); + same.Maximum.ShouldBe("90"); + same.MinimumIsExclusive.ShouldBe(true); + same.MaximumIsExclusive.ShouldBe(true); + } + + [Fact] + public async Task Should_Keep_The_Attribute_Regular_Expression() + { + var property = await GetPropertyAsync(nameof(DataAnnotationTestDto.AttributeRegexValue)); + + property.Regex.ShouldBe("^attribute$"); + } + + [Fact] + public async Task Should_Ignore_Types_Without_A_Validator() + { + var property = await GetPropertyAsync(nameof(UnvalidatedTestDto.Value)); + + property.IsRequired.ShouldBeFalse(); + property.MinLength.ShouldBeNull(); + property.MaxLength.ShouldBeNull(); + } + + [Fact] + public async Task Should_Ignore_Open_Generic_Types() + { + var typeModel = await CreateTypeModelAsync(typeof(GenericTestDto<>)); + var property = typeModel.Properties!.Single(x => x.Name == nameof(GenericTestDto.Name)); + + property.IsRequired.ShouldBeFalse(); + property.MinLength.ShouldBeNull(); + property.MaxLength.ShouldBeNull(); + property.Regex.ShouldBeNull(); + } + + [Fact] + public async Task Should_Merge_Range_Attribute_Bounds_Under_Any_Culture() + { + using (CultureHelper.Use(CultureInfo.GetCultureInfo("de-DE"))) + { + var property = await GetPropertyAsync(nameof(CultureTestDto.DecimalRangeValue)); + + property.Minimum.ShouldBe("2"); + property.Maximum.ShouldBe("9.5"); + } + } + + [Fact] + public async Task Should_Map_Rules_With_An_Overridden_Property_Name() + { + var property = await GetPropertyAsync(nameof(PropertyNameTestDto.RenamedValue)); + + property.IsRequired.ShouldBeTrue(); + property.MaxLength.ShouldBe(20); + } + + [Fact] + public async Task Should_Not_Map_Rules_Of_A_Nested_Object_To_A_Property_Of_The_Same_Name() + { + var property = await GetPropertyAsync(nameof(PropertyNameTestDto.City)); + + property.IsRequired.ShouldBeFalse(); + property.MaxLength.ShouldBeNull(); + } + + [Fact] + public async Task Should_Not_Map_Rules_Of_A_Nested_Object_To_The_Nested_Type() + { + // The model of the nested type is shared by every DTO that uses it, so a rule declared + // by the validator of one of them can not be attributed to it. + var property = await GetPropertyAsync(nameof(PropertyNameTestAddress.City)); + + property.IsRequired.ShouldBeFalse(); + property.MaxLength.ShouldBeNull(); + } + + [Fact] + public async Task Should_Not_Map_Rules_Of_A_Derived_Validator_To_A_Base_Property() + { + // Each type describes only the properties it declares, and the base type is described + // with its own validator, which does not exist here. + var inherited = await GetPropertyAsync(nameof(InheritanceTestBaseDto.InheritedValue)); + inherited.IsRequired.ShouldBeFalse(); + inherited.MaxLength.ShouldBeNull(); + + var own = await GetPropertyAsync(nameof(InheritanceTestDto.OwnValue)); + own.IsRequired.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Keep_A_Bound_That_Is_Not_A_Number() + { + var property = await GetPropertyAsync(nameof(CultureTestDto.DateRangeValue)); + + property.Minimum.ShouldBe("2020-01-01"); + property.Maximum.ShouldBe("2030-01-01"); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_WithoutAutofac_Tests.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_WithoutAutofac_Tests.cs new file mode 100644 index 00000000000..f9fc111facf --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_WithoutAutofac_Tests.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Http.FluentValidation.TestObjects; +using Volo.Abp.Modularity; +using Xunit; + +namespace Volo.Abp.Http.FluentValidation; + +public class FluentValidationApiDescription_WithoutAutofac_Tests + : AbpHttpFluentValidationTestBase +{ + [DependsOn(typeof(AbpHttpFluentValidationModule))] + public class TestModule : AbpModule + { + } + + [Fact] + public async Task Should_Contribute_Without_Autofac() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.LengthValue)); + + property.MinLength.ShouldBe(3); + property.MaxLength.ShouldBe(10); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConditionalTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConditionalTestDto.cs new file mode 100644 index 00000000000..59a27e0c84c --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConditionalTestDto.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class ConditionalTestDto +{ + public bool Enabled { get; set; } + + public string? ChainedConditionValue { get; set; } + + public string? BlockConditionValue { get; set; } + + public string? UnlessConditionValue { get; set; } + + public string? AsyncConditionValue { get; set; } + + public string? UnconditionalValue { get; set; } +} + +public class ConditionalTestDtoValidator : AbstractValidator +{ + public ConditionalTestDtoValidator() + { + RuleFor(x => x.ChainedConditionValue).NotEmpty().When(x => x.Enabled); + + When(x => x.Enabled, () => + { + RuleFor(x => x.BlockConditionValue).NotEmpty().MaximumLength(32); + }); + + Unless(x => x.Enabled, () => + { + RuleFor(x => x.UnlessConditionValue).NotEmpty(); + }); + + RuleFor(x => x.AsyncConditionValue).NotEmpty().WhenAsync((x, _) => Task.FromResult(x.Enabled)); + + RuleFor(x => x.UnconditionalValue).NotEmpty(); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs new file mode 100644 index 00000000000..1df8bc7b6a7 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class ConstraintTestDto +{ + public string? NotEmptyValue { get; set; } + + public string? NotNullValue { get; set; } + + public string? LengthValue { get; set; } + + public string? MinimumLengthValue { get; set; } + + public string? MaximumLengthValue { get; set; } + + public string? RegexValue { get; set; } + + public int InclusiveMinimumValue { get; set; } + + public int InclusiveMaximumValue { get; set; } + + public int ExclusiveMinimumValue { get; set; } + + public int ExclusiveMaximumValue { get; set; } + + public int InclusiveBetweenValue { get; set; } + + public int ExclusiveBetweenValue { get; set; } + + public int ComparedToOtherPropertyValue { get; set; } + + public List CollectionValue { get; set; } = new List(); + + public string? EmptyOnlyValue { get; set; } + + public string? ZeroLengthValue { get; set; } + + public string? DynamicLengthValue { get; set; } + + public DateTime DateComparisonValue { get; set; } + + public double SmallExponentValue { get; set; } + + public string? UnconstrainedValue { get; set; } +} + +public class ConstraintTestDtoValidator : AbstractValidator +{ + public ConstraintTestDtoValidator() + { + RuleFor(x => x.NotEmptyValue).NotEmpty(); + RuleFor(x => x.NotNullValue).NotNull(); + RuleFor(x => x.LengthValue).Length(3, 10); + RuleFor(x => x.MinimumLengthValue).MinimumLength(4); + RuleFor(x => x.MaximumLengthValue).MaximumLength(12); + RuleFor(x => x.RegexValue).Matches("^[a-z]+$"); + RuleFor(x => x.InclusiveMinimumValue).GreaterThanOrEqualTo(5); + RuleFor(x => x.InclusiveMaximumValue).LessThanOrEqualTo(50); + RuleFor(x => x.ExclusiveMinimumValue).GreaterThan(5); + RuleFor(x => x.ExclusiveMaximumValue).LessThan(50); + RuleFor(x => x.InclusiveBetweenValue).InclusiveBetween(1, 10); + RuleFor(x => x.ExclusiveBetweenValue).ExclusiveBetween(1, 10); + RuleFor(x => x.ComparedToOtherPropertyValue).GreaterThanOrEqualTo(x => x.InclusiveMinimumValue); + RuleForEach(x => x.CollectionValue).NotEmpty(); + RuleFor(x => x.EmptyOnlyValue).MaximumLength(0); + RuleFor(x => x.ZeroLengthValue).Length(0, 0); + RuleFor(x => x.DateComparisonValue).GreaterThanOrEqualTo(new DateTime(2020, 1, 1)); + RuleFor(x => x.DynamicLengthValue).Length(_ => 2, _ => 8); + RuleFor(x => x.SmallExponentValue).GreaterThanOrEqualTo(1e-20); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs new file mode 100644 index 00000000000..88d38382b7d --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class CultureTestDto +{ + [Range(1.5, 9.5)] + public double DecimalRangeValue { get; set; } + + [Range(typeof(DateTime), "2020-01-01", "2030-01-01")] + public DateTime DateRangeValue { get; set; } +} + +public class CultureTestDtoValidator : AbstractValidator +{ + public CultureTestDtoValidator() + { + RuleFor(x => x.DecimalRangeValue).GreaterThanOrEqualTo(2.0); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs new file mode 100644 index 00000000000..fa09d4515c0 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class DataAnnotationTestDto +{ + [MinLength(2)] + [MaxLength(50)] + public string? MergedLengthValue { get; set; } + + [RegularExpression("^attribute$")] + public string? AttributeRegexValue { get; set; } + + [Range(0, 100)] + public int MergedRangeValue { get; set; } + + [Range(0, 100)] + public int LooserFluentBoundValue { get; set; } + + [Range(10, 90)] + public int SameBoundValue { get; set; } + + [Range(1, 100, MinimumIsExclusive = true, MaximumIsExclusive = true)] + public int ExclusiveAttributeValue { get; set; } +} + +public class DataAnnotationTestDtoValidator : AbstractValidator +{ + public DataAnnotationTestDtoValidator() + { + RuleFor(x => x.MergedLengthValue).Length(5, 10); + RuleFor(x => x.AttributeRegexValue).Matches("^fluent$"); + RuleFor(x => x.MergedRangeValue).GreaterThanOrEqualTo(10).LessThanOrEqualTo(90); + RuleFor(x => x.LooserFluentBoundValue).GreaterThan(-5).LessThan(500); + RuleFor(x => x.SameBoundValue).GreaterThan(10).LessThan(90); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/GenericTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/GenericTestDto.cs new file mode 100644 index 00000000000..64f5145376a --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/GenericTestDto.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class GenericTestDto +{ + public T? Value { get; set; } + + public string? Name { get; set; } +} + +public class GenericTestDtoValidator : AbstractValidator> +{ + public GenericTestDtoValidator() + { + RuleFor(x => x.Name).NotEmpty().Length(3, 9).Matches("^[a-z]+$"); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/InheritanceTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/InheritanceTestDto.cs new file mode 100644 index 00000000000..13f78223d5f --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/InheritanceTestDto.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class InheritanceTestBaseDto +{ + public string? InheritedValue { get; set; } +} + +public class InheritanceTestDto : InheritanceTestBaseDto +{ + public string? OwnValue { get; set; } +} + +public class InheritanceTestDtoValidator : AbstractValidator +{ + public InheritanceTestDtoValidator() + { + RuleFor(x => x.InheritedValue).NotEmpty().MaximumLength(15); + RuleFor(x => x.OwnValue).NotEmpty(); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/PropertyNameTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/PropertyNameTestDto.cs new file mode 100644 index 00000000000..390d186e003 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/PropertyNameTestDto.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class PropertyNameTestDto +{ + public string? RenamedValue { get; set; } + + public string? City { get; set; } + + public PropertyNameTestAddress Address { get; set; } = new PropertyNameTestAddress(); +} + +public class PropertyNameTestAddress +{ + public string? City { get; set; } +} + +public class PropertyNameTestDtoValidator : AbstractValidator +{ + public PropertyNameTestDtoValidator() + { + RuleFor(x => x.RenamedValue).NotEmpty().MaximumLength(20).OverridePropertyName("displayed_name"); + RuleFor(x => x.Address.City).NotEmpty().MaximumLength(30); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/RuleSetTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/RuleSetTestDto.cs new file mode 100644 index 00000000000..e5867996a67 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/RuleSetTestDto.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class RuleSetTestDto +{ + public string? NamedRuleSetValue { get; set; } + + public string? DefaultRuleSetValue { get; set; } +} + +public class RuleSetTestDtoValidator : AbstractValidator +{ + public RuleSetTestDtoValidator() + { + RuleSet("Create", () => + { + RuleFor(x => x.NamedRuleSetValue).NotEmpty().MinimumLength(4); + }); + + RuleSet("default", () => + { + RuleFor(x => x.DefaultRuleSetValue).NotEmpty(); + }); + } +} diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/UnvalidatedTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/UnvalidatedTestDto.cs new file mode 100644 index 00000000000..c82257ecbdd --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/UnvalidatedTestDto.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.Http.FluentValidation.TestObjects; + +public class UnvalidatedTestDto +{ + public string? Value { get; set; } +} diff --git a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/http/modeling/models.ts b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/http/modeling/models.ts index 670666a1c92..b91d15ee4e5 100644 --- a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/http/modeling/models.ts +++ b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/http/modeling/models.ts @@ -82,6 +82,8 @@ export interface PropertyApiDescriptionModel { maxLength?: number; minimum?: string; maximum?: string; + minimumIsExclusive?: boolean; + maximumIsExclusive?: boolean; regex?: string; } diff --git a/nupkg/common.ps1 b/nupkg/common.ps1 index f47d94f9d75..aaabdcb1d2c 100644 --- a/nupkg/common.ps1 +++ b/nupkg/common.ps1 @@ -221,6 +221,7 @@ $projects = ( "framework/src/Volo.Abp.Http.Client.IdentityModel.WebAssembly", "framework/src/Volo.Abp.Http.Client.IdentityModel.MauiBlazor", "framework/src/Volo.Abp.Http", + "framework/src/Volo.Abp.Http.FluentValidation", "framework/src/Volo.Abp.IdentityModel", "framework/src/Volo.Abp.Imaging.Abstractions", "framework/src/Volo.Abp.Imaging.AspNetCore", From 0ed8624e7aae856067c3ed3ef6161472ab72f1c0 Mon Sep 17 00:00:00 2001 From: maliming Date: Sun, 30 Aug 2026 21:29:18 +0800 Subject: [PATCH 06/10] Write the typed range limits with the invariant culture Range(Type, string, string) keeps its limits as strings until the first validation, so a numeric limit was reported in the culture that declared it and could not be merged with a FluentValidation bound. --- .../Modeling/PropertyApiDescriptionModel.cs | 32 +++++++++++++++++-- .../FluentValidationApiDescription_Tests.cs | 4 +++ .../TestObjects/CultureTestDto.cs | 4 +++ .../PropertyApiDescriptionModel_Tests.cs | 27 ++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs index 7f80468702c..8c98b952ca4 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs @@ -55,8 +55,8 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(propertyInfo.PropertyType), IsRequired = customAttributes.OfType().Any() || propertyInfo.GetCustomAttributesData().Any(attr => attr.AttributeType.Name == "RequiredMemberAttribute"), IsNullable = ReflectionHelper.IsNullable(propertyInfo), - Minimum = rangeAttribute != null ? Convert.ToString(rangeAttribute.Minimum, CultureInfo.InvariantCulture) : null, - Maximum = rangeAttribute != null ? Convert.ToString(rangeAttribute.Maximum, CultureInfo.InvariantCulture) : null, + Minimum = GetRangeBound(rangeAttribute, rangeAttribute?.Minimum), + Maximum = GetRangeBound(rangeAttribute, rangeAttribute?.Maximum), MinimumIsExclusive = GetMinimumIsExclusive(rangeAttribute), MaximumIsExclusive = GetMaximumIsExclusive(rangeAttribute), MinLength = customAttributes.OfType().FirstOrDefault()?.Length ?? customAttributes.OfType().FirstOrDefault()?.MinimumLength, @@ -65,6 +65,34 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) }; } + private static string? GetRangeBound(RangeAttribute? rangeAttribute, object? bound) + { + if (rangeAttribute == null || bound == null) + { + return null; + } + + // The Range(Type, string, string) constructor keeps its limits as strings until the + // first validation, so a numeric one is written in the culture of the declaring code. + if (bound is string text) + { + return decimal.TryParse(text, NumberStyles.Float, GetRangeLimitCulture(rangeAttribute), out var number) + ? number.ToString(CultureInfo.InvariantCulture) + : text; + } + + return Convert.ToString(bound, CultureInfo.InvariantCulture); + } + + private static CultureInfo GetRangeLimitCulture(RangeAttribute rangeAttribute) + { +#if NET8_0_OR_GREATER + return rangeAttribute.ParseLimitsInInvariantCulture ? CultureInfo.InvariantCulture : CultureInfo.CurrentCulture; +#else + return CultureInfo.CurrentCulture; +#endif + } + private static bool? GetMinimumIsExclusive(RangeAttribute? rangeAttribute) { if (rangeAttribute == null) diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs index b06f556d928..c2577624a29 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs @@ -246,6 +246,10 @@ public async Task Should_Merge_Range_Attribute_Bounds_Under_Any_Culture() property.Minimum.ShouldBe("2"); property.Maximum.ShouldBe("9.5"); + + var typed = await GetPropertyAsync(nameof(CultureTestDto.TypedDecimalRangeValue)); + typed.Minimum.ShouldBe("2"); + typed.Maximum.ShouldBe("9.5"); } } diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs index 88d38382b7d..104da7234aa 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs @@ -9,6 +9,9 @@ public class CultureTestDto [Range(1.5, 9.5)] public double DecimalRangeValue { get; set; } + [Range(typeof(decimal), "1,5", "9,5")] + public decimal TypedDecimalRangeValue { get; set; } + [Range(typeof(DateTime), "2020-01-01", "2030-01-01")] public DateTime DateRangeValue { get; set; } } @@ -18,5 +21,6 @@ public class CultureTestDtoValidator : AbstractValidator public CultureTestDtoValidator() { RuleFor(x => x.DecimalRangeValue).GreaterThanOrEqualTo(2.0); + RuleFor(x => x.TypedDecimalRangeValue).GreaterThanOrEqualTo(2m); } } diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs index 546782178f3..a59239de541 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs @@ -21,6 +21,27 @@ public void Create_Should_Write_Range_Bounds_With_The_Invariant_Culture() } } + [Fact] + public void Create_Should_Write_Typed_Range_Bounds_With_The_Invariant_Culture() + { + using (CultureHelper.Use(CultureInfo.GetCultureInfo("de-DE"))) + { + var model = CreateModel(nameof(TestClass.TypedDecimalRangeValue)); + + model.Minimum.ShouldBe("1.5"); + model.Maximum.ShouldBe("9.5"); + } + } + + [Fact] + public void Create_Should_Keep_A_Typed_Range_Bound_That_Is_Not_A_Number() + { + var model = CreateModel(nameof(TestClass.DateRangeValue)); + + model.Minimum.ShouldBe("2020-01-01"); + model.Maximum.ShouldBe("2030-01-01"); + } + [Fact] public void Create_Should_Read_The_Exclusive_Bounds_Of_The_Range_Attribute() { @@ -62,6 +83,12 @@ public class TestClass [Range(1.5, 9.5)] public double DecimalRangeValue { get; set; } + [Range(typeof(decimal), "1,5", "9,5")] + public decimal TypedDecimalRangeValue { get; set; } + + [Range(typeof(DateTime), "2020-01-01", "2030-01-01")] + public DateTime DateRangeValue { get; set; } + [Range(1, 100, MinimumIsExclusive = true, MaximumIsExclusive = true)] public int ExclusiveRangeValue { get; set; } From f92909480d6560c6ac8f9b955da5d732d9e69a28 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 31 Aug 2026 08:40:16 +0800 Subject: [PATCH 07/10] Keep a range limit that is already written in the invariant culture A magnitude below the decimal range, like 1e-30, parses to a zero, so a round trip through decimal turned it into a bound the server does not accept. --- ...idationPropertyApiDescriptionModelContributor.cs | 9 ++++++++- .../Http/Modeling/PropertyApiDescriptionModel.cs | 7 +++++++ .../FluentValidationApiDescription_Tests.cs | 9 +++++++++ .../TestObjects/ConstraintTestDto.cs | 3 +++ .../Modeling/PropertyApiDescriptionModel_Tests.cs | 13 +++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs index d611af03b9d..ad3465a0d08 100644 --- a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs @@ -249,6 +249,13 @@ protected virtual bool TryParseNumber(string? value, out decimal number) { // Float allows the exponent notation but not the group separators, which a Range bound // rendered by a decimal-comma culture would otherwise smuggle in as "1,5" meaning 15. - return decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); + if (!decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) + { + return false; + } + + // A magnitude below the decimal range parses to zero, which would publish a bound the + // server does not enforce. + return number != decimal.Zero || !value!.Any(c => c is > '0' and <= '9'); } } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs index 8c98b952ca4..ac19827a046 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs @@ -74,8 +74,15 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) // The Range(Type, string, string) constructor keeps its limits as strings until the // first validation, so a numeric one is written in the culture of the declaring code. + // A limit that already reads as invariant is kept verbatim, because rewriting it can + // only lose precision: "1e-30" would come back as a zero from a decimal round trip. if (bound is string text) { + if (decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + { + return text; + } + return decimal.TryParse(text, NumberStyles.Float, GetRangeLimitCulture(rangeAttribute), out var number) ? number.ToString(CultureInfo.InvariantCulture) : text; diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs index c2577624a29..1c794466f96 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs @@ -79,6 +79,15 @@ public async Task Should_Map_A_Bound_Written_In_The_Exponent_Notation() property.Minimum.ShouldBe("0.00000000000000000001"); } + [Fact] + public async Task Should_Not_Map_A_Bound_Below_The_Decimal_Range() + { + // It would parse to a zero, which the server does not accept. + var property = await GetPropertyAsync(nameof(ConstraintTestDto.UnderflowExponentValue)); + + property.Minimum.ShouldBeNull(); + } + [Fact] public async Task Should_Map_Regular_Expression_Rules() { diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs index 1df8bc7b6a7..76f5e2c0cef 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs @@ -44,6 +44,8 @@ public class ConstraintTestDto public double SmallExponentValue { get; set; } + public double UnderflowExponentValue { get; set; } + public string? UnconstrainedValue { get; set; } } @@ -70,5 +72,6 @@ public ConstraintTestDtoValidator() RuleFor(x => x.DateComparisonValue).GreaterThanOrEqualTo(new DateTime(2020, 1, 1)); RuleFor(x => x.DynamicLengthValue).Length(_ => 2, _ => 8); RuleFor(x => x.SmallExponentValue).GreaterThanOrEqualTo(1e-20); + RuleFor(x => x.UnderflowExponentValue).GreaterThanOrEqualTo(1e-30); } } diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs index a59239de541..f9274324d6e 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs @@ -33,6 +33,16 @@ public void Create_Should_Write_Typed_Range_Bounds_With_The_Invariant_Culture() } } + [Fact] + public void Create_Should_Keep_A_Typed_Range_Bound_That_Is_Already_Invariant() + { + // A decimal round trip would turn a magnitude below its range into a zero. + var model = CreateModel(nameof(TestClass.ExponentRangeValue)); + + model.Minimum.ShouldBe("1e-30"); + model.Maximum.ShouldBe("1e30"); + } + [Fact] public void Create_Should_Keep_A_Typed_Range_Bound_That_Is_Not_A_Number() { @@ -86,6 +96,9 @@ public class TestClass [Range(typeof(decimal), "1,5", "9,5")] public decimal TypedDecimalRangeValue { get; set; } + [Range(typeof(double), "1e-30", "1e30", ParseLimitsInInvariantCulture = true)] + public double ExponentRangeValue { get; set; } + [Range(typeof(DateTime), "2020-01-01", "2030-01-01")] public DateTime DateRangeValue { get; set; } From aee66f02a79c887e8500c942f2daad8be6965e5a Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 31 Aug 2026 10:11:28 +0800 Subject: [PATCH 08/10] Convert a typed range limit with the operand type of the attribute Range(Type, string, string) keeps its limits as strings until the first validation. They are converted the way the attribute converts them, so the reported limit is the one the server validates against whatever the culture of the request is, and a magnitude outside the decimal range survives it. --- .../Modeling/PropertyApiDescriptionModel.cs | 46 ++++++++++++++++--- .../PropertyApiDescriptionModel_Tests.cs | 23 ++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs index ac19827a046..28e68a2128a 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; @@ -11,6 +13,21 @@ namespace Volo.Abp.Http.Modeling; [Serializable] public class PropertyApiDescriptionModel { + private static readonly HashSet NumericTypes = new HashSet + { + typeof(byte), + typeof(sbyte), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(decimal) + }; + public string Name { get; set; } = default!; public string? JsonName { get; set; } @@ -73,24 +90,41 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) } // The Range(Type, string, string) constructor keeps its limits as strings until the - // first validation, so a numeric one is written in the culture of the declaring code. - // A limit that already reads as invariant is kept verbatim, because rewriting it can - // only lose precision: "1e-30" would come back as a zero from a decimal round trip. + // first validation, so a numeric one is still written in the culture that declared it. + // Converting it with the operand type of the attribute keeps the api definition + // independent of the culture, and reports the value the attribute itself validates + // against, which is not always the value that was written down. if (bound is string text) { - if (decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + if (!NumericTypes.Contains(rangeAttribute.OperandType)) { return text; } - return decimal.TryParse(text, NumberStyles.Float, GetRangeLimitCulture(rangeAttribute), out var number) - ? number.ToString(CultureInfo.InvariantCulture) + var converted = ConvertRangeLimitOrNull(text, rangeAttribute); + return converted != null + ? Convert.ToString(converted, CultureInfo.InvariantCulture) : text; } return Convert.ToString(bound, CultureInfo.InvariantCulture); } + private static object? ConvertRangeLimitOrNull(string text, RangeAttribute rangeAttribute) + { + try + { + return TypeDescriptor + .GetConverter(rangeAttribute.OperandType) + .ConvertFromString(null, GetRangeLimitCulture(rangeAttribute), text); + } + catch (Exception) + { + // A limit the attribute can not convert itself is reported the way it was written. + return null; + } + } + private static CultureInfo GetRangeLimitCulture(RangeAttribute rangeAttribute) { #if NET8_0_OR_GREATER diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs index f9274324d6e..565d66c3c86 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs @@ -34,13 +34,25 @@ public void Create_Should_Write_Typed_Range_Bounds_With_The_Invariant_Culture() } [Fact] - public void Create_Should_Keep_A_Typed_Range_Bound_That_Is_Already_Invariant() + public void Create_Should_Convert_A_Typed_Range_Bound_With_Its_Operand_Type() { - // A decimal round trip would turn a magnitude below its range into a zero. + // A magnitude outside the decimal range is still a valid double bound. var model = CreateModel(nameof(TestClass.ExponentRangeValue)); - model.Minimum.ShouldBe("1e-30"); - model.Maximum.ShouldBe("1e30"); + model.Minimum.ShouldBe("1E-30"); + model.Maximum.ShouldBe("1E+30"); + } + + [Fact] + public void Create_Should_Convert_A_Typed_Double_Range_Bound_Written_In_Another_Culture() + { + using (CultureHelper.Use(CultureInfo.GetCultureInfo("de-DE"))) + { + var model = CreateModel(nameof(TestClass.CultureExponentRangeValue)); + + model.Minimum.ShouldBe("1.5E+30"); + model.Maximum.ShouldBe("9.5E+30"); + } } [Fact] @@ -99,6 +111,9 @@ public class TestClass [Range(typeof(double), "1e-30", "1e30", ParseLimitsInInvariantCulture = true)] public double ExponentRangeValue { get; set; } + [Range(typeof(double), "1,5E+30", "9,5E+30")] + public double CultureExponentRangeValue { get; set; } + [Range(typeof(DateTime), "2020-01-01", "2030-01-01")] public DateTime DateRangeValue { get; set; } From 8e53ef864188a43e19ca1675d33aae9f02ce4cf7 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 31 Aug 2026 10:11:28 +0800 Subject: [PATCH 09/10] Map a comparison rule only to a numeric property The contribution context carries the property now, so a rule on another type, the ordinal comparison of two strings for example, no longer publishes a numeric bound. Two bounds are compared as decimals, which are exact for every integral type, and only fall back to double for the magnitudes decimal can not hold. --- .../fundamentals/fluent-validation.md | 4 +- .../AspNetCoreApiDescriptionModelProvider.cs | 12 +- ...nPropertyApiDescriptionModelContributor.cs | 126 ++++++++++++------ ...yApiDescriptionModelContributionContext.cs | 6 + ...olo.Abp.Http.FluentValidation.Tests.csproj | 1 + .../AbpHttpFluentValidationTestBase.cs | 8 +- .../FluentValidationApiDescription_Tests.cs | 31 ++++- .../TestObjects/ConstraintTestDto.cs | 3 + .../TestObjects/CultureTestDto.cs | 4 + .../TestObjects/DataAnnotationTestDto.cs | 4 + 10 files changed, 151 insertions(+), 48 deletions(-) diff --git a/docs/en/framework/fundamentals/fluent-validation.md b/docs/en/framework/fundamentals/fluent-validation.md index c1dfd41a101..97277f207e2 100644 --- a/docs/en/framework/fundamentals/fluent-validation.md +++ b/docs/en/framework/fundamentals/fluent-validation.md @@ -93,7 +93,7 @@ The following rules are mapped: | FluentValidation rule | API definition | |---|---| | `NotNull()`, `NotEmpty()` | `IsRequired` | -| `Length(min, max)`, `MinimumLength(min)`, `MaximumLength(max)` | `MinLength`, `MaxLength` | +| `Length(min, max)`, `MinimumLength(min)`, `MaximumLength(max)` | `MinLength`, `MaxLength` (a zero bound is left out, see below) | | `Matches(...)` | `Regex` | | `GreaterThanOrEqualTo(...)`, `GreaterThan(...)` | `Minimum` (+ `MinimumIsExclusive`) | | `LessThanOrEqualTo(...)`, `LessThan(...)` | `Maximum` (+ `MaximumIsExclusive`) | @@ -110,7 +110,7 @@ The following rules are not mapped, because they don't apply to every instance o * Rules under `When(...)` / `Unless(...)` (both the chained and the block form) and their async variants, because the same property can be required for one instance and optional for another. * Rules that only belong to a non-default rule set, because ABP validates with FluentValidation's default selector, which does not run them. * `RuleForEach(...)` rules, because they constrain the items of a collection rather than the collection property. -* Comparisons against another property, and any bound that is not a number. +* Comparisons on a property that is not a number, and comparisons against another property. `Minimum` and `Maximum` are numeric bounds, so the ordinal comparison of two strings can not be published there. ### Rules That Are Not Fully Expressed diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 4c8f0a5bec1..837eb929fe8 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -341,9 +341,19 @@ protected virtual async Task ContributeToPropertiesAsync(TypeApiDescriptionModel return; } + var propertyInfos = type + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.DeclaringType == type) + .ToDictionary(p => p.Name, p => p); + foreach (var propertyModel in typeModel.Properties!) { - var context = new PropertyApiDescriptionModelContributionContext(propertyModel, type); + if (!propertyInfos.TryGetValue(propertyModel.Name, out var propertyInfo)) + { + continue; + } + + var context = new PropertyApiDescriptionModelContributionContext(propertyModel, propertyInfo, type); foreach (var contributor in _propertyContributors) { await contributor.ContributeAsync(context); diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs index ad3465a0d08..8074d60bfad 100644 --- a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs @@ -1,20 +1,38 @@ using System; using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using FluentValidation; using FluentValidation.Internal; using FluentValidation.Validators; using Volo.Abp.DependencyInjection; using Volo.Abp.Http.Modeling; +using Volo.Abp.Reflection; namespace Volo.Abp.Http.FluentValidation; [ExposeServices(typeof(IPropertyApiDescriptionModelContributor))] public class FluentValidationPropertyApiDescriptionModelContributor : IPropertyApiDescriptionModelContributor, ITransientDependency { + private static readonly FrozenSet NumericTypes = new HashSet + { + typeof(byte), + typeof(sbyte), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(decimal) + }.ToFrozenSet(); + protected IServiceProvider ServiceProvider { get; } protected ConcurrentDictionary?> RuleCache { get; } @@ -37,7 +55,7 @@ public virtual Task ContributeAsync(PropertyApiDescriptionModelContributionConte foreach (var validator in rules[context.Model.Name]) { - ApplyValidator(context.Model, validator); + ApplyValidator(context.Model, context.PropertyInfo, validator); } return Task.CompletedTask; @@ -103,7 +121,7 @@ protected virtual bool IsCollectionRule(IValidationRule rule) .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ICollectionRule<,>)); } - protected virtual void ApplyValidator(PropertyApiDescriptionModel model, IPropertyValidator validator) + protected virtual void ApplyValidator(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, IPropertyValidator validator) { switch (validator) { @@ -118,10 +136,10 @@ protected virtual void ApplyValidator(PropertyApiDescriptionModel model, IProper ApplyRegularExpression(model, regularExpressionValidator); break; case IBetweenValidator betweenValidator: - ApplyBetween(model, betweenValidator); + ApplyBetween(model, propertyInfo, betweenValidator); break; case IComparisonValidator comparisonValidator: - ApplyComparison(model, comparisonValidator); + ApplyComparison(model, propertyInfo, comparisonValidator); break; } } @@ -156,106 +174,132 @@ protected virtual void ApplyRegularExpression(PropertyApiDescriptionModel model, model.Regex = validator.Expression; } - protected virtual void ApplyComparison(PropertyApiDescriptionModel model, IComparisonValidator validator) + protected virtual void ApplyComparison(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, IComparisonValidator validator) { switch (validator.Comparison) { case Comparison.GreaterThan: - ApplyMinimum(model, validator.ValueToCompare, isExclusive: true); + ApplyMinimum(model, propertyInfo, validator.ValueToCompare, isExclusive: true); break; case Comparison.GreaterThanOrEqual: - ApplyMinimum(model, validator.ValueToCompare, isExclusive: false); + ApplyMinimum(model, propertyInfo, validator.ValueToCompare, isExclusive: false); break; case Comparison.LessThan: - ApplyMaximum(model, validator.ValueToCompare, isExclusive: true); + ApplyMaximum(model, propertyInfo, validator.ValueToCompare, isExclusive: true); break; case Comparison.LessThanOrEqual: - ApplyMaximum(model, validator.ValueToCompare, isExclusive: false); + ApplyMaximum(model, propertyInfo, validator.ValueToCompare, isExclusive: false); break; } } - protected virtual void ApplyBetween(PropertyApiDescriptionModel model, IBetweenValidator validator) + protected virtual void ApplyBetween(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, IBetweenValidator validator) { var isExclusive = validator is not IInclusiveBetweenValidator; - ApplyMinimum(model, validator.From, isExclusive); - ApplyMaximum(model, validator.To, isExclusive); + ApplyMinimum(model, propertyInfo, validator.From, isExclusive); + ApplyMaximum(model, propertyInfo, validator.To, isExclusive); } - protected virtual void ApplyMinimum(PropertyApiDescriptionModel model, object? value, bool isExclusive) + protected virtual void ApplyMinimum(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, object? value, bool isExclusive) { - // Minimum and Maximum are ordered bounds, so a value that is not a number, such as a - // DateTime, has nothing meaningful to publish there. - if (!TryGetNumber(value, out var minimum)) + var bound = GetNumericBound(propertyInfo, value); + if (bound == null) { return; } if (model.Minimum != null) { - if (!TryParseNumber(model.Minimum, out var existingMinimum)) + if (!TryCompareBounds(model.Minimum, bound, out var comparison)) { return; } - // The flag follows the winning bound instead of being combined, otherwise ">= 10" merged with "> 5" would become "> 10". - var existingIsExclusive = model.MinimumIsExclusive == true; - if (existingMinimum > minimum || (existingMinimum == minimum && existingIsExclusive)) + // The higher bound wins, and an exclusive one is the stricter when both sit on the + // same value. The winning bound is published the way it was written, so no value is + // lost on the way through a number type that can not hold it. + if (comparison > 0 || (comparison == 0 && model.MinimumIsExclusive == true)) { - minimum = existingMinimum; - isExclusive = existingIsExclusive; + return; } } - model.Minimum = minimum.ToString(CultureInfo.InvariantCulture); + model.Minimum = bound; model.MinimumIsExclusive = isExclusive; } - protected virtual void ApplyMaximum(PropertyApiDescriptionModel model, object? value, bool isExclusive) + protected virtual void ApplyMaximum(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, object? value, bool isExclusive) { - if (!TryGetNumber(value, out var maximum)) + var bound = GetNumericBound(propertyInfo, value); + if (bound == null) { return; } if (model.Maximum != null) { - if (!TryParseNumber(model.Maximum, out var existingMaximum)) + if (!TryCompareBounds(model.Maximum, bound, out var comparison)) { return; } - var existingIsExclusive = model.MaximumIsExclusive == true; - if (existingMaximum < maximum || (existingMaximum == maximum && existingIsExclusive)) + if (comparison < 0 || (comparison == 0 && model.MaximumIsExclusive == true)) { - maximum = existingMaximum; - isExclusive = existingIsExclusive; + return; } } - model.Maximum = maximum.ToString(CultureInfo.InvariantCulture); + model.Maximum = bound; model.MaximumIsExclusive = isExclusive; } - protected virtual bool TryGetNumber(object? value, out decimal number) + protected virtual string? GetNumericBound(PropertyInfo propertyInfo, object? value) { - // A comparison against another property has no value to read. - var bound = value != null ? Convert.ToString(value, CultureInfo.InvariantCulture) : null; - return TryParseNumber(bound, out number); + // Minimum and Maximum are numeric bounds. A comparison on another type, the ordinal + // comparison of two strings for example, means something else and can not go there. + // A comparison against another property has no value to publish either. + if (value == null || !NumericTypes.Contains(TypeHelper.StripNullable(propertyInfo.PropertyType))) + { + return null; + } + + var bound = Convert.ToString(value, CultureInfo.InvariantCulture); + return bound.IsNullOrWhiteSpace() || !double.TryParse(bound, NumberStyles.Float, CultureInfo.InvariantCulture, out _) + ? null + : bound; + } + + protected virtual bool TryCompareBounds(string left, string right, out int comparison) + { + // Decimal is exact for every integral type and for decimal itself, which double is not + // above its 53 bits of mantissa. Double only comes in for the magnitudes decimal can + // not hold, where its precision is the best there is anyway. + if (TryParseExactly(left, out var leftValue) && TryParseExactly(right, out var rightValue)) + { + comparison = leftValue.CompareTo(rightValue); + return true; + } + + if (double.TryParse(left, NumberStyles.Float, CultureInfo.InvariantCulture, out var leftDouble) && + double.TryParse(right, NumberStyles.Float, CultureInfo.InvariantCulture, out var rightDouble)) + { + comparison = leftDouble.CompareTo(rightDouble); + return true; + } + + comparison = 0; + return false; } - protected virtual bool TryParseNumber(string? value, out decimal number) + protected virtual bool TryParseExactly(string value, out decimal number) { - // Float allows the exponent notation but not the group separators, which a Range bound - // rendered by a decimal-comma culture would otherwise smuggle in as "1,5" meaning 15. if (!decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) { return false; } - // A magnitude below the decimal range parses to zero, which would publish a bound the - // server does not enforce. - return number != decimal.Zero || !value!.Any(c => c is > '0' and <= '9'); + // A magnitude below the decimal range collapses to a zero, which would compare wrong. + return number != decimal.Zero || !value.Any(c => c is > '0' and <= '9'); } } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs index a7bda9890c6..d4acf2c0b4c 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using JetBrains.Annotations; namespace Volo.Abp.Http.Modeling; @@ -8,14 +9,19 @@ public class PropertyApiDescriptionModelContributionContext [NotNull] public PropertyApiDescriptionModel Model { get; } + [NotNull] + public PropertyInfo PropertyInfo { get; } + [NotNull] public Type DeclaringType { get; } public PropertyApiDescriptionModelContributionContext( [NotNull] PropertyApiDescriptionModel model, + [NotNull] PropertyInfo propertyInfo, [NotNull] Type declaringType) { Model = Check.NotNull(model, nameof(model)); + PropertyInfo = Check.NotNull(propertyInfo, nameof(propertyInfo)); DeclaringType = Check.NotNull(declaringType, nameof(declaringType)); } } diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj index dd3639df09c..f3001bd8a8a 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj @@ -4,6 +4,7 @@ net10.0 + enable diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs index 76a2e1306c5..be25f2b813a 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Http.Modeling; @@ -22,9 +23,14 @@ protected virtual async Task CreateTypeModelAsync(Type var typeModel = TypeApiDescriptionModel.Create(type); var contributors = ServiceProvider.GetServices().ToArray(); + var propertyInfos = type + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.DeclaringType == type) + .ToDictionary(p => p.Name, p => p); + foreach (var propertyModel in typeModel.Properties!) { - var context = new PropertyApiDescriptionModelContributionContext(propertyModel, type); + var context = new PropertyApiDescriptionModelContributionContext(propertyModel, propertyInfos[propertyModel.Name], type); foreach (var contributor in contributors) { await contributor.ContributeAsync(context); diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs index 1c794466f96..2a155476de6 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs @@ -76,16 +76,25 @@ public async Task Should_Map_A_Bound_Written_In_The_Exponent_Notation() { var property = await GetPropertyAsync(nameof(ConstraintTestDto.SmallExponentValue)); - property.Minimum.ShouldBe("0.00000000000000000001"); + property.Minimum.ShouldBe("1E-20"); } [Fact] - public async Task Should_Not_Map_A_Bound_Below_The_Decimal_Range() + public async Task Should_Map_A_Bound_Below_The_Decimal_Range() { - // It would parse to a zero, which the server does not accept. var property = await GetPropertyAsync(nameof(ConstraintTestDto.UnderflowExponentValue)); + property.Minimum.ShouldBe("1E-30"); + } + + [Fact] + public async Task Should_Not_Map_A_Comparison_On_A_Property_That_Is_Not_A_Number() + { + // The server compares two strings ordinally, so a numeric bound would say something else. + var property = await GetPropertyAsync(nameof(ConstraintTestDto.StringComparisonValue)); + property.Minimum.ShouldBeNull(); + property.MinimumIsExclusive.ShouldBeNull(); } [Fact] @@ -216,6 +225,17 @@ public async Task Should_Keep_The_Exclusivity_Of_The_Winning_Bound() same.MaximumIsExclusive.ShouldBe(true); } + [Fact] + public async Task Should_Compare_Bounds_Beyond_The_Double_Precision_Exactly() + { + // Both bounds collapse to the same double, so only an exact comparison keeps the + // stricter attribute bound instead of replacing it with the looser rule. + var property = await GetPropertyAsync(nameof(DataAnnotationTestDto.HighPrecisionValue)); + + property.Minimum.ShouldBe("9007199254740993"); + property.Maximum.ShouldBe("18446744073709551615"); + } + [Fact] public async Task Should_Keep_The_Attribute_Regular_Expression() { @@ -259,6 +279,11 @@ public async Task Should_Merge_Range_Attribute_Bounds_Under_Any_Culture() var typed = await GetPropertyAsync(nameof(CultureTestDto.TypedDecimalRangeValue)); typed.Minimum.ShouldBe("2"); typed.Maximum.ShouldBe("9.5"); + + // A bound outside the decimal range still loses to the stricter rule. + var exponent = await GetPropertyAsync(nameof(CultureTestDto.ExponentRangeValue)); + exponent.Minimum.ShouldBe("2"); + exponent.Maximum.ShouldBe("1E+30"); } } diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs index 76f5e2c0cef..7771199f1eb 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs @@ -46,6 +46,8 @@ public class ConstraintTestDto public double UnderflowExponentValue { get; set; } + public string? StringComparisonValue { get; set; } + public string? UnconstrainedValue { get; set; } } @@ -73,5 +75,6 @@ public ConstraintTestDtoValidator() RuleFor(x => x.DynamicLengthValue).Length(_ => 2, _ => 8); RuleFor(x => x.SmallExponentValue).GreaterThanOrEqualTo(1e-20); RuleFor(x => x.UnderflowExponentValue).GreaterThanOrEqualTo(1e-30); + RuleFor(x => x.StringComparisonValue).GreaterThan("10"); } } diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs index 104da7234aa..13aca5cfeb9 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs @@ -12,6 +12,9 @@ public class CultureTestDto [Range(typeof(decimal), "1,5", "9,5")] public decimal TypedDecimalRangeValue { get; set; } + [Range(typeof(double), "1e-30", "1e30", ParseLimitsInInvariantCulture = true)] + public double ExponentRangeValue { get; set; } + [Range(typeof(DateTime), "2020-01-01", "2030-01-01")] public DateTime DateRangeValue { get; set; } } @@ -22,5 +25,6 @@ public CultureTestDtoValidator() { RuleFor(x => x.DecimalRangeValue).GreaterThanOrEqualTo(2.0); RuleFor(x => x.TypedDecimalRangeValue).GreaterThanOrEqualTo(2m); + RuleFor(x => x.ExponentRangeValue).GreaterThanOrEqualTo(2d); } } diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs index fa09d4515c0..1220d65a24e 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs @@ -23,6 +23,9 @@ public class DataAnnotationTestDto [Range(1, 100, MinimumIsExclusive = true, MaximumIsExclusive = true)] public int ExclusiveAttributeValue { get; set; } + + [Range(typeof(ulong), "9007199254740993", "18446744073709551615", ParseLimitsInInvariantCulture = true)] + public ulong HighPrecisionValue { get; set; } } public class DataAnnotationTestDtoValidator : AbstractValidator @@ -34,5 +37,6 @@ public DataAnnotationTestDtoValidator() RuleFor(x => x.MergedRangeValue).GreaterThanOrEqualTo(10).LessThanOrEqualTo(90); RuleFor(x => x.LooserFluentBoundValue).GreaterThan(-5).LessThan(500); RuleFor(x => x.SameBoundValue).GreaterThan(10).LessThan(90); + RuleFor(x => x.HighPrecisionValue).GreaterThanOrEqualTo(9_007_199_254_740_992UL); } } From 270533e30f74042a02be8aa1c7bc2a577d4932af Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 31 Aug 2026 11:12:58 +0800 Subject: [PATCH 10/10] Map a numeric bound only when its meaning is the same on both sides A native integer is a number in the api type system too, and a between rule that carries its own comparer is skipped when its bounds no longer read as an interval, because the comparer itself is not on the descriptor. --- .../fundamentals/fluent-validation.md | 3 +++ ...onPropertyApiDescriptionModelContributor.cs | 15 ++++++++++++++- .../Modeling/PropertyApiDescriptionModel.cs | 16 ++++++++++------ .../FluentValidationApiDescription_Tests.cs | 18 ++++++++++++++++++ .../TestObjects/ConstraintTestDto.cs | 14 ++++++++++++++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/en/framework/fundamentals/fluent-validation.md b/docs/en/framework/fundamentals/fluent-validation.md index 97277f207e2..7a73396ab8f 100644 --- a/docs/en/framework/fundamentals/fluent-validation.md +++ b/docs/en/framework/fundamentals/fluent-validation.md @@ -101,6 +101,8 @@ The following rules are mapped: `MinimumIsExclusive` and `MaximumIsExclusive` indicate whether the value can be equal to the bound. They are also filled from the `Range` attribute, so an exclusive bound is not lost when it is declared with an attribute. +> A `Range` attribute that writes its limits as strings, like `[Range(typeof(decimal), "1.5", "9.5")]`, reads them in the culture of the request unless it sets `ParseLimitsInInvariantCulture`. Set it, so that the limit means the same thing to the server and to the api definition on every request. + When a rule and an attribute constrain the same property, the stricter bound is used: the higher minimum and the lower maximum. When both bounds have the same value, the exclusive one is used. The exclusivity always comes from the bound that is used, so `[Range(0, 100)]` with `GreaterThan(-5)` results in an inclusive `Minimum = 0`. A non-numeric bound, like a `Range` attribute on a `DateTime` property, is kept as-is. An existing `Regex` is also kept, because a single value can not express two patterns that both have to match. ### Rules That Are Not Mapped @@ -121,6 +123,7 @@ The following rules are not fully expressed in the API definition: * A validator of a derived DTO can not add rules to a property declared by its base class, because each type describes only its own properties. * A rule on a nested object, like `RuleFor(x => x.Address.City)`, is not published either. The nested type is described on its own, with its own validator, and its model is shared by every DTO that uses it. * A validator of a closed generic DTO is not used, because the API definition describes the generic type definition, which is shared by all of its instantiations. +* `InclusiveBetween(...)` and `ExclusiveBetween(...)` with their own `IComparer` are only published when their bounds still read as an interval in the natural order. FluentValidation does not expose the comparer, so a rule that orders its values differently can not be recognised. * `Matches(pattern, RegexOptions)` publishes the pattern without the options. This is the one case where a client can be stricter than the server, so avoid the overload if the client should not reject what the server accepts. > The API definition describes a type, while the server runs the validation per action. So, a DTO that is only used as a return value, or that is sent to an action which doesn't validate its parameters, still declares its constraints here. This is also how the data annotation attributes have always been reported. diff --git a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs index 8074d60bfad..64d07bd0211 100644 --- a/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs @@ -30,7 +30,9 @@ public class FluentValidationPropertyApiDescriptionModelContributor : IPropertyA typeof(ulong), typeof(float), typeof(double), - typeof(decimal) + typeof(decimal), + typeof(IntPtr), + typeof(UIntPtr) }.ToFrozenSet(); protected IServiceProvider ServiceProvider { get; } @@ -195,6 +197,17 @@ protected virtual void ApplyComparison(PropertyApiDescriptionModel model, Proper protected virtual void ApplyBetween(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, IBetweenValidator validator) { + var from = GetNumericBound(propertyInfo, validator.From); + var to = GetNumericBound(propertyInfo, validator.To); + + // A between rule can carry its own comparer, which the descriptor does not expose. An + // interval that reads as empty in the natural order is what one looks like from here, + // and publishing its bounds would say the opposite of what the rule accepts. + if (from == null || to == null || !TryCompareBounds(from, to, out var comparison) || comparison > 0) + { + return; + } + var isExclusive = validator is not IInclusiveBetweenValidator; ApplyMinimum(model, propertyInfo, validator.From, isExclusive); diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs index 28e68a2128a..a8d3f3ae5f3 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel.cs @@ -25,7 +25,9 @@ public class PropertyApiDescriptionModel typeof(ulong), typeof(float), typeof(double), - typeof(decimal) + typeof(decimal), + typeof(IntPtr), + typeof(UIntPtr) }; public string Name { get; set; } = default!; @@ -90,10 +92,10 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) } // The Range(Type, string, string) constructor keeps its limits as strings until the - // first validation, so a numeric one is still written in the culture that declared it. - // Converting it with the operand type of the attribute keeps the api definition - // independent of the culture, and reports the value the attribute itself validates - // against, which is not always the value that was written down. + // first validation. Converting one the way the attribute converts it reports the value + // the attribute validates against, which is not always the value that was written down. + // The attribute reads its limits in the culture of the request unless it opts into the + // invariant one, so only that opt-in makes the reported limit stable across requests. if (bound is string text) { if (!NumericTypes.Contains(rangeAttribute.OperandType)) @@ -120,7 +122,9 @@ public static PropertyApiDescriptionModel Create(PropertyInfo propertyInfo) } catch (Exception) { - // A limit the attribute can not convert itself is reported the way it was written. + // A limit that does not convert is reported the way it was written. The attribute + // throws on it during the first validation, and failing the whole api definition + // over one declaration would hide every other type. return null; } } diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs index 2a155476de6..699729689f8 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs @@ -87,6 +87,24 @@ public async Task Should_Map_A_Bound_Below_The_Decimal_Range() property.Minimum.ShouldBe("1E-30"); } + [Fact] + public async Task Should_Map_A_Comparison_On_A_Native_Integer() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.NativeIntegerValue)); + + property.Minimum.ShouldBe("5"); + } + + [Fact] + public async Task Should_Not_Map_A_Between_Rule_That_Reads_As_An_Empty_Interval() + { + // The rule carries its own comparer, so its bounds mean the opposite of what they say. + var property = await GetPropertyAsync(nameof(ConstraintTestDto.ReversedBetweenValue)); + + property.Minimum.ShouldBeNull(); + property.Maximum.ShouldBeNull(); + } + [Fact] public async Task Should_Not_Map_A_Comparison_On_A_Property_That_Is_Not_A_Number() { diff --git a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs index 7771199f1eb..2a93cbd79e6 100644 --- a/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs @@ -48,9 +48,21 @@ public class ConstraintTestDto public string? StringComparisonValue { get; set; } + public nint NativeIntegerValue { get; set; } + + public int ReversedBetweenValue { get; set; } + public string? UnconstrainedValue { get; set; } } +public class ReversedComparer : IComparer +{ + public int Compare(int x, int y) + { + return y.CompareTo(x); + } +} + public class ConstraintTestDtoValidator : AbstractValidator { public ConstraintTestDtoValidator() @@ -76,5 +88,7 @@ public ConstraintTestDtoValidator() RuleFor(x => x.SmallExponentValue).GreaterThanOrEqualTo(1e-20); RuleFor(x => x.UnderflowExponentValue).GreaterThanOrEqualTo(1e-30); RuleFor(x => x.StringComparisonValue).GreaterThan("10"); + RuleFor(x => x.NativeIntegerValue).GreaterThanOrEqualTo((nint)5); + RuleFor(x => x.ReversedBetweenValue).InclusiveBetween(10, 1, new ReversedComparer()); } }