Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
114 changes: 85 additions & 29 deletions src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
[HttpPost(Name = "Create")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
{
Expand All @@ -39,11 +39,15 @@
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 Down Expand Up @@ -76,7 +80,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 +93,12 @@
else
{
logger.LogWarning("GET /players not found");
return TypedResults.NotFound();
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",

Check warning on line 98 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 +111,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 +123,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 +138,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 +178,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 +192,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 (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()
);
}
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()
);
}
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 235 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 +255,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
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ public async Task Post_Players_ValidationError_Returns400BadRequest()
),
Times.Once
);
if (result is BadRequest httpResult)
if (result is ValidationProblem httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<BadRequest>();
httpResult.Should().NotBeNull().And.BeOfType<ValidationProblem>();
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Expand Down Expand Up @@ -198,9 +198,9 @@ public async Task Get_Players_NonExisting_Returns404NotFound()

// Assert
service.Verify(service => service.RetrieveAsync(), Times.Once);
if (result is NotFound httpResult)
if (result is ProblemHttpResult httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<NotFound>();
httpResult.Should().NotBeNull().And.BeOfType<ProblemHttpResult>();
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
}
Expand All @@ -223,9 +223,9 @@ public async Task Get_PlayerById_NonExisting_Returns404NotFound()

// Assert
service.Verify(service => service.RetrieveByIdAsync(It.IsAny<Guid>()), Times.Once);
if (result is NotFound httpResult)
if (result is ProblemHttpResult httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<NotFound>();
httpResult.Should().NotBeNull().And.BeOfType<ProblemHttpResult>();
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
}
Expand Down Expand Up @@ -274,9 +274,9 @@ public async Task Get_PlayerBySquadNumber_NonExisting_Returns404NotFound()

// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
if (result is NotFound httpResult)
if (result is ProblemHttpResult httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<NotFound>();
httpResult.Should().NotBeNull().And.BeOfType<ProblemHttpResult>();
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
}
Expand Down Expand Up @@ -350,9 +350,9 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
),
Times.Once
);
if (result is BadRequest httpResult)
if (result is ValidationProblem httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<BadRequest>();
httpResult.Should().NotBeNull().And.BeOfType<ValidationProblem>();
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
}
Expand Down Expand Up @@ -392,13 +392,50 @@ public async Task Put_PlayerBySquadNumber_NonExisting_Returns404NotFound()
),
Times.Once
);
if (result is NotFound httpResult)
if (result is ProblemHttpResult httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<NotFound>();
httpResult.Should().NotBeNull().And.BeOfType<ProblemHttpResult>();
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
}

[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_SquadNumberMismatch_Returns400BadRequest()
{
// Arrange
var squadNumber = 23;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
request.SquadNumber = 99; // Mismatched squad number
var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<PlayerRequestModel>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));

var controller = new PlayerController(service.Object, logger.Object, validator.Object);

// Act
var result = await controller.PutAsync(squadNumber, request);

// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
if (result is ProblemHttpResult httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<ProblemHttpResult>();
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
}

[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent()
Expand Down Expand Up @@ -461,9 +498,9 @@ public async Task Delete_PlayerBySquadNumber_NonExisting_Returns404NotFound()
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.DeleteAsync(It.IsAny<int>()), Times.Never);
if (result is NotFound httpResult)
if (result is ProblemHttpResult httpResult)
{
httpResult.Should().NotBeNull().And.BeOfType<NotFound>();
httpResult.Should().NotBeNull().And.BeOfType<ProblemHttpResult>();
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
}
Expand Down
Loading