Skip to content

Commit 95d1292

Browse files
authored
Merge pull request #203 from nanotaboada/feature/fluentvalidation
feature/fluentvalidation
2 parents 2a68b49 + d20e2de commit 95d1292

File tree

18 files changed

+397
-164
lines changed

18 files changed

+397
-164
lines changed

.codacy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ exclude_paths:
2828
- '**/Models/**' # Domain and DTO models
2929
- '**/Properties/**' # launchSettings.json or AssemblyInfo.cs
3030
- '**/Utilities/**' # Helper extensions or static classes
31+
- '**/Validators/**' # FluentValidation validators
3132
- 'test/**/*' # Entire test suite (unit + integration)

.github/workflows/dotnet.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
uses: actions/checkout@v4
4747

4848
- name: Run tests and generate Cobertura coverage reports
49-
run: dotnet test --results-directory "coverage" --collect:"Code Coverage;Format=cobertura" --settings .runsettings
49+
run: dotnet test --results-directory "coverage" --collect:"XPlat Code Coverage" --settings .runsettings
5050

5151
- name: Install dotnet-coverage tool
5252
run: dotnet tool install --global dotnet-coverage

.runsettings

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
<RunSettings>
33
<DataCollectionRunSettings>
44
<DataCollectors>
5-
<DataCollector friendlyName="Code Coverage" uri="datacollector://Microsoft/CodeCoverage/2.0">
5+
<DataCollector friendlyName="XPlat Code Coverage">
66
<Configuration>
7+
<Format>cobertura</Format>
8+
<SkipAutoProps>true</SkipAutoProps>
9+
<BasePath>$(MSBuildProjectDirectory)</BasePath>
710
<CodeCoverage>
8-
<!-- Exclude all test projects -->
911
<ModulePaths>
12+
<!-- Exclude all test projects -->
1013
<Exclude>
1114
<ModulePath>.*\.Tests\.dll$</ModulePath>
1215
</Exclude>

.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@
99
"[csharp]": {
1010
"editor.formatOnSave": true,
1111
"editor.defaultFormatter": "csharpier.csharpier-vscode"
12+
},
13+
"sonarlint.connectedMode.project": {
14+
"connectionId": "nanotaboada",
15+
"projectKey": "nanotaboada_Dotnet.Samples.AspNetCore.WebApi"
1216
}
1317
}

codecov.yml

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,33 @@
1-
# Codecov repository YAML
1+
2+
# Codecov Repository YAML
23
# https://docs.codecov.com/docs/codecov-yaml
3-
coverage:
44

5-
# https://docs.codecov.com/docs/commit-status
5+
coverage:
6+
# https://docs.codecov.com/docs/commit-status
67
status:
7-
88
project:
99
default:
10-
target: 80% # Default target for all components
11-
threshold: 10% # Allowable drop in coverage without failing
12-
if_not_found: success # If no coverage report is found, don't fail
13-
if_ci_failed: error # CI failure should fail coverage check
14-
15-
patch:
16-
default:
17-
target: 80% # Target for changed lines
18-
threshold: 10% # Allowable drop in coverage without failing
10+
target: 80%
11+
threshold: 100%
12+
if_not_found: success
13+
if_ci_failed: success
14+
patch:
15+
default:
16+
target: 0%
17+
threshold: 100%
18+
if_not_found: success
1919

20+
# https://docs.codecov.com/docs/components#component-options
2021
component_management:
21-
22-
default_rules: # default rules that will be inherited by all components
22+
default_rules:
2323
statuses:
24-
- type: project # components that don't have a status defined will have a project type one
24+
- type: project
2525
target: auto
2626
branches:
2727
- "!main"
28-
2928
individual_components:
30-
- component_id: controllers # this is an identifier that should not be changed
31-
name: Controllers # this is a display name, and can be changed freely
29+
- component_id: controllers
30+
name: Controllers
3231
paths:
3332
- 'src/Dotnet.Samples.AspNetCore.WebApi/Controllers/'
3433
- component_id: services
@@ -38,28 +37,28 @@ component_management:
3837

3938
comment:
4039
layout: "header, diff, flags, components"
40+
behavior: default
41+
require_changes: false
4142

4243
# https://docs.codecov.com/docs/ignoring-paths
4344
ignore:
44-
# Ignoring specific file yypes
45-
- '**/*.sln' # Solution files
46-
- '**/*.csproj' # C# project files
47-
- '**/*.json' # JSON config files (e.g., appsettings)
48-
- '**/*.yml' # YAML config files (e.g., pipelines)
49-
- '**/*.png' # Image assets (e.g., Swagger diagram)
45+
- .*\.sln
46+
- .*\.csproj
47+
- .*\.json
48+
- .*\.yml
49+
- .*\.png
50+
- '**/*.md'
5051

51-
# Ignoring a specific folder
52-
- 'src/**/Data/**/*' # Repositories, DbContext, database files
53-
- 'src/**/Enums/**/*' # Enums like Position
54-
- 'src/**/Mappings/**/*' # AutoMapper profiles
55-
- 'src/**/Migrations/**/*' # EF Core migration artifacts
56-
- 'src/**/Models/**/*' # Domain and DTO models
57-
- 'src/**/Properties/**/*' # launchSettings.json or other system files
58-
- 'src/**/Utilities/**/*' # Static helper and extension classes
59-
- 'test' # Any file in the test folder (unit/integration/utils)
52+
- .*\/test\/.*
53+
- .*\/Program\.cs
54+
- '**/LICENSE'
55+
- '**/README.md'
6056

61-
# Ignoring Specific Files At All Depths
62-
- '**/Program.cs' # ASP.NET Core entry point
63-
- '**/.gitignore' # Git ignore file
64-
- '**/LICENSE' # License text
65-
- '**/README.md' # Project readme
57+
- .*\/Data\/.*
58+
- .*\/Enums\/.*
59+
- .*\/Mappings\/.*
60+
- .*\/Migrations\/.*
61+
- .*\/Models\/.*
62+
- .*\/Properties\/.*
63+
- .*\/Utilities\/.*
64+
- .*\/Validators\/.*

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: 10 additions & 22 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,34 +47,19 @@
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
{
5054
builder.Services.AddSwaggerGen(options =>
5155
{
52-
options.SwaggerDoc(
53-
"v1",
54-
new OpenApiInfo
55-
{
56-
Version = "1.0.0",
57-
Title = "Dotnet.Samples.AspNetCore.WebApi",
58-
Description =
59-
"🧪 Proof of Concept for a Web API (Async) made with .NET 8 (LTS) and ASP.NET Core 8.0",
60-
Contact = new OpenApiContact
61-
{
62-
Name = "GitHub",
63-
Url = new Uri("https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi")
64-
},
65-
License = new OpenApiLicense
66-
{
67-
Name = "MIT License",
68-
Url = new Uri("https://opensource.org/license/mit")
69-
}
70-
}
56+
options.SwaggerDoc("v1", builder.Configuration.GetSection("SwaggerDoc").Get<OpenApiInfo>());
57+
options.IncludeXmlComments(
58+
Path.Combine(
59+
AppContext.BaseDirectory,
60+
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml"
61+
)
7162
);
72-
73-
var filePath = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
74-
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, filePath));
7563
});
7664
}
7765

0 commit comments

Comments
 (0)