Skip to content

Commit da5313b

Browse files
authored
Merge pull request #479 from nanotaboada/feat/469-422-validation-errors
feat(api): adopt 422 Unprocessable Entity for validation errors (#469)
2 parents 6b73d75 + ff31667 commit da5313b

File tree

5 files changed

+34
-24
lines changed

5 files changed

+34
-24
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ This project uses famous football stadiums (A-Z) that hosted FIFA World Cup matc
4646

4747
### Changed
4848

49+
- 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
50+
4951
### Fixed
5052

5153
### Removed

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ IValidator<PlayerRequestModel> validator
2727
/// </summary>
2828
/// <param name="player">The PlayerRequestModel</param>
2929
/// <response code="201">Created</response>
30-
/// <response code="400">Bad Request</response>
3130
/// <response code="409">Conflict</response>
31+
/// <response code="422">Unprocessable Entity</response>
3232
[HttpPost(Name = "Create")]
3333
[Consumes(MediaTypeNames.Application.Json)]
3434
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status201Created)]
35-
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
3635
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
36+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)]
3737
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
3838
{
3939
// Use the "Create" rule set, which includes BeUniqueSquadNumber.
@@ -49,10 +49,13 @@ public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
4949
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
5050

5151
logger.LogWarning("POST /players validation failed: {@Errors}", errors);
52-
return TypedResults.ValidationProblem(
53-
errors,
54-
detail: "See the errors field for details.",
55-
instance: HttpContext?.Request?.Path.ToString()
52+
return TypedResults.Problem(
53+
new HttpValidationProblemDetails(errors)
54+
{
55+
Status = StatusCodes.Status422UnprocessableEntity,
56+
Detail = "See the errors field for details.",
57+
Instance = HttpContext?.Request?.Path.ToString(),
58+
}
5659
);
5760
}
5861

@@ -177,13 +180,15 @@ public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
177180
/// <param name="player">The PlayerRequestModel</param>
178181
/// <param name="squadNumber">The Squad Number of the Player</param>
179182
/// <response code="204">No Content</response>
180-
/// <response code="400">Bad Request</response>
183+
/// <response code="400">Bad Request (route/body squad number mismatch)</response>
181184
/// <response code="404">Not Found</response>
185+
/// <response code="422">Unprocessable Entity (field validation failure)</response>
182186
[HttpPut("squadNumber/{squadNumber:int}", Name = "Update")]
183187
[Consumes(MediaTypeNames.Application.Json)]
184188
[ProducesResponseType(StatusCodes.Status204NoContent)]
185189
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
186190
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
191+
[ProducesResponseType<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)]
187192
public async Task<IResult> PutAsync(
188193
[FromRoute] int squadNumber,
189194
[FromBody] PlayerRequestModel player
@@ -207,10 +212,13 @@ [FromBody] PlayerRequestModel player
207212
squadNumber,
208213
errors
209214
);
210-
return TypedResults.ValidationProblem(
211-
errors,
212-
detail: "See the errors field for details.",
213-
instance: HttpContext?.Request?.Path.ToString()
215+
return TypedResults.Problem(
216+
new HttpValidationProblemDetails(errors)
217+
{
218+
Status = StatusCodes.Status422UnprocessableEntity,
219+
Detail = "See the errors field for details.",
220+
Instance = HttpContext?.Request?.Path.ToString(),
221+
}
214222
);
215223
}
216224
if (player.SquadNumber != squadNumber)

src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ private static (int StatusCode, string Title) MapExceptionToStatusCode(Exception
8888
{
8989
return exception switch
9090
{
91-
ValidationException => (StatusCodes.Status400BadRequest, "Validation Error"),
91+
ValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation Error"),
9292
ArgumentException
9393
or ArgumentNullException
9494
=> (StatusCodes.Status400BadRequest, "Bad Request"),

test/Dotnet.Samples.AspNetCore.WebApi.Tests/Integration/PlayerWebApplicationTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ public async Task Post_Players_Nonexistent_Returns201Created()
206206

207207
[Fact]
208208
[Trait("Category", "Integration")]
209-
public async Task Post_Players_ValidationError_Returns400BadRequest()
209+
public async Task Post_Players_ValidationError_Returns422UnprocessableEntity()
210210
{
211211
// Arrange — SquadNumber 0 is the int default, fails NotEmpty
212212
var request = PlayerFakes.MakeRequestModelForCreate();
@@ -216,9 +216,9 @@ public async Task Post_Players_ValidationError_Returns400BadRequest()
216216
var response = await _client.PostAsJsonAsync("/players", request);
217217

218218
// Assert
219-
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
219+
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
220220
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
221-
problem!.Status.Should().Be(StatusCodes.Status400BadRequest);
221+
problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
222222
}
223223

224224
/* -------------------------------------------------------------------------
@@ -259,7 +259,7 @@ public async Task Put_PlayerBySquadNumber_Unknown_Returns404NotFound()
259259

260260
[Fact]
261261
[Trait("Category", "Integration")]
262-
public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
262+
public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity()
263263
{
264264
// Arrange — SquadNumber -1 fails GreaterThan(0)
265265
var request = PlayerFakes.MakeRequestModelForUpdate(23);
@@ -269,9 +269,9 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
269269
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
270270

271271
// Assert
272-
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
272+
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
273273
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
274-
problem!.Status.Should().Be(StatusCodes.Status400BadRequest);
274+
problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
275275
}
276276

277277
[Fact]

test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public PlayerControllerTests()
2626

2727
[Fact]
2828
[Trait("Category", "Unit")]
29-
public async Task Post_Players_ValidationError_Returns400BadRequest()
29+
public async Task Post_Players_ValidationError_Returns422UnprocessableEntity()
3030
{
3131
// Arrange
3232
var request = PlayerFakes.MakeRequestModelForCreate();
@@ -63,8 +63,8 @@ public async Task Post_Players_ValidationError_Returns400BadRequest()
6363
),
6464
Times.Once
6565
);
66-
var httpResult = result.Should().BeOfType<ValidationProblem>().Subject;
67-
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
66+
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
67+
httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity);
6868
}
6969

7070
[Fact]
@@ -299,7 +299,7 @@ public async Task Get_PlayerBySquadNumber_Existing_Returns200OK()
299299

300300
[Fact]
301301
[Trait("Category", "Unit")]
302-
public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
302+
public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity()
303303
{
304304
// Arrange
305305
var squadNumber = 20;
@@ -339,8 +339,8 @@ public async Task Put_PlayerBySquadNumber_ValidationError_Returns400BadRequest()
339339
),
340340
Times.Once
341341
);
342-
var httpResult = result.Should().BeOfType<ValidationProblem>().Subject;
343-
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
342+
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
343+
httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity);
344344
}
345345

346346
[Fact]

0 commit comments

Comments
 (0)