Skip to content

Commit 17c70d1

Browse files
nanotaboadaclaude
andcommitted
feat(api): return 422 Unprocessable Entity for validation errors (#319)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4abc47e commit 17c70d1

4 files changed

Lines changed: 43 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ Release names follow the **historic football clubs** naming convention (A–Z):
4444

4545
### Changed
4646

47+
- Return `422 Unprocessable Entity` for field validation failures (`@Valid`
48+
constraint violations and squad number mismatch) instead of `400 Bad Request`;
49+
reserve `400` for genuinely malformed requests (unparseable JSON, wrong
50+
`Content-Type`); introduce `GlobalExceptionHandler` (`@ControllerAdvice`) to
51+
intercept `MethodArgumentNotValidException`; update OpenAPI `@ApiResponse`
52+
annotations and test assertions accordingly (#319)
53+
4754
### Fixed
4855

4956
### Removed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package ar.com.nanotaboada.java.samples.spring.boot.controllers;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.http.ResponseEntity;
5+
import org.springframework.web.bind.MethodArgumentNotValidException;
6+
import org.springframework.web.bind.annotation.ExceptionHandler;
7+
import org.springframework.web.bind.annotation.RestControllerAdvice;
8+
9+
@RestControllerAdvice
10+
public class GlobalExceptionHandler {
11+
12+
@ExceptionHandler(MethodArgumentNotValidException.class)
13+
public ResponseEntity<Void> handleValidationException(MethodArgumentNotValidException exception) {
14+
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
15+
}
16+
}

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
* <li><b>200 OK:</b> Successful retrieval</li>
5252
* <li><b>201 Created:</b> Successful creation (with Location header)</li>
5353
* <li><b>204 No Content:</b> Successful update/delete</li>
54-
* <li><b>400 Bad Request:</b> Validation failure</li>
54+
* <li><b>422 Unprocessable Entity:</b> Validation failure</li>
5555
* <li><b>404 Not Found:</b> Resource not found</li>
5656
* </ul>
5757
*
@@ -80,14 +80,14 @@ public class PlayersController {
8080
* </p>
8181
*
8282
* @param playerDTO the player data to create (validated with JSR-380 constraints)
83-
* @return 201 Created with Location header, 400 Bad Request if validation fails, or 409 Conflict if squad number exists
83+
* @return 201 Created with Location header, 409 Conflict if squad number exists, or 422 Unprocessable Entity if validation fails
8484
*/
8585
@PostMapping("/players")
8686
@Operation(summary = "Creates a new player")
8787
@ApiResponses(value = {
8888
@ApiResponse(responseCode = "201", description = "Created", content = @Content),
89-
@ApiResponse(responseCode = "400", description = "Bad Request - Validation failure", content = @Content),
90-
@ApiResponse(responseCode = "409", description = "Conflict - Squad number already exists", content = @Content)
89+
@ApiResponse(responseCode = "409", description = "Conflict - Squad number already exists", content = @Content),
90+
@ApiResponse(responseCode = "422", description = "Unprocessable Entity - Validation failure", content = @Content)
9191
})
9292
public ResponseEntity<Void> post(@RequestBody @Valid PlayerDTO playerDTO) {
9393
PlayerDTO createdPlayer = playersService.create(playerDTO);
@@ -200,18 +200,18 @@ public ResponseEntity<List<PlayerDTO>> searchByLeague(@PathVariable String leagu
200200
*
201201
* @param squadNumber the squad number (natural key) of the player to update
202202
* @param playerDTO the complete player data (must pass validation)
203-
* @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails
203+
* @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 422 Unprocessable Entity if validation fails
204204
*/
205205
@PutMapping("/players/{squadNumber}")
206206
@Operation(summary = "Updates (entirely) a player by squad number")
207207
@ApiResponses(value = {
208208
@ApiResponse(responseCode = "204", description = "No Content", content = @Content),
209-
@ApiResponse(responseCode = "400", description = "Bad Request", content = @Content),
210-
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content)
209+
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content),
210+
@ApiResponse(responseCode = "422", description = "Unprocessable Entity - Validation failure", content = @Content)
211211
})
212212
public ResponseEntity<Void> put(@PathVariable Integer squadNumber, @RequestBody @Valid PlayerDTO playerDTO) {
213213
if (!playerDTO.getSquadNumber().equals(squadNumber)) {
214-
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
214+
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
215215
}
216216
playerDTO.setSquadNumber(squadNumber);
217217
boolean updated = playersService.update(squadNumber, playerDTO);

src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ void givenValidPlayer_whenPost_thenReturnsCreated()
103103
/**
104104
* Given invalid player data is provided (validation fails)
105105
* When attempting to create a player
106-
* Then response status is 400 Bad Request and service is never called
106+
* Then response status is 422 Unprocessable Entity and service is never called
107107
*/
108108
@Test
109-
void givenInvalidPlayer_whenPost_thenReturnsBadRequest()
109+
void givenInvalidPlayer_whenPost_thenReturnsUnprocessableEntity()
110110
throws Exception {
111111
// Given
112112
PlayerDTO dto = PlayerDTOFakes.createOneInvalid();
@@ -122,7 +122,7 @@ void givenInvalidPlayer_whenPost_thenReturnsBadRequest()
122122
.getResponse();
123123
// Then
124124
verify(playersServiceMock, never()).create(any(PlayerDTO.class));
125-
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
125+
then(response.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.value());
126126
}
127127

128128
/**
@@ -437,10 +437,10 @@ void givenUnknownPlayer_whenPut_thenReturnsNotFound()
437437
/**
438438
* Given invalid player data is provided (validation fails)
439439
* When attempting to update a player
440-
* Then response status is 400 Bad Request and service is never called
440+
* Then response status is 422 Unprocessable Entity and service is never called
441441
*/
442442
@Test
443-
void givenInvalidPlayer_whenPut_thenReturnsBadRequest()
443+
void givenInvalidPlayer_whenPut_thenReturnsUnprocessableEntity()
444444
throws Exception {
445445
// Given
446446
PlayerDTO dto = PlayerDTOFakes.createOneInvalid();
@@ -456,16 +456,16 @@ void givenInvalidPlayer_whenPut_thenReturnsBadRequest()
456456
.getResponse();
457457
// Then
458458
verify(playersServiceMock, never()).update(anyInt(), any(PlayerDTO.class));
459-
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
459+
then(response.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.value());
460460
}
461461

462462
/**
463463
* Given the path squad number does not match the body squad number
464464
* When attempting to update a player
465-
* Then response status is 400 Bad Request and service is never called
465+
* Then response status is 422 Unprocessable Entity and service is never called
466466
*/
467467
@Test
468-
void givenSquadNumberMismatch_whenPut_thenReturnsBadRequest()
468+
void givenSquadNumberMismatch_whenPut_thenReturnsUnprocessableEntity()
469469
throws Exception {
470470
// Given
471471
PlayerDTO dto = PlayerDTOFakes.createOneValid();
@@ -483,16 +483,16 @@ void givenSquadNumberMismatch_whenPut_thenReturnsBadRequest()
483483
.getResponse();
484484
// Then
485485
verify(playersServiceMock, never()).update(anyInt(), any(PlayerDTO.class));
486-
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
486+
then(response.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.value());
487487
}
488488

489489
/**
490490
* Given the body squad number is null
491491
* When attempting to update a player
492-
* Then response status is 400 Bad Request (squad number is required)
492+
* Then response status is 422 Unprocessable Entity (squad number is required)
493493
*/
494494
@Test
495-
void givenNullBodySquadNumber_whenPut_thenReturnsBadRequest()
495+
void givenNullBodySquadNumber_whenPut_thenReturnsUnprocessableEntity()
496496
throws Exception {
497497
// Given
498498
PlayerDTO dto = PlayerDTOFakes.createOneValid();
@@ -509,7 +509,7 @@ void givenNullBodySquadNumber_whenPut_thenReturnsBadRequest()
509509
.getResponse();
510510
// Then
511511
verify(playersServiceMock, never()).update(anyInt(), any(PlayerDTO.class));
512-
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
512+
then(response.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY.value());
513513
}
514514

515515
/*

0 commit comments

Comments
 (0)