diff --git a/CHANGELOG.md b/CHANGELOG.md index c218b32..9cf5170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ This project uses famous football stadiums (A-Z) that hosted FIFA World Cup matc ### Changed +- Field validation failures now return `422 Unprocessable Entity` (RFC 4918) instead of `400 Bad Request`; `400 Bad Request` is now reserved for malformed requests (unparseable JSON, wrong `Content-Type`, route/body mismatch) per RFC 9457 + ### Fixed ### Removed diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs index 69c7046..e643185 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs @@ -27,13 +27,13 @@ IValidator validator /// /// The PlayerRequestModel /// Created - /// Bad Request /// Conflict + /// Unprocessable Entity [HttpPost(Name = "Create")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] public async Task PostAsync([FromBody] PlayerRequestModel player) { // Use the "Create" rule set, which includes BeUniqueSquadNumber. @@ -49,10 +49,13 @@ public async Task PostAsync([FromBody] PlayerRequestModel player) .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); logger.LogWarning("POST /players validation failed: {@Errors}", errors); - return TypedResults.ValidationProblem( - errors, - detail: "See the errors field for details.", - instance: HttpContext?.Request?.Path.ToString() + return TypedResults.Problem( + new HttpValidationProblemDetails(errors) + { + Status = StatusCodes.Status422UnprocessableEntity, + Detail = "See the errors field for details.", + Instance = HttpContext?.Request?.Path.ToString(), + } ); } @@ -177,13 +180,15 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) /// The PlayerRequestModel /// The Squad Number of the Player /// No Content - /// Bad Request + /// Bad Request (route/body squad number mismatch) /// Not Found + /// Unprocessable Entity (field validation failure) [HttpPut("squadNumber/{squadNumber:int}", Name = "Update")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] public async Task PutAsync( [FromRoute] int squadNumber, [FromBody] PlayerRequestModel player @@ -207,10 +212,13 @@ [FromBody] PlayerRequestModel player squadNumber, errors ); - return TypedResults.ValidationProblem( - errors, - detail: "See the errors field for details.", - instance: HttpContext?.Request?.Path.ToString() + return TypedResults.Problem( + new HttpValidationProblemDetails(errors) + { + Status = StatusCodes.Status422UnprocessableEntity, + Detail = "See the errors field for details.", + Instance = HttpContext?.Request?.Path.ToString(), + } ); } if (player.SquadNumber != squadNumber) diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs index 0b8b9b6..8b907c7 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs @@ -88,7 +88,7 @@ private static (int StatusCode, string Title) MapExceptionToStatusCode(Exception { return exception switch { - ValidationException => (StatusCodes.Status400BadRequest, "Validation Error"), + ValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation Error"), ArgumentException or ArgumentNullException => (StatusCodes.Status400BadRequest, "Bad Request"), diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs index d7bb382..7acce55 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs @@ -206,7 +206,7 @@ public async Task Post_Players_Nonexistent_Returns201Created() [Fact] [Trait("Category", "Integration")] - public async Task Post_Players_ValidationError_Returns400BadRequest() + public async Task Post_Players_ValidationError_Returns422UnprocessableEntity() { // Arrange — SquadNumber 0 is the int default, fails NotEmpty var request = PlayerFakes.MakeRequestModelForCreate(); @@ -216,9 +216,9 @@ public async Task Post_Players_ValidationError_Returns400BadRequest() var response = await _client.PostAsJsonAsync("/players", request); // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); var problem = await response.Content.ReadFromJsonAsync(); - problem!.Status.Should().Be(StatusCodes.Status400BadRequest); + problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity); } /* ------------------------------------------------------------------------- @@ -259,7 +259,7 @@ public async Task Put_PlayerBySquadNumber_Unknown_Returns404NotFound() [Fact] [Trait("Category", "Integration")] - public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest() + public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity() { // Arrange — SquadNumber -1 fails GreaterThan(0) var request = PlayerFakes.MakeRequestModelForUpdate(23); @@ -269,9 +269,9 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest() var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request); // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); var problem = await response.Content.ReadFromJsonAsync(); - problem!.Status.Should().Be(StatusCodes.Status400BadRequest); + problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity); } [Fact] diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs index 29b9ca6..c6b1e18 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs @@ -26,7 +26,7 @@ public PlayerControllerTests() [Fact] [Trait("Category", "Unit")] - public async Task Post_Players_ValidationError_Returns400BadRequest() + public async Task Post_Players_ValidationError_Returns422UnprocessableEntity() { // Arrange var request = PlayerFakes.MakeRequestModelForCreate(); @@ -63,8 +63,8 @@ public async Task Post_Players_ValidationError_Returns400BadRequest() ), Times.Once ); - var httpResult = result.Should().BeOfType().Subject; - httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity); } [Fact] @@ -299,7 +299,7 @@ public async Task Get_PlayerBySquadNumber_Existing_Returns200OK() [Fact] [Trait("Category", "Unit")] - public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest() + public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity() { // Arrange var squadNumber = 20; @@ -339,8 +339,8 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest() ), Times.Once ); - var httpResult = result.Should().BeOfType().Subject; - httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + var httpResult = result.Should().BeOfType().Subject; + httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity); } [Fact]