From 270533e30f74042a02be8aa1c7bc2a577d4932af Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 31 Aug 2026 11:12:58 +0800 Subject: [PATCH] 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 97277f207e..7a73396ab8 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 8074d60bfa..64d07bd021 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 @@ public class FluentValidationPropertyApiDescriptionModelContributor : IPropertyA 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 28e68a2128..a8d3f3ae5f 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 class PropertyApiDescriptionModel } // 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 class PropertyApiDescriptionModel } 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 2a155476de..699729689f 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 class FluentValidationApiDescription_Tests : AbpHttpFluentValidationTestB 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 7771199f1e..2a93cbd79e 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 class ConstraintTestDtoValidator : AbstractValidator 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()); } }