Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/en/framework/fundamentals/fluent-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,74 @@ public class CreateUpdateBookDtoValidator : AbstractValidator<CreateUpdateBookDt

ABP will automatically find this class and associate with the `CreateUpdateBookDto` on object validation.

## Exposing the Rules in the API Definition

ABP creates the API definition from the data annotation attributes of the DTO properties. So, a DTO that is only validated by FluentValidation has no constraints in the API definition, and the client proxy generators can not see them. The [Volo.Abp.Http.FluentValidation](https://www.nuget.org/packages/Volo.Abp.Http.FluentValidation) NuGet package adds the FluentValidation rules to the API definition, merged with the ones coming from the attributes.

### Installation

Open a command line window in the folder of the project (.csproj file) that hosts your HTTP API and type the following command:

````bash
abp add-package Volo.Abp.Http.FluentValidation
````

If you want to manually install, add the [Volo.Abp.Http.FluentValidation](https://www.nuget.org/packages/Volo.Abp.Http.FluentValidation) NuGet package to your project and add the `AbpHttpFluentValidationModule` to the dependency list of your module:

````csharp
[DependsOn(
//...other dependencies
typeof(AbpHttpFluentValidationModule)
)]
public class YourModule : AbpModule
{
}
````

`AbpHttpFluentValidationModule` already depends on `AbpFluentValidationModule`, so you don't need both.

### Mapped Rules

The following rules are mapped:

| FluentValidation rule | API definition |
|---|---|
| `NotNull()`, `NotEmpty()` | `IsRequired` |
| `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`) |
| `InclusiveBetween(...)`, `ExclusiveBetween(...)` | `Minimum`, `Maximum` (+ the exclusive flags) |

`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

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<T, int>` 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<T>` 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)
2 changes: 2 additions & 0 deletions framework/Volo.Abp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
<Project Path="src/Volo.Abp.Http.Client.IdentityModel/Volo.Abp.Http.Client.IdentityModel.csproj" />
<Project Path="src/Volo.Abp.Http.Client.Web/Volo.Abp.Http.Client.Web.csproj" />
<Project Path="src/Volo.Abp.Http.Client/Volo.Abp.Http.Client.csproj" />
<Project Path="src/Volo.Abp.Http.FluentValidation/Volo.Abp.Http.FluentValidation.csproj" />
<Project Path="src/Volo.Abp.Http/Volo.Abp.Http.csproj" />
<Project Path="src/Volo.Abp.IdentityModel/Volo.Abp.IdentityModel.csproj" />
<Project Path="src/Volo.Abp.Imaging.Abstractions/Volo.Abp.Imaging.Abstractions.csproj" />
Expand Down Expand Up @@ -229,6 +230,7 @@
<Project Path="test/Volo.Abp.GlobalFeatures.Tests/Volo.Abp.GlobalFeatures.Tests.csproj" />
<Project Path="test/Volo.Abp.Http.Client.IdentityModel.Web.Tests/Volo.Abp.Http.Client.IdentityModel.Web.Tests.csproj" />
<Project Path="test/Volo.Abp.Http.Client.Tests/Volo.Abp.Http.Client.Tests.csproj" />
<Project Path="test/Volo.Abp.Http.FluentValidation.Tests/Volo.Abp.Http.FluentValidation.Tests.csproj" />
<Project Path="test/Volo.Abp.Http.Tests/Volo.Abp.Http.Tests.csproj" />
<Project Path="test/Volo.Abp.IdentityModel.Tests/Volo.Abp.IdentityModel.Tests.csproj" />
<Project Path="test/Volo.Abp.Imaging.Abstractions.Tests/Volo.Abp.Imaging.Abstractions.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AspNetCoreApiDescriptionModelProviderOptions> options,
IApiDescriptionGroupCollectionProvider descriptionProvider,
IOptions<AbpAspNetCoreMvcOptions> abpAspNetCoreMvcOptions,
IOptions<AbpApiDescriptionModelOptions> modelOptions,
IXmlDocumentationProvider xmlDocProvider)
IXmlDocumentationProvider xmlDocProvider,
IEnumerable<IPropertyApiDescriptionModelContributor> propertyContributors)
{
_options = options.Value;
_descriptionProvider = descriptionProvider;
_abpAspNetCoreMvcOptions = abpAspNetCoreMvcOptions.Value;
_modelOptions = modelOptions.Value;
_xmlDocProvider = xmlDocProvider;
_propertyContributors = propertyContributors.ToArray();

Logger = NullLogger<AspNetCoreApiDescriptionModelProvider>.Instance;
}
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Volo.Abp.Http.FluentValidation.Tests")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"role": "lib.framework"
}
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="..\..\..\configureawait.props" />
<Import Project="..\..\..\common.props" />

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<AssemblyName>Volo.Abp.Http.FluentValidation</AssemblyName>
<PackageId>Volo.Abp.Http.FluentValidation</PackageId>
<AssetTargetFallback>$(AssetTargetFallback);portable-net45+win8+wp8+wpa81;</AssetTargetFallback>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<RootNamespace />
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Volo.Abp.FluentValidation\Volo.Abp.FluentValidation.csproj" />
<ProjectReference Include="..\Volo.Abp.Http\Volo.Abp.Http.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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
{
}
Loading
Loading