diff --git a/docs/en/framework/fundamentals/fluent-validation.md b/docs/en/framework/fundamentals/fluent-validation.md index 8270e65b724..7a73396ab8f 100644 --- a/docs/en/framework/fundamentals/fluent-validation.md +++ b/docs/en/framework/fundamentals/fluent-validation.md @@ -60,6 +60,74 @@ public class CreateUpdateBookDtoValidator : AbstractValidator 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 + +The following rules are not mapped, because they don't apply to every instance of the DTO: + +* 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 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 + +The following rules are not fully expressed in the API definition: + +* A zero length bound, from `MaximumLength(0)` or `Length(0, 0)`, is not published. Every length rule has a `Func` 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. +* `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. + ## 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.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index bd5e1e82a04..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 @@ -34,19 +34,22 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide private readonly AbpAspNetCoreMvcOptions _abpAspNetCoreMvcOptions; private readonly AbpApiDescriptionModelOptions _modelOptions; private readonly IXmlDocumentationProvider _xmlDocProvider; + 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; } @@ -321,6 +324,8 @@ private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel app 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)) @@ -329,6 +334,33 @@ private async Task AddCustomTypesToModelAsync(ApplicationApiDescriptionModel app } } + protected virtual async Task ContributeToPropertiesAsync(TypeApiDescriptionModel typeModel, Type type) + { + if (_propertyContributors.IsNullOrEmpty() || typeModel.Properties.IsNullOrEmpty()) + { + 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!) + { + 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); + } + } + } + private static string CalculateTypeName(Type type) { if (!type.IsGenericTypeDefinition) 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..64d07bd0211 --- /dev/null +++ b/framework/src/Volo.Abp.Http.FluentValidation/Volo/Abp/Http/FluentValidation/FluentValidationPropertyApiDescriptionModelContributor.cs @@ -0,0 +1,318 @@ +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), + typeof(IntPtr), + typeof(UIntPtr) + }.ToFrozenSet(); + + 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, context.PropertyInfo, 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, PropertyInfo propertyInfo, 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, propertyInfo, betweenValidator); + break; + case IComparisonValidator comparisonValidator: + ApplyComparison(model, propertyInfo, 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, PropertyInfo propertyInfo, IComparisonValidator validator) + { + switch (validator.Comparison) + { + case Comparison.GreaterThan: + ApplyMinimum(model, propertyInfo, validator.ValueToCompare, isExclusive: true); + break; + case Comparison.GreaterThanOrEqual: + ApplyMinimum(model, propertyInfo, validator.ValueToCompare, isExclusive: false); + break; + case Comparison.LessThan: + ApplyMaximum(model, propertyInfo, validator.ValueToCompare, isExclusive: true); + break; + case Comparison.LessThanOrEqual: + ApplyMaximum(model, propertyInfo, validator.ValueToCompare, isExclusive: false); + break; + } + } + + 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); + ApplyMaximum(model, propertyInfo, validator.To, isExclusive); + } + + protected virtual void ApplyMinimum(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, object? value, bool isExclusive) + { + var bound = GetNumericBound(propertyInfo, value); + if (bound == null) + { + return; + } + + if (model.Minimum != null) + { + if (!TryCompareBounds(model.Minimum, bound, out var comparison)) + { + return; + } + + // 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)) + { + return; + } + } + + model.Minimum = bound; + model.MinimumIsExclusive = isExclusive; + } + + protected virtual void ApplyMaximum(PropertyApiDescriptionModel model, PropertyInfo propertyInfo, object? value, bool isExclusive) + { + var bound = GetNumericBound(propertyInfo, value); + if (bound == null) + { + return; + } + + if (model.Maximum != null) + { + if (!TryCompareBounds(model.Maximum, bound, out var comparison)) + { + return; + } + + if (comparison < 0 || (comparison == 0 && model.MaximumIsExclusive == true)) + { + return; + } + } + + model.Maximum = bound; + model.MaximumIsExclusive = isExclusive; + } + + protected virtual string? GetNumericBound(PropertyInfo propertyInfo, object? value) + { + // 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 TryParseExactly(string value, out decimal number) + { + if (!decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) + { + return false; + } + + // 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/IPropertyApiDescriptionModelContributor.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs new file mode 100644 index 00000000000..a802b564093 --- /dev/null +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/IPropertyApiDescriptionModelContributor.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.Http.Modeling; + +public interface IPropertyApiDescriptionModelContributor +{ + Task ContributeAsync(PropertyApiDescriptionModelContributionContext context); +} 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..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 @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Linq; using System.Reflection; using Volo.Abp.Http.ProxyScripting.Configuration; @@ -10,6 +13,23 @@ 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), + typeof(IntPtr), + typeof(UIntPtr) + }; + public string Name { get; set; } = default!; public string? JsonName { get; set; } @@ -28,6 +48,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 +65,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 +74,95 @@ 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 = GetRangeBound(rangeAttribute, rangeAttribute?.Minimum), + Maximum = GetRangeBound(rangeAttribute, rangeAttribute?.Maximum), + 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 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. 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)) + { + return text; + } + + 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 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; + } + } + + 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) + { + 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/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..d4acf2c0b4c --- /dev/null +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/PropertyApiDescriptionModelContributionContext.cs @@ -0,0 +1,27 @@ +using System; +using System.Reflection; +using JetBrains.Annotations; + +namespace Volo.Abp.Http.Modeling; + +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.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..f3001bd8a8a --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj @@ -0,0 +1,18 @@ + + + + + + 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 new file mode 100644 index 00000000000..be25f2b813a --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/AbpHttpFluentValidationTestBase.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using System.Reflection; +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(); + + 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, propertyInfos[propertyModel.Name], 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..699729689f8 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/FluentValidationApiDescription_Tests.cs @@ -0,0 +1,358 @@ +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("1E-20"); + } + + [Fact] + public async Task Should_Map_A_Bound_Below_The_Decimal_Range() + { + var property = await GetPropertyAsync(nameof(ConstraintTestDto.UnderflowExponentValue)); + + 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() + { + // 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] + 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_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() + { + 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"); + + 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"); + } + } + + [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..2a93cbd79e6 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/ConstraintTestDto.cs @@ -0,0 +1,94 @@ +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 double UnderflowExponentValue { get; set; } + + 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() + { + 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); + 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()); + } +} 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..13aca5cfeb9 --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/CultureTestDto.cs @@ -0,0 +1,30 @@ +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(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; } +} + +public class CultureTestDtoValidator : AbstractValidator +{ + 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 new file mode 100644 index 00000000000..1220d65a24e --- /dev/null +++ b/framework/test/Volo.Abp.Http.FluentValidation.Tests/Volo/Abp/Http/FluentValidation/TestObjects/DataAnnotationTestDto.cs @@ -0,0 +1,42 @@ +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; } + + [Range(typeof(ulong), "9007199254740993", "18446744073709551615", ParseLimitsInInvariantCulture = true)] + public ulong HighPrecisionValue { 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); + RuleFor(x => x.HighPrecisionValue).GreaterThanOrEqualTo(9_007_199_254_740_992UL); + } +} 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/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..565d66c3c86 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/PropertyApiDescriptionModel_Tests.cs @@ -0,0 +1,125 @@ +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_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_Convert_A_Typed_Range_Bound_With_Its_Operand_Type() + { + // 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("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] + 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() + { + 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(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(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; } + + [Range(1, 100, MinimumIsExclusive = true, MaximumIsExclusive = true)] + public int ExclusiveRangeValue { get; set; } + + public string? UnconstrainedValue { 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",