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,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)
|
||||
Reference in New Issue
Block a user