Skip to content

Commit 17ccd5e

Browse files
committed
feat(controllers): enforce input validation with FluentValidation
1 parent e226427 commit 17ccd5e

File tree

10 files changed

+297
-100
lines changed

10 files changed

+297
-100
lines changed

src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
using System.Net.Mime;
22
using Dotnet.Samples.AspNetCore.WebApi.Models;
33
using Dotnet.Samples.AspNetCore.WebApi.Services;
4+
using FluentValidation;
45
using Microsoft.AspNetCore.Mvc;
56

67
namespace Dotnet.Samples.AspNetCore.WebApi.Controllers;
78

89
[ApiController]
910
[Route("[controller]")]
1011
[Produces("application/json")]
11-
public class PlayerController(IPlayerService playerService, ILogger<PlayerController> logger)
12-
: ControllerBase
12+
public class PlayerController(
13+
IPlayerService playerService,
14+
ILogger<PlayerController> logger,
15+
IValidator<PlayerRequestModel> validator
16+
) : ControllerBase
1317
{
1418
private readonly IPlayerService _playerService = playerService;
1519
private readonly ILogger<PlayerController> _logger = logger;
20+
private readonly IValidator<PlayerRequestModel> validator = validator;
1621

1722
/* -------------------------------------------------------------------------
1823
* HTTP POST
@@ -32,23 +37,30 @@ public class PlayerController(IPlayerService playerService, ILogger<PlayerContro
3237
[ProducesResponseType(StatusCodes.Status409Conflict)]
3338
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
3439
{
35-
if (!ModelState.IsValid)
40+
var validation = await validator.ValidateAsync(player);
41+
42+
if (!validation.IsValid)
3643
{
37-
return TypedResults.BadRequest();
44+
var errors = validation
45+
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
46+
.ToArray();
47+
48+
_logger.LogWarning("POST validation failed: {@Errors}", errors);
49+
return TypedResults.BadRequest(errors);
3850
}
39-
else if (await _playerService.RetrieveByIdAsync(player.Id) != null)
51+
52+
if (await _playerService.RetrieveByIdAsync(player.Id) != null)
4053
{
4154
return TypedResults.Conflict();
4255
}
43-
else
44-
{
45-
var result = await _playerService.CreateAsync(player);
46-
return TypedResults.CreatedAtRoute(
47-
routeName: "GetById",
48-
routeValues: new { id = result.Id },
49-
value: result
50-
);
51-
}
56+
57+
var result = await _playerService.CreateAsync(player);
58+
59+
return TypedResults.CreatedAtRoute(
60+
routeName: "GetById",
61+
routeValues: new { id = result.Id },
62+
value: result
63+
);
5264
}
5365

5466
/* -------------------------------------------------------------------------
@@ -142,20 +154,26 @@ public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
142154
[ProducesResponseType(StatusCodes.Status404NotFound)]
143155
public async Task<IResult> PutAsync([FromRoute] long id, [FromBody] PlayerRequestModel player)
144156
{
145-
if (!ModelState.IsValid)
157+
var validation = await validator.ValidateAsync(player);
158+
159+
if (!validation.IsValid)
146160
{
147-
return TypedResults.BadRequest();
161+
var errors = validation
162+
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
163+
.ToArray();
164+
165+
_logger.LogWarning("PUT /players/{Id} validation failed: {@Errors}", id, errors);
166+
return TypedResults.BadRequest(errors);
148167
}
149-
else if (await _playerService.RetrieveByIdAsync(id) == null)
168+
169+
if (await _playerService.RetrieveByIdAsync(id) == null)
150170
{
151171
return TypedResults.NotFound();
152172
}
153-
else
154-
{
155-
await _playerService.UpdateAsync(player);
156173

157-
return TypedResults.NoContent();
158-
}
174+
await _playerService.UpdateAsync(player);
175+
176+
return TypedResults.NoContent();
159177
}
160178

161179
/* -------------------------------------------------------------------------

src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="AutoMapper" Version="14.0.0" />
12+
<PackageReference Include="FluentValidation" Version="11.11.0" />
1213
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.3" />
1314
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
1415
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">

src/Dotnet.Samples.AspNetCore.WebApi/Enums/Position.cs

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,47 @@ public class Position : Enumeration
1717
public static readonly Position LeftWinger = new(11, "Left Winger", "LW");
1818

1919
private Position(int id, string name, string abbr)
20-
: base(id, name)
21-
{
22-
Abbr = abbr;
23-
}
20+
: base(id, name) => Abbr = abbr;
2421

25-
public static Position? FromAbbr(string abbr)
26-
{
27-
return GetAll<Position>().FirstOrDefault(position => position.Abbr == abbr);
28-
}
22+
/// <summary>
23+
/// Returns a Position object based on the abbreviation.
24+
/// </summary>
25+
/// <remarks>
26+
/// This method searches through all the Position objects and returns the one
27+
/// that matches the provided abbreviation. If no match is found, it returns null.
28+
/// </remarks>
29+
/// <param name="abbr">The abbreviation of the Position.</param>
30+
/// <returns>
31+
/// A Position object if found; otherwise, null.
32+
/// </returns>
33+
public static Position? FromAbbr(string abbr) =>
34+
GetAll<Position>().FirstOrDefault(position => position.Abbr == abbr);
2935

30-
public static Position? FromId(int id)
31-
{
32-
return GetAll<Position>().FirstOrDefault(position => position.Id == id);
33-
}
36+
/// <summary>
37+
/// Returns a Position object based on the ID.
38+
/// </summary>
39+
/// <remarks>
40+
/// This method searches through all the Position objects and returns the one
41+
/// that matches the provided ID. If no match is found, it returns null.
42+
/// </remarks>
43+
/// <param name="id">The ID of the Position.</param>
44+
/// <returns>
45+
/// A Position object if found; otherwise, null.
46+
/// </returns>
47+
public static Position? FromId(int id) =>
48+
GetAll<Position>().FirstOrDefault(position => position.Id == id);
49+
50+
/// <summary>
51+
/// Checks if the provided abbreviation is valid.
52+
/// </summary>
53+
/// <remarks>
54+
/// This method checks if the provided abbreviation is not null or empty and
55+
/// if it corresponds to a valid Position object.
56+
/// </remarks>
57+
/// <param name="abbr">The abbreviation to check.</param>
58+
/// <returns>
59+
/// True if the abbreviation is valid; otherwise, false.
60+
/// </returns>
61+
public static bool IsValidAbbr(string? abbr) =>
62+
!string.IsNullOrWhiteSpace(abbr) && FromAbbr(abbr!) is not null;
3463
}

src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerRequestModel.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,16 @@ public class PlayerRequestModel
1414
{
1515
public long Id { get; set; }
1616

17-
[Required]
1817
public string? FirstName { get; set; }
1918

2019
public string? MiddleName { get; set; }
2120

22-
[Required]
2321
public string? LastName { get; set; }
2422

2523
public DateTime? DateOfBirth { get; set; }
2624

27-
[Required]
2825
public int SquadNumber { get; set; }
2926

30-
[Required]
3127
public string? AbbrPosition { get; set; }
3228

3329
public string? Team { get; set; }

src/Dotnet.Samples.AspNetCore.WebApi/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
using System.Reflection;
22
using Dotnet.Samples.AspNetCore.WebApi.Data;
33
using Dotnet.Samples.AspNetCore.WebApi.Mappings;
4+
using Dotnet.Samples.AspNetCore.WebApi.Models;
45
using Dotnet.Samples.AspNetCore.WebApi.Services;
56
using Dotnet.Samples.AspNetCore.WebApi.Utilities;
7+
using Dotnet.Samples.AspNetCore.WebApi.Validators;
8+
using FluentValidation;
69
using Microsoft.EntityFrameworkCore;
710
using Microsoft.OpenApi.Models;
811
using Serilog;
@@ -44,6 +47,7 @@
4447
builder.Services.AddScoped<IPlayerService, PlayerService>();
4548
builder.Services.AddMemoryCache();
4649
builder.Services.AddAutoMapper(typeof(PlayerMappingProfile));
50+
builder.Services.AddScoped<IValidator<PlayerRequestModel>, PlayerRequestModelValidator>();
4751

4852
if (builder.Environment.IsDevelopment())
4953
{
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Dotnet.Samples.AspNetCore.WebApi.Enums;
2+
using Dotnet.Samples.AspNetCore.WebApi.Models;
3+
using FluentValidation;
4+
5+
namespace Dotnet.Samples.AspNetCore.WebApi.Validators;
6+
7+
public class PlayerRequestModelValidator : AbstractValidator<PlayerRequestModel>
8+
{
9+
public PlayerRequestModelValidator()
10+
{
11+
RuleFor(x => x.FirstName).NotEmpty().WithMessage("FirstName is required.");
12+
13+
RuleFor(x => x.LastName).NotEmpty().WithMessage("LastName is required.");
14+
15+
RuleFor(x => x.SquadNumber)
16+
.NotEmpty()
17+
.WithMessage("SquadNumber is required.")
18+
.GreaterThan(0)
19+
.WithMessage("SquadNumber must be greater than 0.");
20+
21+
RuleFor(x => x.AbbrPosition)
22+
.NotEmpty()
23+
.WithMessage("AbbrPosition is required.")
24+
.Must(Position.IsValidAbbr)
25+
.WithMessage("AbbrPosition is invalid.");
26+
}
27+
}

src/Dotnet.Samples.AspNetCore.WebApi/packages.lock.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
"Microsoft.Extensions.Options": "8.0.0"
1212
}
1313
},
14+
"FluentValidation": {
15+
"type": "Direct",
16+
"requested": "[11.11.0, )",
17+
"resolved": "11.11.0",
18+
"contentHash": "cyIVdQBwSipxWG8MA3Rqox7iNbUNUTK5bfJi9tIdm4CAfH71Oo5ABLP4/QyrUwuakqpUEPGtE43BDddvEehuYw=="
19+
},
1420
"Microsoft.AspNetCore.OpenApi": {
1521
"type": "Direct",
1622
"requested": "[8.0.14, )",

0 commit comments

Comments
 (0)