Add custom-validation, etag-caching, restclient-basic-auth: Boot 4.1 API pass
Three companion modules verifying and rewriting the Boot 4.1.1 / Framework 7.0.9 story for three older articles: the javax->jakarta.validation namespace fix plus Jakarta Validation 3.1 record-validation clarification, ETag/ conditional-request APIs re-verified unchanged plus the starter rename, and RestTemplate Basic Auth rebuilt on RestClient with the exchange() trap called out. 19 real passing tests generate every transcript quoted from the three companion articles. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# 1. The jakarta.validation namespace, and what Bean Validation 3.1 actually changed
|
||||
|
||||
[README](../README.md) | Next: [Record validation](02-record-validation.md)
|
||||
|
||||
Source: [`ContactForm.java`](../src/main/java/com/ankurm/customvalidation/dto/ContactForm.java),
|
||||
[`SpamMessageCheck.java`](../src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java).
|
||||
Transcripts: [`docs/output/00-jakarta-validation-api-manifest.txt`](output/00-jakarta-validation-api-manifest.txt),
|
||||
[`docs/output/00b-hibernate-validator-manifest.txt`](output/00b-hibernate-validator-manifest.txt).
|
||||
|
||||
## The defect this module replaces
|
||||
|
||||
The original version of the companion article this module backs used
|
||||
`javax.validation.constraints.NotBlank`, `javax.validation.Constraint`, and so on throughout --
|
||||
the pre-Jakarta-EE-9 namespace. That package was already wrong for any Spring Boot 3+ application
|
||||
by the time the article was first published: Spring Boot 3.0 moved its entire dependency tree from
|
||||
`javax.*` to `jakarta.*` in December 2022, and `javax.validation.*` classes are simply not on the
|
||||
classpath of a `spring-boot-starter-validation` 3.x or 4.x application. Every class in this module
|
||||
uses `jakarta.validation.*` instead. If you have a codebase still on `javax.validation`, it did not
|
||||
survive the Boot 2 to 3 upgrade and needs the same mechanical rename this module already reflects.
|
||||
|
||||
## What Spring Boot 4.1.1 actually pins, checked directly
|
||||
|
||||
Rather than trust a blog's version claim (including an older draft of this one), the fact was
|
||||
checked the way this repository always checks it: build a throwaway project against
|
||||
`spring-boot-starter-parent:4.1.1` and run `mvn dependency:tree`.
|
||||
|
||||
```
|
||||
[INFO] +- org.springframework.boot:spring-boot-starter-validation:jar:4.1.1:compile
|
||||
[INFO] | \- org.springframework.boot:spring-boot-validation:jar:4.1.1:compile
|
||||
[INFO] | \- org.hibernate.validator:hibernate-validator:jar:9.1.3.Final:compile
|
||||
[INFO] | +- jakarta.validation:jakarta.validation-api:jar:3.1.1:compile
|
||||
```
|
||||
|
||||
Then confirmed a second, independent way: open the actual jars this application loads at runtime
|
||||
and read their own manifests, rather than trusting the dependency tree's coordinates alone.
|
||||
|
||||
```
|
||||
$ mvn test # ClasspathVersionTest
|
||||
```
|
||||
|
||||
<pre><code class="language-none">Class: jakarta.validation.Validation
|
||||
Jar file: jakarta.validation-api-3.1.1.jar
|
||||
Bundle-SymbolicName: jakarta.validation.jakarta.validation-api
|
||||
Bundle-Version: 3.1.1
|
||||
Implementation-Version: null</code></pre>
|
||||
|
||||
([`docs/output/00-jakarta-validation-api-manifest.txt`](output/00-jakarta-validation-api-manifest.txt))
|
||||
|
||||
<pre><code class="language-none">Class: org.hibernate.validator.internal.engine.ValidatorFactoryImpl
|
||||
Jar file: hibernate-validator-9.1.3.Final.jar
|
||||
Implementation-Title: hibernate-validator
|
||||
Implementation-Version: 9.1.3.Final</code></pre>
|
||||
|
||||
([`docs/output/00b-hibernate-validator-manifest.txt`](output/00b-hibernate-validator-manifest.txt))
|
||||
|
||||
Spring Boot 4.1.1 pins **Hibernate Validator 9.1.3.Final**, the reference implementation of
|
||||
**Jakarta Validation 3.1** -- the spec revision released in 2024 as part of Jakarta EE 11.
|
||||
|
||||
## What is actually new in 3.1 (not a rename of 3.0)
|
||||
|
||||
Three real changes, not a version-number bump for its own sake:
|
||||
|
||||
- **The specification itself was renamed** from "Jakarta Bean Validation" to "Jakarta Validation."
|
||||
The namespace (`jakarta.validation.*`) and the annotations you already know
|
||||
(`@NotBlank`, `@Size`, `@Valid`, `@Constraint`) are unchanged -- this is a spec-title change, not
|
||||
an API break.
|
||||
- **The minimum required Java version moved to 17.** Not a concern for anything already running
|
||||
JDK 21 or 25, but it is the reason Hibernate Validator 9.x cannot be backported to run on
|
||||
Java 11.
|
||||
- **Record validation is now explicitly specified**, closing a real gap in 3.0 where the spec was
|
||||
silent on how a constraint on a record component should behave. That is substantial enough to
|
||||
earn its own chapter -- see below.
|
||||
|
||||
## Going deeper
|
||||
|
||||
- [Jakarta Validation news and release history](https://beanvalidation.org/news/) (rel="nofollow")
|
||||
- [Hibernate Validator reference guide](https://docs.hibernate.org/stable/validator/reference/en-US/html_single/) (rel="nofollow")
|
||||
- Next: [Record validation](02-record-validation.md)
|
||||
@@ -0,0 +1,95 @@
|
||||
# 2. Record validation, field-level and class-level
|
||||
|
||||
[Prev: The jakarta.validation namespace](01-jakarta-namespace-and-bean-validation-3-1.md) | [README](../README.md) | Next: [What the defaults do not do](03-what-the-defaults-do-not-do.md)
|
||||
|
||||
Source: [`ContactFormRecord.java`](../src/main/java/com/ankurm/customvalidation/dto/ContactFormRecord.java),
|
||||
[`EventBookingRecord.java`](../src/main/java/com/ankurm/customvalidation/dto/EventBookingRecord.java),
|
||||
[`DateRangeValid.java`](../src/main/java/com/ankurm/customvalidation/validator/DateRangeValid.java).
|
||||
Test: [`ValidationScenariosTest.java`](../src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java).
|
||||
Transcripts: [`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt),
|
||||
[`docs/output/05-record-based-bad-date-range.txt`](output/05-record-based-bad-date-range.txt),
|
||||
[`docs/output/06-record-based-good-date-range.txt`](output/06-record-based-good-date-range.txt).
|
||||
|
||||
## Field-level: a constraint on a record component
|
||||
|
||||
`ContactFormRecord` carries the same three constraints as the class-based `ContactForm` --
|
||||
`@NotBlank`, `@Email`, `@SpamMessageCheck` -- placed directly on record components instead of
|
||||
fields:
|
||||
|
||||
```java
|
||||
public record ContactFormRecord(
|
||||
@NotBlank(message = "Email cannot be empty!")
|
||||
@Email(message = "Please provide a valid email address.")
|
||||
String email,
|
||||
|
||||
@NotBlank(message = "Message cannot be empty!")
|
||||
@Size(min = 10, message = "Message must be at least 10 characters long.")
|
||||
@SpamMessageCheck
|
||||
String message
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
This only compiles because `@SpamMessageCheck`'s `@Target` includes `RECORD_COMPONENT` (see
|
||||
[`SpamMessageCheck.java`](../src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java)).
|
||||
Forget that element type on a custom constraint and the annotation still compiles fine on a class
|
||||
field -- it simply cannot be placed on a record component at all, a compile error rather than a
|
||||
silent no-op. Built-in constraints like `@NotBlank` already declare `RECORD_COMPONENT`, which is
|
||||
why they worked on a hand-rolled record before this module ever touched the topic; a homemade
|
||||
constraint needs the same declaration deliberately added.
|
||||
|
||||
Rejection behaves identically to the class-based version at the validation layer -- same
|
||||
constraint, same message:
|
||||
|
||||
```
|
||||
POST /contact-record
|
||||
{"email":"[email protected]","message":"This is spam."}
|
||||
```
|
||||
|
||||
[`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt) shows
|
||||
the one thing that is *not* identical: the HTTP response body. See
|
||||
[the next chapter](03-what-the-defaults-do-not-do.md) for why.
|
||||
|
||||
## Class-level (cross-field): a constraint on the record's type
|
||||
|
||||
`EventBookingRecord` carries `@DateRangeValid` on the record's type declaration -- the same
|
||||
position a class-level constraint occupies on an ordinary class:
|
||||
|
||||
```java
|
||||
@DateRangeValid
|
||||
public record EventBookingRecord(
|
||||
@NotNull(message = "startDate is required") LocalDate startDate,
|
||||
@NotNull(message = "endDate is required") LocalDate endDate
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
The one code change a cross-field validator needs to support both shapes is in the validator
|
||||
itself, not the annotation -- [`DateRangeValidator`](../src/main/java/com/ankurm/customvalidation/validator/DateRangeValidator.java)
|
||||
dispatches on the runtime type and reads accessor methods that differ by naming convention only:
|
||||
|
||||
```java
|
||||
if (value instanceof EventBooking booking) {
|
||||
return booking.getEndDate().isAfter(booking.getStartDate());
|
||||
}
|
||||
if (value instanceof EventBookingRecord booking) {
|
||||
return booking.endDate().isAfter(booking.startDate()); // record accessors, no "get" prefix
|
||||
}
|
||||
```
|
||||
|
||||
Both shapes reject the same bad input the same way:
|
||||
|
||||
```
|
||||
POST /booking-record
|
||||
{"startDate":"2026-05-10","endDate":"2026-05-01"}
|
||||
```
|
||||
|
||||
HTTP 400 -- [`docs/output/05-record-based-bad-date-range.txt`](output/05-record-based-bad-date-range.txt) --
|
||||
and a correctly-ordered pair is accepted:
|
||||
[`docs/output/06-record-based-good-date-range.txt`](output/06-record-based-good-date-range.txt).
|
||||
|
||||
## Going deeper
|
||||
|
||||
- Records themselves: [Java Records (JEP 395)](https://openjdk.org/jeps/395) (rel="nofollow")
|
||||
- Prev: [The jakarta.validation namespace](01-jakarta-namespace-and-bean-validation-3-1.md)
|
||||
- Next: [What the defaults do not do](03-what-the-defaults-do-not-do.md)
|
||||
@@ -0,0 +1,77 @@
|
||||
# 3. What the defaults do not do: record validation failures with no error body
|
||||
|
||||
[Prev: Record validation](02-record-validation.md) | [README](../README.md)
|
||||
|
||||
Source: [`ContactController.java`](../src/main/java/com/ankurm/customvalidation/web/ContactController.java).
|
||||
Test: [`ValidationScenariosTest.java`](../src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java),
|
||||
[`ProblemDetailsEnabledTest.java`](../src/test/java/com/ankurm/customvalidation/ProblemDetailsEnabledTest.java).
|
||||
Transcripts: [`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt),
|
||||
[`docs/output/03b-record-based-spam-rejected-accept-json.txt`](output/03b-record-based-spam-rejected-accept-json.txt),
|
||||
[`docs/output/07-problemdetails-enabled-record-rejected.txt`](output/07-problemdetails-enabled-record-rejected.txt).
|
||||
|
||||
## The surprise this chapter exists to document
|
||||
|
||||
`ContactController#submitContactForm` (the class-based endpoint) declares a `BindingResult`
|
||||
parameter immediately after `@Valid ContactForm`. That is what lets it inspect
|
||||
`bindingResult.getFieldErrors()` and hand back a `{"message": "..."}` body of its own construction.
|
||||
A record parameter has nowhere convenient to put an equivalent `BindingResult` in this codebase's
|
||||
controller signatures, so `submitContactFormRecord` has none -- and a failing constraint on the
|
||||
record path throws `MethodArgumentNotValidException` instead of populating a result object.
|
||||
|
||||
The assumption going in was that Spring's default handling of that exception would still produce
|
||||
*some* readable body. It does not, by default:
|
||||
|
||||
```
|
||||
$ curl -s -i -X POST localhost:8080/contact-record \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"[email protected]","message":"This is spam."}'
|
||||
|
||||
HTTP status: 400
|
||||
Body: '' (empty!)
|
||||
```
|
||||
|
||||
([`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt))
|
||||
|
||||
<blockquote>An <code>Accept: application/json</code> header does not change this. It was tested
|
||||
directly rather than assumed -- see
|
||||
<a href="../docs/output/03b-record-based-spam-rejected-accept-json.txt">docs/output/03b-record-based-spam-rejected-accept-json.txt</a>.
|
||||
The empty body is not a content-negotiation problem; nothing is being negotiated because nothing is
|
||||
being written.</blockquote>
|
||||
|
||||
## The property that changes this, and what it still does not give you
|
||||
|
||||
`spring.mvc.problemdetails.enabled=true` (a property that already existed in Spring Boot 3, not
|
||||
new in 4.1) turns on RFC 9457 `ProblemDetail` responses for framework-thrown MVC exceptions,
|
||||
`MethodArgumentNotValidException` included:
|
||||
|
||||
```
|
||||
HTTP status: 400
|
||||
Content-Type: application/problem+json
|
||||
Body: {"detail":"Invalid request content.","instance":"/contact-record","status":400,"title":"Bad Request"}
|
||||
```
|
||||
|
||||
([`docs/output/07-problemdetails-enabled-record-rejected.txt`](output/07-problemdetails-enabled-record-rejected.txt))
|
||||
|
||||
Progress -- there is now a body, and a real HTTP status-coded RFC 9457 document -- but read it
|
||||
closely: no mention of "spam", no field name, no constraint message. Boot's default mapping fills
|
||||
in only the generic fields (`title`, `status`, a fixed `detail` string). The per-field detail the
|
||||
class-based endpoint hand-rolls from `bindingResult.getFieldErrors()` is not reproduced
|
||||
automatically; getting it back needs a custom `@ExceptionHandler` (or a
|
||||
`ResponseEntityExceptionHandler` override of `handleMethodArgumentNotValid`) that reads the
|
||||
exception's `BindingResult` -- the exception itself still carries one, even though the controller
|
||||
signature does not expose it -- and writes the field errors into the `ProblemDetail`'s own
|
||||
`properties` map.
|
||||
|
||||
<blockquote>Turning on <code>spring.mvc.problemdetails.enabled</code> is not, by itself, a
|
||||
drop-in replacement for the <code>BindingResult</code>-based error reporting pattern most
|
||||
Spring tutorials (including the earlier version of this one) teach. It replaces an empty body with
|
||||
a standardised envelope; it does not replace the work of putting your validation messages inside
|
||||
that envelope.</blockquote>
|
||||
|
||||
## Going deeper
|
||||
|
||||
- [Spring Framework `ProblemDetail` reference](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html) (rel="nofollow")
|
||||
- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457) (rel="nofollow")
|
||||
- See also this repo's [`problem-details/`](../problem-details) module, built for a different
|
||||
ankurm.com article specifically about `ProblemDetail` and global exception handling in depth.
|
||||
- Prev: [Record validation](02-record-validation.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
Class: jakarta.validation.Validation
|
||||
Jar file: jakarta.validation-api-3.1.1.jar
|
||||
Bundle-SymbolicName: jakarta.validation.jakarta.validation-api
|
||||
Bundle-Version: 3.1.1
|
||||
Implementation-Version: null
|
||||
@@ -0,0 +1,4 @@
|
||||
Class: org.hibernate.validator.internal.engine.ValidatorFactoryImpl
|
||||
Jar file: hibernate-validator-9.1.3.Final.jar
|
||||
Implementation-Title: hibernate-validator
|
||||
Implementation-Version: 9.1.3.Final
|
||||
@@ -0,0 +1,6 @@
|
||||
$ curl -s -X POST localhost:8080/contact \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"[email protected]","message":"This is spam."}'
|
||||
|
||||
HTTP status: 400
|
||||
Body: {"message":"Message contains 'spam' and must be at least 50 characters long."}
|
||||
@@ -0,0 +1,6 @@
|
||||
$ curl -s -X POST localhost:8080/contact \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"[email protected]","message":"This is a legitimate message about an issue I'm facing."}'
|
||||
|
||||
HTTP status: 200
|
||||
Body: Contact form submitted successfully!
|
||||
@@ -0,0 +1,12 @@
|
||||
$ curl -s -i -X POST localhost:8080/contact-record \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"[email protected]","message":"This is spam."}'
|
||||
|
||||
HTTP status: 400
|
||||
Body: '' (empty!)
|
||||
|
||||
# The record parameter has no BindingResult to collect field errors into, unlike
|
||||
# ContactController#submitContactForm's class-based, BindingResult-carrying signature.
|
||||
# A failing constraint throws MethodArgumentNotValidException instead, and Boot 4.1's
|
||||
# default handling for it returns an EMPTY body when the request has no Accept header
|
||||
# asking for a structured error. See the next transcript for what changes with one.
|
||||
@@ -0,0 +1,12 @@
|
||||
$ curl -s -X POST localhost:8080/contact-record \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||
-d '{"email":"[email protected]","message":"This is spam."}'
|
||||
|
||||
HTTP status: 400
|
||||
Content-Type: null
|
||||
Body: '' (still empty!)
|
||||
|
||||
# An Accept header alone changes nothing -- the body is still empty. What actually turns
|
||||
# on a structured error body is the spring.mvc.problemdetails.enabled property, which is
|
||||
# off by default and unrelated to content negotiation. See
|
||||
# docs/output/07-problemdetails-enabled-record-rejected.txt for the same request with it on.
|
||||
@@ -0,0 +1,6 @@
|
||||
$ curl -s -X POST localhost:8080/booking \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"startDate":"2026-05-10","endDate":"2026-05-01"}'
|
||||
|
||||
HTTP status: 400
|
||||
Body:
|
||||
@@ -0,0 +1,10 @@
|
||||
$ curl -s -X POST localhost:8080/booking-record \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"startDate":"2026-05-10","endDate":"2026-05-01"}'
|
||||
|
||||
HTTP status: 400
|
||||
Body:
|
||||
|
||||
# Class-level @DateRangeValid, placed on the record's type declaration exactly as it would
|
||||
# be on a class, is honoured the same way. The validator reads booking.startDate() /
|
||||
# booking.endDate() (accessor methods) instead of getStartDate()/getEndDate().
|
||||
@@ -0,0 +1,6 @@
|
||||
$ curl -s -X POST localhost:8080/booking-record \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"startDate":"2026-05-01","endDate":"2026-05-10"}'
|
||||
|
||||
HTTP status: 200
|
||||
Body: Booking accepted: 2026-05-01 -> 2026-05-10
|
||||
@@ -0,0 +1,18 @@
|
||||
# application.yaml: spring.mvc.problemdetails.enabled: true (opt-in; unrelated to Bean
|
||||
# Validation 3.1 itself -- this property already existed in Boot 3)
|
||||
|
||||
$ curl -s -X POST localhost:8080/contact-record \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"[email protected]","message":"This is spam."}'
|
||||
|
||||
HTTP status: 400
|
||||
Content-Type: application/problem+json
|
||||
Body: {"detail":"Invalid request content.","instance":"/contact-record","status":400,"title":"Bad Request"}
|
||||
|
||||
# Progress over the empty body, but notice what is MISSING: no mention of "spam", no field
|
||||
# name. Boot's default MethodArgumentNotValidException -> ProblemDetail mapping fills in
|
||||
# only the generic RFC 9457 fields (title, status, detail="Invalid request content.").
|
||||
# Per-field messages -- what the class-based /contact endpoint hand-rolls from
|
||||
# bindingResult.getFieldErrors() -- need a custom @ExceptionHandler that does the same
|
||||
# thing into the ProblemDetail's own "properties" map. Turning the property on is not,
|
||||
# by itself, a drop-in replacement for BindingResult-based error reporting.
|
||||
Reference in New Issue
Block a user