Skip to content

Commit 0c91c0b

Browse files
authored
Merge branch 'master' into dependabot/nuget/test/Dotnet.Samples.AspNetCore.WebApi.Tests/coverlet.collector-8.0.1
2 parents 320fb36 + 07e76fb commit 0c91c0b

6 files changed

Lines changed: 177 additions & 111 deletions

File tree

.github/workflows/dotnet-cd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ jobs:
136136
} >> $GITHUB_OUTPUT
137137
138138
- name: Create GitHub Release
139-
uses: softprops/action-gh-release@v2.6.0
139+
uses: softprops/action-gh-release@v2.6.1
140140
with:
141141
name: "v${{ steps.version.outputs.semver }} - ${{ steps.version.outputs.stadium }} 🏟️"
142142
tag_name: ${{ steps.version.outputs.tag_name }}

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,10 @@ Interactive API documentation is available via Swagger UI at `https://localhost:
218218

219219
- `GET /players` - List all players
220220
- `GET /players/{id:Guid}` - Get player by ID (requires authentication)
221-
- `GET /players/{squadNumber:int}` - Get player by squad number
221+
- `GET /players/squadNumber/{squadNumber:int}` - Get player by squad number
222222
- `POST /players` - Create new player
223-
- `PUT /players/{squadNumber}` - Update player
224-
- `DELETE /players/{squadNumber}` - Remove player
223+
- `PUT /players/squadNumber/{squadNumber:int}` - Update player
224+
- `DELETE /players/squadNumber/{squadNumber:int}` - Remove player
225225
- `GET /health` - Health check
226226

227227
For complete endpoint documentation with request/response schemas, explore the [interactive Swagger UI](https://localhost:9000/swagger/index.html).

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

Lines changed: 98 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class PlayerController(
1616
IValidator<PlayerRequestModel> validator
1717
) : ControllerBase
1818
{
19+
private const string NotFoundTitle = "Not Found";
20+
1921
/* -------------------------------------------------------------------------
2022
* HTTP POST
2123
* ---------------------------------------------------------------------- */
@@ -30,20 +32,24 @@ IValidator<PlayerRequestModel> validator
3032
[HttpPost(Name = "Create")]
3133
[Consumes(MediaTypeNames.Application.Json)]
3234
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status201Created)]
33-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
34-
[ProducesResponseType(StatusCodes.Status409Conflict)]
35+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
36+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
3537
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
3638
{
3739
var validation = await validator.ValidateAsync(player);
3840

3941
if (!validation.IsValid)
4042
{
4143
var errors = validation
42-
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
43-
.ToArray();
44+
.Errors.GroupBy(e => e.PropertyName)
45+
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
4446

4547
logger.LogWarning("POST /players validation failed: {@Errors}", errors);
46-
return TypedResults.BadRequest(errors);
48+
return TypedResults.ValidationProblem(
49+
errors,
50+
detail: "See the errors field for details.",
51+
instance: HttpContext?.Request?.Path.ToString()
52+
);
4753
}
4854

4955
if (await playerService.RetrieveBySquadNumberAsync(player.SquadNumber) != null)
@@ -52,7 +58,16 @@ public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
5258
"POST /players failed: Player with Squad Number {SquadNumber} already exists",
5359
player.SquadNumber
5460
);
55-
return TypedResults.Conflict();
61+
return TypedResults.Conflict(
62+
new ProblemDetails
63+
{
64+
Type = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409",
65+
Title = "Conflict",
66+
Status = StatusCodes.Status409Conflict,
67+
Detail = $"Player with Squad Number '{player.SquadNumber}' already exists.",
68+
Instance = HttpContext?.Request?.Path.ToString()
69+
}
70+
);
5671
}
5772

5873
var result = await playerService.CreateAsync(player);
@@ -76,7 +91,7 @@ public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
7691
/// <response code="404">Not Found</response>
7792
[HttpGet(Name = "Retrieve")]
7893
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
79-
[ProducesResponseType(StatusCodes.Status404NotFound)]
94+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
8095
public async Task<IResult> GetAsync()
8196
{
8297
var players = await playerService.RetrieveAsync();
@@ -89,7 +104,12 @@ public async Task<IResult> GetAsync()
89104
else
90105
{
91106
logger.LogWarning("GET /players not found");
92-
return TypedResults.NotFound();
107+
return TypedResults.Problem(
108+
statusCode: StatusCodes.Status404NotFound,
109+
title: NotFoundTitle,
110+
detail: "No players were found.",
111+
instance: HttpContext?.Request?.Path.ToString()
112+
);
93113
}
94114
}
95115

@@ -102,7 +122,7 @@ public async Task<IResult> GetAsync()
102122
[Authorize]
103123
[HttpGet("{id:Guid}", Name = "RetrieveById")]
104124
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
105-
[ProducesResponseType(StatusCodes.Status404NotFound)]
125+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
106126
public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
107127
{
108128
var player = await playerService.RetrieveByIdAsync(id);
@@ -114,7 +134,12 @@ public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
114134
else
115135
{
116136
logger.LogWarning("GET /players/{Id} not found", id);
117-
return TypedResults.NotFound();
137+
return TypedResults.Problem(
138+
statusCode: StatusCodes.Status404NotFound,
139+
title: NotFoundTitle,
140+
detail: $"Player with Id '{id}' was not found.",
141+
instance: HttpContext?.Request?.Path.ToString()
142+
);
118143
}
119144
}
120145

@@ -124,25 +149,30 @@ public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
124149
/// <param name="squadNumber">The Squad Number of the Player</param>
125150
/// <response code="200">OK</response>
126151
/// <response code="404">Not Found</response>
127-
[HttpGet("{squadNumber:int}", Name = "RetrieveBySquadNumber")]
152+
[HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")]
128153
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
129-
[ProducesResponseType(StatusCodes.Status404NotFound)]
154+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
130155
public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
131156
{
132157
var player = await playerService.RetrieveBySquadNumberAsync(squadNumber);
133158
if (player != null)
134159
{
135160
logger.LogInformation(
136-
"GET /players/{SquadNumber} retrieved: {@Player}",
161+
"GET /players/squadNumber/{SquadNumber} retrieved: {@Player}",
137162
squadNumber,
138163
player
139164
);
140165
return TypedResults.Ok(player);
141166
}
142167
else
143168
{
144-
logger.LogWarning("GET /players/{SquadNumber} not found", squadNumber);
145-
return TypedResults.NotFound();
169+
logger.LogWarning("GET /players/squadNumber/{SquadNumber} not found", squadNumber);
170+
return TypedResults.Problem(
171+
statusCode: StatusCodes.Status404NotFound,
172+
title: NotFoundTitle,
173+
detail: $"Player with Squad Number '{squadNumber}' was not found.",
174+
instance: HttpContext?.Request?.Path.ToString()
175+
);
146176
}
147177
}
148178

@@ -159,11 +189,11 @@ public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
159189
/// <response code="204">No Content</response>
160190
/// <response code="400">Bad Request</response>
161191
/// <response code="404">Not Found</response>
162-
[HttpPut("{squadNumber:int}", Name = "Update")]
192+
[HttpPut("squadNumber/{squadNumber:int}", Name = "Update")]
163193
[Consumes(MediaTypeNames.Application.Json)]
164194
[ProducesResponseType(StatusCodes.Status204NoContent)]
165-
[ProducesResponseType(StatusCodes.Status400BadRequest)]
166-
[ProducesResponseType(StatusCodes.Status404NotFound)]
195+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
196+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
167197
public async Task<IResult> PutAsync(
168198
[FromRoute] int squadNumber,
169199
[FromBody] PlayerRequestModel player
@@ -173,24 +203,56 @@ [FromBody] PlayerRequestModel player
173203
if (!validation.IsValid)
174204
{
175205
var errors = validation
176-
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
177-
.ToArray();
206+
.Errors.GroupBy(e => e.PropertyName)
207+
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
178208

179209
logger.LogWarning(
180-
"PUT /players/{SquadNumber} validation failed: {@Errors}",
210+
"PUT /players/squadNumber/{SquadNumber} validation failed: {@Errors}",
181211
squadNumber,
182212
errors
183213
);
184-
return TypedResults.BadRequest(errors);
214+
return TypedResults.ValidationProblem(
215+
errors,
216+
detail: "See the errors field for details.",
217+
instance: HttpContext?.Request?.Path.ToString()
218+
);
219+
}
220+
if (player.SquadNumber != squadNumber)
221+
{
222+
logger.LogWarning(
223+
"PutAsync squad number mismatch: route {SquadNumber} != body {PlayerSquadNumber}",
224+
squadNumber,
225+
player.SquadNumber
226+
);
227+
return TypedResults.Problem(
228+
statusCode: StatusCodes.Status400BadRequest,
229+
title: "Bad Request",
230+
detail: "Squad number in the route does not match squad number in the request body.",
231+
instance: HttpContext?.Request?.Path.ToString()
232+
);
185233
}
186234
if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null)
187235
{
188-
logger.LogWarning("PUT /players/{SquadNumber} not found", squadNumber);
189-
return TypedResults.NotFound();
236+
logger.LogWarning("PUT /players/squadNumber/{SquadNumber} not found", squadNumber);
237+
return TypedResults.Problem(
238+
statusCode: StatusCodes.Status404NotFound,
239+
title: NotFoundTitle,
240+
detail: $"Player with Squad Number '{squadNumber}' was not found.",
241+
instance: HttpContext?.Request?.Path.ToString()
242+
);
190243
}
191244
await playerService.UpdateAsync(player);
192-
// codeql[cs/log-forging] Serilog structured logging with @ destructuring automatically escapes control characters
193-
logger.LogInformation("PUT /players/{SquadNumber} updated: {@Player}", squadNumber, player);
245+
// Sanitize user-provided player data before logging to prevent log forging
246+
var sanitizedPlayerString = player
247+
.ToString()
248+
?.Replace(Environment.NewLine, string.Empty)
249+
.Replace("\r", string.Empty)
250+
.Replace("\n", string.Empty);
251+
logger.LogInformation(
252+
"PUT /players/squadNumber/{SquadNumber} updated: {Player}",
253+
squadNumber,
254+
sanitizedPlayerString
255+
);
194256
return TypedResults.NoContent();
195257
}
196258

@@ -204,20 +266,25 @@ [FromBody] PlayerRequestModel player
204266
/// <param name="squadNumber">The Squad Number of the Player</param>
205267
/// <response code="204">No Content</response>
206268
/// <response code="404">Not Found</response>
207-
[HttpDelete("{squadNumber:int}", Name = "Delete")]
269+
[HttpDelete("squadNumber/{squadNumber:int}", Name = "Delete")]
208270
[ProducesResponseType(StatusCodes.Status204NoContent)]
209-
[ProducesResponseType(StatusCodes.Status404NotFound)]
271+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
210272
public async Task<IResult> DeleteAsync([FromRoute] int squadNumber)
211273
{
212274
if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null)
213275
{
214-
logger.LogWarning("DELETE /players/{SquadNumber} not found", squadNumber);
215-
return TypedResults.NotFound();
276+
logger.LogWarning("DELETE /players/squadNumber/{SquadNumber} not found", squadNumber);
277+
return TypedResults.Problem(
278+
statusCode: StatusCodes.Status404NotFound,
279+
title: "Not Found",
280+
detail: $"Player with Squad Number '{squadNumber}' was not found.",
281+
instance: HttpContext?.Request?.Path.ToString()
282+
);
216283
}
217284
else
218285
{
219286
await playerService.DeleteAsync(squadNumber);
220-
logger.LogInformation("DELETE /players/{SquadNumber} deleted", squadNumber);
287+
logger.LogInformation("DELETE /players/squadNumber/{SquadNumber} deleted", squadNumber);
221288
return TypedResults.NoContent();
222289
}
223290
}

test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<ItemGroup Label="Test dependencies">
1212
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" PrivateAssets="all" />
1313
<PackageReference Include="Moq" Version="4.20.72" PrivateAssets="all" />
14-
<PackageReference Include="FluentAssertions" Version="8.8.0" PrivateAssets="all" />
14+
<PackageReference Include="FluentAssertions" Version="8.9.0" PrivateAssets="all" />
1515
<PackageReference Include="xunit" Version="2.9.3" PrivateAssets="all" />
1616
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
1717
<PackageReference Include="coverlet.collector" Version="8.0.1" PrivateAssets="all" />

0 commit comments

Comments
 (0)