Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.1" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using FluentValidation;

namespace ClassLibrary1
{
public static class FluentValidationExtensions
{
public static IRuleBuilderOptions<T, string> FullName<T>(this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder.MinimumLength(10)
.Must(val => val.Split(" ").Length == 2)
.WithMessage("Name must contain a single space and be at least 10 characters long");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace ClassLibrary1
{
public class Order
{
public string CustomerName { get; set; }
public int Price { get; set; }
public string CustomerEmail { get; set; }
public OrderStatus OrderStatus { get; set; }
public Product[] Products { get; set; }
}

public class Product
{
public string Name { get; set; }
}

public enum OrderStatus
{
Accepted,
Processing,
Complete
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using FluentValidation;

namespace ClassLibrary1
{
public class OrderValidator : AbstractValidator<Order>
{
public OrderValidator()
{
RuleFor(model => model.CustomerName).FullName();
RuleFor(model => model.CustomerEmail).Cascade(CascadeMode.Stop).EmailAddress().MinimumLength(20);
RuleFor(model => model.Price).InclusiveBetween(1, 1000);
RuleFor(model => model.OrderStatus).IsInEnum();
RuleForEach(model => model.Products).SetValidator(new ProductValidator());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using FluentValidation;

namespace ClassLibrary1
{
public class ProductValidator : AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(model => model.Name).NotEmpty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassLibrary1", "ClassLibrary1\ClassLibrary1.csproj", "{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApplication1", "WebApplication1\WebApplication1.csproj", "{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{A5C5A8F0-33DE-422D-B41D-811514D33353}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Debug|x64.ActiveCfg = Debug|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Debug|x64.Build.0 = Debug|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Debug|x86.ActiveCfg = Debug|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Debug|x86.Build.0 = Debug|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Release|Any CPU.Build.0 = Release|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Release|x64.ActiveCfg = Release|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Release|x64.Build.0 = Release|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Release|x86.ActiveCfg = Release|Any CPU
{D53ED5E5-4F4D-4B88-8D63-B1A11CC83475}.Release|x86.Build.0 = Release|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Debug|x64.ActiveCfg = Debug|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Debug|x64.Build.0 = Debug|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Debug|x86.ActiveCfg = Debug|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Debug|x86.Build.0 = Debug|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Release|Any CPU.Build.0 = Release|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Release|x64.ActiveCfg = Release|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Release|x64.Build.0 = Release|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Release|x86.ActiveCfg = Release|Any CPU
{209EDB45-BFE8-4F1B-A0B9-9EF30A3D4D6D}.Release|x86.Build.0 = Release|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Debug|x64.Build.0 = Debug|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Debug|x86.Build.0 = Debug|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Release|Any CPU.Build.0 = Release|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Release|x64.ActiveCfg = Release|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Release|x64.Build.0 = Release|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Release|x86.ActiveCfg = Release|Any CPU
{A5C5A8F0-33DE-422D-B41D-811514D33353}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
31 changes: 31 additions & 0 deletions dotnet-client-libraries/FluentValidationValidators/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## FluentValidation Validators in C#: Built-in and Custom

Source code for [FluentValidation Validators in C#: Built-in and Custom](https://code-maze.com/deep-dive-validators-fluentvalidation/).

| Folder | What it is |
| - | - |
| `ClassLibrary1` | The validators themselves — `Order`, `Product`, `OrderValidator`, `ProductValidator`, and the `FullName()` custom validator written as an extension method on `IRuleBuilder<T, string>`. |
| `WebApplication1` | An ASP.NET Core API that references the class library, registers every validator with `AddValidatorsFromAssemblyContaining<OrderValidator>()`, and invokes `IValidator<Order>` explicitly in `OrdersController`. |
| `Tests` | Tests over the validators: the custom `FullName()` message, `CascadeMode.Stop` reporting one failure instead of two, `IsInEnum()` rejecting an undeclared member, `RuleForEach()` reporting the item index, and the exact strings `EmailAddress()` does and does not accept. |

Everything targets .NET 10 and FluentValidation 12.

```
dotnet build FluentValidationValidators.sln
dotnet test FluentValidationValidators.sln
```

### A note on `FluentValidation.AspNetCore`

The API project does **not** reference `FluentValidation.AspNetCore`, and this is
deliberate on two counts.

The package stops at **11.3.1** — there is no 12.x — so it cannot be paired with
FluentValidation 12.1.1. And the automatic-validation pipeline it provides is the
approach FluentValidation's own ASP.NET Core documentation says it "no longer
recommend[s] ... for new projects", while still supporting it for legacy code.

The current path is the one this sample uses: register the validators with
`AddValidatorsFromAssemblyContaining()` from
`FluentValidation.DependencyInjectionExtensions`, inject `IValidator<T>`, and call
`ValidateAsync()` where the validation belongs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using FluentValidation;

namespace Tests;

/// <summary>
/// These tests pin the two library behaviours the article states as facts, so that a package
/// bump that changes either of them fails CI instead of silently making the article wrong.
/// </summary>
[TestClass]
public class BuiltInValidatorBehaviourTests
{
private class EmailHolder
{
public string Email { get; set; }
}

private class EmailHolderValidator : AbstractValidator<EmailHolder>
{
public EmailHolderValidator() => RuleFor(model => model.Email).EmailAddress();
}

private static bool IsAcceptedAsEmail(string value)
=> new EmailHolderValidator().Validate(new EmailHolder { Email = value }).IsValid;

[TestMethod]
[DataRow("joebloggs@someemaildomain.com")]
[DataRow("a@b")]
[DataRow("has space@x.com")]
public void EmailAddressValidator_AcceptsAnythingWithASingleAtSign(string value)
{
Assert.IsTrue(IsAcceptedAsEmail(value));
}

[TestMethod]
[DataRow("not-an-email")]
[DataRow("plain")]
[DataRow("a@b@c")]
public void EmailAddressValidator_RejectsValuesWithoutExactlyOneAtSign(string value)
{
Assert.IsFalse(IsAcceptedAsEmail(value));
}

[TestMethod]
public void CascadeMode_DeclaresOnlyContinueAndStop()
{
CollectionAssert.AreEquivalent(
new[] { "Continue", "Stop" },
Enum.GetNames<CascadeMode>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using ClassLibrary1;
using FluentValidation;

namespace Tests;

[TestClass]
public class OrderValidatorTests
{
private readonly OrderValidator _validator = new();

private static Order ValidOrder() => new()
{
CustomerName = "Joe Bloggsworth",
CustomerEmail = "joebloggs@someemaildomain.com",
Price = 100,
OrderStatus = OrderStatus.Accepted,
Products = [new Product { Name = "Keyboard" }]
};

[TestMethod]
public void WhenOrderIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(ValidOrder());

Assert.IsTrue(result.IsValid);
Assert.AreEqual(0, result.Errors.Count);
}

[TestMethod]
public void WhenCustomerNameHasNoSpace_ThenFullNameValidatorReportsItsOwnMessage()
{
var order = ValidOrder();
order.CustomerName = "JoeBloggsworth";

var result = _validator.Validate(order);

Assert.IsFalse(result.IsValid);
var failure = result.Errors.Single(e => e.PropertyName == nameof(Order.CustomerName));
Assert.AreEqual("Name must contain a single space and be at least 10 characters long", failure.ErrorMessage);
}

[TestMethod]
public void WhenCascadeModeIsStop_ThenOnlyTheFirstEmailFailureIsReported()
{
var order = ValidOrder();
order.CustomerEmail = "AAAAA";

var result = _validator.Validate(order);

var emailFailures = result.Errors.Where(e => e.PropertyName == nameof(Order.CustomerEmail)).ToList();
Assert.AreEqual(1, emailFailures.Count);
StringAssert.Contains(emailFailures[0].ErrorMessage, "valid email address");
}

[TestMethod]
public void WhenOrderStatusIsNotADeclaredMember_ThenIsInEnumRejectsIt()
{
var order = ValidOrder();
order.OrderStatus = (OrderStatus)42;

var result = _validator.Validate(order);

Assert.IsFalse(result.IsValid);
Assert.IsTrue(result.Errors.Any(e => e.PropertyName == nameof(Order.OrderStatus)));
}

[TestMethod]
public void WhenOneProductIsInvalid_ThenRuleForEachReportsTheItemIndex()
{
var order = ValidOrder();
order.Products = [new Product { Name = "Keyboard" }, new Product { Name = string.Empty }];

var result = _validator.Validate(order);

Assert.IsFalse(result.IsValid);
Assert.AreEqual("Products[1].Name", result.Errors.Single().PropertyName);
}

[TestMethod]
public void WhenValidationFails_ThenValidateAndThrowThrowsValidationException()
{
var order = ValidOrder();
order.Price = 5000;

Assert.ThrowsExactly<ValidationException>(() => _validator.ValidateAndThrow(order));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="MSTest.TestAdapter" Version="4.3.3" />
<PackageReference Include="MSTest.TestFramework" Version="4.3.3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ClassLibrary1\ClassLibrary1.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using ClassLibrary1;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class OrdersController : ControllerBase
{
private readonly IValidator<Order> _validator;

public OrdersController(IValidator<Order> validator)
{
_validator = validator;
}

[HttpPost]
public async Task<ActionResult> Post([FromBody] Order order)
{
var validationResult = await _validator.ValidateAsync(order);

if (!validationResult.IsValid)
{
foreach (var error in validationResult.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}

return ValidationProblem(ModelState);
}

return Ok("Success!");
}
}
}
Loading
Loading