diff --git a/README.md b/README.md index ae5c85e..3af7aa5 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs index 3a566c9..ada6515 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs @@ -16,6 +16,8 @@ public class PlayerController( IValidator validator ) : ControllerBase { + private const string NotFoundTitle = "Not Found"; + /* ------------------------------------------------------------------------- * HTTP POST * ---------------------------------------------------------------------- */ @@ -30,8 +32,8 @@ IValidator validator [HttpPost(Name = "Create")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task PostAsync([FromBody] PlayerRequestModel player) { var validation = await validator.ValidateAsync(player); @@ -39,11 +41,15 @@ public async Task PostAsync([FromBody] PlayerRequestModel 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) @@ -52,7 +58,16 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) "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); @@ -76,7 +91,7 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) /// Not Found [HttpGet(Name = "Retrieve")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetAsync() { var players = await playerService.RetrieveAsync(); @@ -89,7 +104,12 @@ public async Task GetAsync() else { logger.LogWarning("GET /players not found"); - return TypedResults.NotFound(); + return TypedResults.Problem( + statusCode: StatusCodes.Status404NotFound, + title: NotFoundTitle, + detail: "No players were found.", + instance: HttpContext?.Request?.Path.ToString() + ); } } @@ -102,7 +122,7 @@ public async Task GetAsync() [Authorize] [HttpGet("{id:Guid}", Name = "RetrieveById")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetByIdAsync([FromRoute] Guid id) { var player = await playerService.RetrieveByIdAsync(id); @@ -114,7 +134,12 @@ public async Task GetByIdAsync([FromRoute] Guid id) else { logger.LogWarning("GET /players/{Id} not found", id); - return TypedResults.NotFound(); + return TypedResults.Problem( + statusCode: StatusCodes.Status404NotFound, + title: NotFoundTitle, + detail: $"Player with Id '{id}' was not found.", + instance: HttpContext?.Request?.Path.ToString() + ); } } @@ -124,16 +149,16 @@ public async Task GetByIdAsync([FromRoute] Guid id) /// The Squad Number of the Player /// OK /// Not Found - [HttpGet("{squadNumber:int}", Name = "RetrieveBySquadNumber")] + [HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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 ); @@ -141,8 +166,13 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) } 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: NotFoundTitle, + detail: $"Player with Squad Number '{squadNumber}' was not found.", + instance: HttpContext?.Request?.Path.ToString() + ); } } @@ -159,11 +189,11 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) /// No Content /// Bad Request /// Not Found - [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(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task PutAsync( [FromRoute] int squadNumber, [FromBody] PlayerRequestModel player @@ -173,24 +203,56 @@ [FromBody] PlayerRequestModel 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( - "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: NotFoundTitle, + 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 + .ToString() + ?.Replace(Environment.NewLine, string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty); + logger.LogInformation( + "PUT /players/squadNumber/{SquadNumber} updated: {Player}", + squadNumber, + sanitizedPlayerString + ); return TypedResults.NoContent(); } @@ -204,20 +266,25 @@ [FromBody] PlayerRequestModel player /// The Squad Number of the Player /// No Content /// Not Found - [HttpDelete("{squadNumber:int}", Name = "Delete")] + [HttpDelete("squadNumber/{squadNumber:int}", Name = "Delete")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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(); } } diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs index 2532861..38af79b 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs @@ -5,6 +5,7 @@ using FluentValidation.Results; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; using Moq; namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit; @@ -56,11 +57,8 @@ public async Task Post_Players_ValidationError_Returns400BadRequest() ), Times.Once ); - if (result is BadRequest httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); } [Fact] @@ -99,11 +97,8 @@ public async Task Post_Players_Existing_Returns409Conflict() ), Times.Once ); - if (result is Conflict httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status409Conflict); - } + var httpResult = result.Should().BeOfType>().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status409Conflict); } [Fact] @@ -174,13 +169,10 @@ public async Task Get_Players_Existing_ReturnsPlayers() // Assert service.Verify(service => service.RetrieveAsync(), Times.Once); - if (result is Ok> httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType>>(); - httpResult.StatusCode.Should().Be(StatusCodes.Status200OK); - httpResult.Value.Should().NotBeNull().And.BeOfType>(); - httpResult.Value.Should().BeEquivalentTo(response); - } + var httpResult = result.Should().BeOfType>>().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status200OK); + httpResult.Value.Should().NotBeNull().And.BeOfType>(); + httpResult.Value.Should().BeEquivalentTo(response); } [Fact] @@ -198,11 +190,8 @@ public async Task Get_Players_NonExisting_Returns404NotFound() // Assert service.Verify(service => service.RetrieveAsync(), Times.Once); - if (result is NotFound httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); } [Fact] @@ -223,11 +212,8 @@ public async Task Get_PlayerById_NonExisting_Returns404NotFound() // Assert service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); - if (result is NotFound httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); } [Fact] @@ -247,13 +233,10 @@ public async Task Get_PlayerById_Existing_Returns200OK() // Assert service.Verify(service => service.RetrieveByIdAsync(It.IsAny()), Times.Once); - if (result is Ok httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType>(); - httpResult.StatusCode.Should().Be(StatusCodes.Status200OK); - httpResult.Value.Should().NotBeNull().And.BeOfType(); - httpResult.Value.Should().BeEquivalentTo(response); - } + var httpResult = result.Should().BeOfType>().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status200OK); + httpResult.Value.Should().NotBeNull().And.BeOfType(); + httpResult.Value.Should().BeEquivalentTo(response); } [Fact] @@ -274,11 +257,8 @@ public async Task Get_PlayerBySquadNumber_NonExisting_Returns404NotFound() // Assert service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); - if (result is NotFound httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); } [Fact] @@ -300,13 +280,10 @@ public async Task Get_PlayerBySquadNumber_Existing_Returns200OK() // Assert service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); - if (result is Ok httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType>(); - httpResult.StatusCode.Should().Be(StatusCodes.Status200OK); - httpResult.Value.Should().NotBeNull().And.BeOfType(); - httpResult.Value.Should().BeEquivalentTo(response); - } + var httpResult = result.Should().BeOfType>().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status200OK); + httpResult.Value.Should().NotBeNull().And.BeOfType(); + httpResult.Value.Should().BeEquivalentTo(response); } /* ------------------------------------------------------------------------- @@ -350,11 +327,8 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest() ), Times.Once ); - if (result is BadRequest httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); } [Fact] @@ -379,7 +353,10 @@ public async Task Put_PlayerBySquadNumber_NonExisting_Returns404NotFound() var controller = new PlayerController(service.Object, logger.Object, validator.Object); // Act - var result = await controller.PutAsync(squadNumber, It.IsAny()); + var result = await controller.PutAsync( + squadNumber, + new PlayerRequestModel { SquadNumber = squadNumber } + ); // Assert service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); @@ -392,11 +369,42 @@ public async Task Put_PlayerBySquadNumber_NonExisting_Returns404NotFound() ), Times.Once ); - if (result is NotFound httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } + var httpResult = result.Should().BeOfType().Subject; + 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(), + It.IsAny() + ) + ) + .ReturnsAsync(new ValidationResult(new List { })); + + 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()), Times.Never); + service.Verify(service => service.UpdateAsync(It.IsAny()), Times.Never); + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); } [Fact] @@ -431,11 +439,8 @@ public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent() // Assert service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.UpdateAsync(It.IsAny()), Times.Once); - if (result is NoContent httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent); } /* ------------------------------------------------------------------------- @@ -461,11 +466,8 @@ public async Task Delete_PlayerBySquadNumber_NonExisting_Returns404NotFound() // Assert service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.DeleteAsync(It.IsAny()), Times.Never); - if (result is NotFound httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound); } [Fact] @@ -489,11 +491,8 @@ public async Task Delete_PlayerBySquadNumber_Existing_Returns204NoContent() // Assert service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny()), Times.Once); service.Verify(service => service.DeleteAsync(It.IsAny()), Times.Once); - if (result is NoContent httpResult) - { - httpResult.Should().NotBeNull().And.BeOfType(); - httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent); - } + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent); } protected virtual void Dispose(bool disposing)