Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,10 @@ Interactive API documentation is available via Swagger UI at `https://localhost:

- `GET /players` - List all players
- `GET /players/{id:Guid}` - Get player by ID (requires authentication)
- `GET /players/{squadNumber:int}` - Get player by squad number
- `GET /players/squadNumber/{squadNumber:int}` - Get player by squad number
- `POST /players` - Create new player
- `PUT /players/{squadNumber}` - Update player
- `DELETE /players/{squadNumber}` - Remove player
- `PUT /players/squadNumber/{squadNumber:int}` - Update player
- `DELETE /players/squadNumber/{squadNumber:int}` - Remove player
- `GET /health` - Health check

For complete endpoint documentation with request/response schemas, explore the [interactive Swagger UI](https://localhost:9000/swagger/index.html).
Expand Down
127 changes: 96 additions & 31 deletions src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,24 @@
[HttpPost(Name = "Create")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
{
var validation = await validator.ValidateAsync(player);

if (!validation.IsValid)
{
var errors = validation
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
.ToArray();
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

logger.LogWarning("POST /players validation failed: {@Errors}", errors);
return TypedResults.BadRequest(errors);
return TypedResults.ValidationProblem(
errors,
detail: "See the errors field for details.",
instance: HttpContext?.Request?.Path.ToString()
);
}

if (await playerService.RetrieveBySquadNumberAsync(player.SquadNumber) != null)
Expand All @@ -52,7 +56,16 @@
"POST /players failed: Player with Squad Number {SquadNumber} already exists",
player.SquadNumber
);
return TypedResults.Conflict();
return TypedResults.Conflict(
new ProblemDetails
{
Type = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = $"Player with Squad Number '{player.SquadNumber}' already exists.",
Instance = HttpContext?.Request?.Path.ToString()
}
);
}

var result = await playerService.CreateAsync(player);
Expand All @@ -76,7 +89,7 @@
/// <response code="404">Not Found</response>
[HttpGet(Name = "Retrieve")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> GetAsync()
{
var players = await playerService.RetrieveAsync();
Expand All @@ -89,7 +102,12 @@
else
{
logger.LogWarning("GET /players not found");
return TypedResults.NotFound();
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",

Check warning on line 107 in src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'Not Found' 5 times.

See more on https://sonarcloud.io/project/issues?id=nanotaboada_Dotnet.Samples.AspNetCore.WebApi&issues=AZz_GNdtbE8dfXhDp2iL&open=AZz_GNdtbE8dfXhDp2iL&pullRequest=420
detail: "No players were found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
}

Expand All @@ -102,7 +120,7 @@
[Authorize]
[HttpGet("{id:Guid}", Name = "RetrieveById")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
{
var player = await playerService.RetrieveByIdAsync(id);
Expand All @@ -114,7 +132,12 @@
else
{
logger.LogWarning("GET /players/{Id} not found", id);
return TypedResults.NotFound();
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",
detail: $"Player with Id '{id}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
}

Expand All @@ -124,25 +147,30 @@
/// <param name="squadNumber">The Squad Number of the Player</param>
/// <response code="200">OK</response>
/// <response code="404">Not Found</response>
[HttpGet("{squadNumber:int}", Name = "RetrieveBySquadNumber")]
[HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
{
var player = await playerService.RetrieveBySquadNumberAsync(squadNumber);
if (player != null)
{
logger.LogInformation(
"GET /players/{SquadNumber} retrieved: {@Player}",
"GET /players/squadNumber/{SquadNumber} retrieved: {@Player}",
squadNumber,
player
);
return TypedResults.Ok(player);
}
else
{
logger.LogWarning("GET /players/{SquadNumber} not found", squadNumber);
return TypedResults.NotFound();
logger.LogWarning("GET /players/squadNumber/{SquadNumber} not found", squadNumber);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",
detail: $"Player with Squad Number '{squadNumber}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
}

Expand All @@ -159,11 +187,11 @@
/// <response code="204">No Content</response>
/// <response code="400">Bad Request</response>
/// <response code="404">Not Found</response>
[HttpPut("{squadNumber:int}", Name = "Update")]
[HttpPut("squadNumber/{squadNumber:int}", Name = "Update")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> PutAsync(
[FromRoute] int squadNumber,
[FromBody] PlayerRequestModel player
Expand All @@ -173,24 +201,56 @@
if (!validation.IsValid)
{
var errors = validation
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
.ToArray();
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

logger.LogWarning(
"PUT /players/{SquadNumber} validation failed: {@Errors}",
"PUT /players/squadNumber/{SquadNumber} validation failed: {@Errors}",
squadNumber,
errors
);
return TypedResults.BadRequest(errors);
return TypedResults.ValidationProblem(
errors,
detail: "See the errors field for details.",
instance: HttpContext?.Request?.Path.ToString()
);
}
if (player.SquadNumber != squadNumber)
{
logger.LogWarning(
"PutAsync squad number mismatch: route {SquadNumber} != body {PlayerSquadNumber}",
squadNumber,
player.SquadNumber
);
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Bad Request",
detail: "Squad number in the route does not match squad number in the request body.",
instance: HttpContext?.Request?.Path.ToString()
);
}
if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null)
{
logger.LogWarning("PUT /players/{SquadNumber} not found", squadNumber);
return TypedResults.NotFound();
logger.LogWarning("PUT /players/squadNumber/{SquadNumber} not found", squadNumber);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",
detail: $"Player with Squad Number '{squadNumber}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
await playerService.UpdateAsync(player);
// codeql[cs/log-forging] Serilog structured logging with @ destructuring automatically escapes control characters
logger.LogInformation("PUT /players/{SquadNumber} updated: {@Player}", squadNumber, player);
// Sanitize user-provided player data before logging to prevent log forging
var sanitizedPlayerString = player

Check warning on line 244 in src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unnecessary check for null.

See more on https://sonarcloud.io/project/issues?id=nanotaboada_Dotnet.Samples.AspNetCore.WebApi&issues=AZz-r4dPuBgDsqyF7ZDu&open=AZz-r4dPuBgDsqyF7ZDu&pullRequest=420
?.ToString()
?.Replace(Environment.NewLine, string.Empty)
.Replace("\r", string.Empty)
.Replace("\n", string.Empty);
logger.LogInformation(
"PUT /players/squadNumber/{SquadNumber} updated: {Player}",
squadNumber,
sanitizedPlayerString
);
Comment thread
nanotaboada marked this conversation as resolved.
return TypedResults.NoContent();
}

Expand All @@ -204,20 +264,25 @@
/// <param name="squadNumber">The Squad Number of the Player</param>
/// <response code="204">No Content</response>
/// <response code="404">Not Found</response>
[HttpDelete("{squadNumber:int}", Name = "Delete")]
[HttpDelete("squadNumber/{squadNumber:int}", Name = "Delete")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> DeleteAsync([FromRoute] int squadNumber)
{
if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null)
{
logger.LogWarning("DELETE /players/{SquadNumber} not found", squadNumber);
return TypedResults.NotFound();
logger.LogWarning("DELETE /players/squadNumber/{SquadNumber} not found", squadNumber);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",
detail: $"Player with Squad Number '{squadNumber}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
else
{
await playerService.DeleteAsync(squadNumber);
logger.LogInformation("DELETE /players/{SquadNumber} deleted", squadNumber);
logger.LogInformation("DELETE /players/squadNumber/{SquadNumber} deleted", squadNumber);
return TypedResults.NoContent();
}
}
Expand Down
Loading
Loading