Skip to content

feat(api): return 422 Unprocessable Entity for validation errors (#319)#323

Open
nanotaboada wants to merge 2 commits intomasterfrom
feat/319-422-unprocessable-entity
Open

feat(api): return 422 Unprocessable Entity for validation errors (#319)#323
nanotaboada wants to merge 2 commits intomasterfrom
feat/319-422-unprocessable-entity

Conversation

@nanotaboada
Copy link
Copy Markdown
Owner

@nanotaboada nanotaboada commented Apr 19, 2026

Summary

  • Introduce GlobalExceptionHandler (@RestControllerAdvice) that intercepts MethodArgumentNotValidException and returns 422 Unprocessable Entity, while leaving Spring's default handling intact so malformed requests (HttpMessageNotReadableException) continue to return 400 Bad Request
  • Replace HttpStatus.BAD_REQUEST with HttpStatus.UNPROCESSABLE_ENTITY in the PUT endpoint's squad number mismatch check
  • Update OpenAPI @ApiResponse annotations on POST and PUT to declare 422 instead of 400 for validation failures
  • Rename and update four test methods in PlayersControllerTests to assert 422 and reflect the new expected status in their names

Test plan

  • ./mvnw clean install — BUILD SUCCESS, 40 tests pass, all JaCoCo coverage checks met
  • docker compose build — image built successfully
  • CodeRabbit review — 1 finding fixed (@RestControllerAdvice annotation), 1 rejected (squad mismatch → intentional 422 per issue spec)

Closes #319

🤖 Generated with Claude Code


This change is Reviewable

Summary by CodeRabbit

  • Changed
    • Validation errors (including invalid fields and constraint violations) now return HTTP 422 Unprocessable Entity instead of 400 Bad Request
    • HTTP 400 is now reserved exclusively for genuinely malformed requests such as unparseable JSON or incorrect Content-Type headers

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 19, 2026

Warning

Rate limit exceeded

@nanotaboada has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 56 minutes and 23 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 56 minutes and 23 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 29099f3d-1a03-4efe-86f3-85f5219aeb75

📥 Commits

Reviewing files that changed from the base of the PR and between 17c70d1 and ef68f7a.

📒 Files selected for processing (1)
  • src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java

Walkthrough

A new global exception handler intercepts validation failures (MethodArgumentNotValidException) to return HTTP 422 Unprocessable Entity instead of 400 Bad Request. OpenAPI documentation and controller logic were updated to reflect this distinction, with tests aligned accordingly. CHANGELOG documented the change.

Changes

Cohort / File(s) Summary
Global Exception Handler
src/main/java/.../controllers/GlobalExceptionHandler.java, CHANGELOG.md
Added @RestControllerAdvice class with handler for MethodArgumentNotValidException returning 422 UNPROCESSABLE_ENTITY. Documented the semantic HTTP status code change in changelog.
Controller & OpenAPI Updates
src/main/java/.../controllers/PlayersController.java
Updated @ApiResponses annotations to declare 422 instead of 400 for validation failure responses. Modified put() method to return HttpStatus.UNPROCESSABLE_ENTITY on squad number mismatch. Adjusted Javadoc accordingly.
Test Assertions
src/test/java/.../controllers/PlayersControllerTests.java
Renamed validation-focused test methods and updated assertions to expect HttpStatus.UNPROCESSABLE_ENTITY (422) for invalid player POST/PUT and squad number mismatch scenarios.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Assessment against linked issues

Objective Addressed Explanation
Field validation failures return 422 Unprocessable Entity [#319]
400 Bad Request reserved for malformed requests [#319]
OpenAPI @ApiResponse annotations updated to declare 422 instead of 400 [#319]
Tests updated to assert the new status code [#319]
CHANGELOG.md updated [#319]
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits format (feat: prefix), is 71 characters (under 80 limit), and clearly describes the main change: returning 422 Unprocessable Entity for validation errors.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📋 Issue Planner

Built with CodeRabbit's Coding Plans for faster development and fewer bugs.

View plan used: #319

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/319-422-unprocessable-entity
  • 🛠️ sync documentation: Commit on current branch
  • 🛠️ sync documentation: Create PR
  • 🛠️ enforce http error handling: Commit on current branch
  • 🛠️ enforce http error handling: Create PR
  • 🛠️ idiomatic review: Commit on current branch
  • 🛠️ idiomatic review: Create PR
  • 🛠️ verify api contract: Commit on current branch
  • 🛠️ verify api contract: Create PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (4abc47e) to head (ef68f7a).

Additional details and impacted files
@@             Coverage Diff             @@
##              master      #323   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity        30        32    +2     
===========================================
  Files              2         3    +1     
  Lines             88        90    +2     
  Branches           8         8           
===========================================
+ Hits              88        90    +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java (1)

207-215: Consider delegating squad-mismatch to bean validation for consistency.

The squad-number-mismatch branch at line 214 now returns 422 directly from the controller, which is correct per the issue spec. However, this inlined check is conceptually a validation concern that bypasses the GlobalExceptionHandler path taken by every other 422. As a future improvement, you could expose the mismatch as a MethodArgumentNotValidException (e.g., via a cross-field validator or by throwing a custom exception mapped in GlobalExceptionHandler) so all 422 responses share a single shape once a problem-details body is added. Not blocking — current behavior matches the PR objective.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java`
around lines 207 - 215, The controller currently checks squad-number mismatch
inline in PlayersController.put and returns a 422 directly; instead delegate
this to validation so 422s flow through GlobalExceptionHandler: implement a
cross-field constraint on PlayerDTO that validates playerDTO.getSquadNumber()
equals the `@PathVariable` squadNumber (or introduce a custom exception type
mapped in GlobalExceptionHandler and throw it from put), then remove the inline
equality check from PlayersController.put so validation failures produce
MethodArgumentNotValidException (or your mapped custom exception) and are
handled consistently by GlobalExceptionHandler.
src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java (1)

442-513: Add a test that locks in the 400 vs 422 distinction.

The CHANGELOG and issue #319 explicitly reserve 400 Bad Request for malformed requests (unparseable JSON, wrong Content-Type). There is no test asserting that contract, so a future change to the exception handler could silently collapse both paths to the same status and go unnoticed. Consider adding a POST (and optionally PUT) test with a malformed JSON body that asserts HttpStatus.BAD_REQUEST.

💚 Example test
`@Test`
void givenMalformedJson_whenPost_thenReturnsBadRequest() throws Exception {
    MockHttpServletRequestBuilder request = MockMvcRequestBuilders
            .post(PATH)
            .content("{ not-valid-json ")
            .contentType(MediaType.APPLICATION_JSON);

    MockHttpServletResponse response = application
            .perform(request)
            .andReturn()
            .getResponse();

    verify(playersServiceMock, never()).create(any(PlayerDTO.class));
    then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java`
around lines 442 - 513, Add a test in PlayersControllerTests that submits
malformed JSON (e.g. ".content("{ not-valid-json ")") to the POST endpoint (use
MockMvcRequestBuilders.post(PATH) with contentType(MediaType.APPLICATION_JSON)),
perform the request via application.perform(...).andReturn().getResponse(),
verify playersServiceMock.create(...) is never called, and assert
response.getStatus() == HttpStatus.BAD_REQUEST.value() (optionally add an
equivalent PUT test using playersServiceMock.update(...) and path param). Ensure
the new test method has a clear name like
givenMalformedJson_whenPost_thenReturnsBadRequest.
src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/GlobalExceptionHandler.java (1)

12-15: Consider returning a problem-details body so clients can see which fields failed.

The handler currently discards the MethodArgumentNotValidException and returns an empty 422. Clients get the status code but no indication of which field(s) violated which constraint, which defeats much of the value of switching from 400 to 422. Consider returning a small body (field → message map, or RFC 7807 ProblemDetail) and logging the violations via SLF4J for observability.

♻️ Example refactor using Spring's ProblemDetail
-    `@ExceptionHandler`(MethodArgumentNotValidException.class)
-    public ResponseEntity<Void> handleValidationException(MethodArgumentNotValidException exception) {
-        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
-    }
+    `@ExceptionHandler`(MethodArgumentNotValidException.class)
+    public ResponseEntity<ProblemDetail> handleValidationException(MethodArgumentNotValidException exception) {
+        Map<String, String> errors = exception.getBindingResult().getFieldErrors().stream()
+                .collect(Collectors.toMap(
+                        FieldError::getField,
+                        fe -> Optional.ofNullable(fe.getDefaultMessage()).orElse("invalid"),
+                        (a, b) -> a));
+        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
+        problem.setTitle("Validation failed");
+        problem.setProperty("errors", errors);
+        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(problem);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/GlobalExceptionHandler.java`
around lines 12 - 15, The handler handleValidationException currently returns an
empty 422; change it to build and return a ProblemDetail (or a simple
field→message map) containing the validation violations extracted from
exception.getBindingResult() (e.g., iterate field errors and collect field names
to messages) and return it in the ResponseEntity with status
UNPROCESSABLE_ENTITY; also log the extracted violations via the
GlobalExceptionHandler's SLF4J logger for observability. Ensure you reference
MethodArgumentNotValidException, handleValidationException, and ProblemDetail
(or a Map<String,String>) when locating where to implement these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/GlobalExceptionHandler.java`:
- Around line 12-15: The handler handleValidationException currently returns an
empty 422; change it to build and return a ProblemDetail (or a simple
field→message map) containing the validation violations extracted from
exception.getBindingResult() (e.g., iterate field errors and collect field names
to messages) and return it in the ResponseEntity with status
UNPROCESSABLE_ENTITY; also log the extracted violations via the
GlobalExceptionHandler's SLF4J logger for observability. Ensure you reference
MethodArgumentNotValidException, handleValidationException, and ProblemDetail
(or a Map<String,String>) when locating where to implement these changes.

In
`@src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java`:
- Around line 207-215: The controller currently checks squad-number mismatch
inline in PlayersController.put and returns a 422 directly; instead delegate
this to validation so 422s flow through GlobalExceptionHandler: implement a
cross-field constraint on PlayerDTO that validates playerDTO.getSquadNumber()
equals the `@PathVariable` squadNumber (or introduce a custom exception type
mapped in GlobalExceptionHandler and throw it from put), then remove the inline
equality check from PlayersController.put so validation failures produce
MethodArgumentNotValidException (or your mapped custom exception) and are
handled consistently by GlobalExceptionHandler.

In
`@src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java`:
- Around line 442-513: Add a test in PlayersControllerTests that submits
malformed JSON (e.g. ".content("{ not-valid-json ")") to the POST endpoint (use
MockMvcRequestBuilders.post(PATH) with contentType(MediaType.APPLICATION_JSON)),
perform the request via application.perform(...).andReturn().getResponse(),
verify playersServiceMock.create(...) is never called, and assert
response.getStatus() == HttpStatus.BAD_REQUEST.value() (optionally add an
equivalent PUT test using playersServiceMock.update(...) and path param). Ensure
the new test method has a clear name like
givenMalformedJson_whenPost_thenReturnsBadRequest.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f3cd6cbc-f705-4769-83fc-8afccc9c4bb5

📥 Commits

Reviewing files that changed from the base of the PR and between 4abc47e and 17c70d1.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/GlobalExceptionHandler.java
  • src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java
  • src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/controllers/PlayersControllerTests.java

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

@nanotaboada
Copy link
Copy Markdown
Owner Author

@coderabbitai Two findings from the review were deferred as out of scope for #319:

  1. GlobalExceptionHandler — return structured validation errors: The handler currently returns an empty 422 body. A follow-up could return a ProblemDetail (or a Map<String, String> of field → message) built from exception.getBindingResult(), with SLF4J logging of the violations for observability.

  2. PlayersController.put — delegate squad number mismatch to validation: The inline mismatch check currently returns 422 directly. A follow-up could introduce a cross-field constraint on PlayerDTO (or a custom exception mapped in GlobalExceptionHandler) so all 422s flow through a single handler consistently.

Could you please create a new GitHub issue to track these two improvements?

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 19, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh issue create \
  --repo nanotaboada/java.samples.spring.boot \
  --title "feat(api): improve 422 handling — structured error body and unified validation flow" \
  --assignee nanotaboada \
  --body "## Overview

Two follow-up improvements deferred from `#319` (PR `#323`) to keep that change focused. Both relate to making \`422 Unprocessable Entity\` handling more robust and consistent.

---

### 1. \`GlobalExceptionHandler\` — Return structured validation errors

**Current behaviour:** \`handleValidationException\` returns an empty \`422\` body (\`ResponseEntity<Void>\`).

**Proposed improvement:**
- Build a response body from \`exception.getBindingResult()\` — either a \`ProblemDetail\` (RFC 9457, available since Spring 6 / Spring Boot 3) or a \`Map<String, String>\` of field → message.
- Add SLF4J logging of the violations for observability.

**Example sketch:**
\`\`\`java
`@ExceptionHandler`(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidationException(MethodArgumentNotValidException exception) {
    log.warn(\"Validation failed: {}\", exception.getBindingResult().getAllErrors());
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
    problem.setDetail(\"Validation failed\");
    Map<String, String> errors = exception.getBindingResult().getFieldErrors().stream()
        .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
    problem.setProperty(\"errors\", errors);
    return ResponseEntity.unprocessableEntity().body(problem);
}
\`\`\`

---

### 2. \`PlayersController.put\` — Delegate squad number mismatch to validation

**Current behaviour:** The inline mismatch check returns \`422\` directly inside the controller method, bypassing \`GlobalExceptionHandler\`.

**Proposed improvement:**
- Introduce a cross-field constraint on \`PlayerDTO\` (e.g., a custom \`@SquadNumberMatch\` annotation), **or**
- Throw a custom exception (e.g., \`SquadNumberMismatchException\`) and map it in \`GlobalExceptionHandler\`,

so that **all** \`422\` responses flow through a single, consistent handler.

---

### References
- PR: `#323`
- Issue: `#319`
- Requested by: `@nanotaboada`
"

Length of output: 66


@nanotaboada: Done! I've created issue #324 to track both improvements:

👉 #324

Here's a summary of what was captured:

  1. GlobalExceptionHandler — structured error body: Replace the empty ResponseEntity<Void> with a ProblemDetail (RFC 9457) or Map<String, String> built from exception.getBindingResult(), plus SLF4J logging of violations.

  2. PlayersController.put — unified validation flow: Move the inline squad number mismatch check out of the controller by introducing either a cross-field constraint on PlayerDTO or a custom exception mapped in GlobalExceptionHandler, so all 422 responses flow through a single handler.

The issue has been assigned to you and includes backlinks to PR #323 and issue #319.


🧠 Learnings used
Learnt from: nanotaboada
Repo: nanotaboada/java.samples.spring.boot PR: 297
File: README.md:154-154
Timestamp: 2026-04-05T17:31:05.622Z
Learning: In the `nanotaboada/java.samples.spring.boot` repo (and its five sibling repos in the series), the API Reference section in README.md intentionally uses a single unified `Method | Endpoint | Description | Status` table for endpoints, with HTTP error codes (400, 404, 409, etc.) summarized as a one-liner below the table rather than in a separate response codes table. This is a deliberate, consistent design decision applied across all six repos.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adopt 422 Unprocessable Entity for payload validation errors

1 participant