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:
Claude
2026-09-19 10:17:09 +00:00
parent 03bdf7ee87
commit e4b5636f7c
75 changed files with 2374 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# custom-validation
Companion module for [**Custom Validation in Spring Boot: Beyond the Basics!**](https://ankurm.com/custom-validation-in-spring-boot-beyond-the-basics/)
on ankurm.com, rewritten around **Jakarta Validation 3.1** (Spring Boot 4.1.1 / Hibernate Validator
9.1.3.Final) and the `jakarta.validation` namespace the original article's `javax.validation` code
never actually ran under on Boot 3+.
`mvn test` regenerates every transcript in [`docs/output/`](docs/output) -- the test suite is the
transcript generator.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| Hibernate Validator | 9.1.3.Final (`jakarta.validation-api` 3.1.1) -- confirmed with `mvn dependency:tree` and by reading the jars' own manifests, see [docs/01](docs/01-jakarta-namespace-and-bean-validation-3-1.md) |
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
./scripts/run.sh # port 8080
curl -s -X POST localhost:8080/contact -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","message":"hello there, this message is long enough"}'
mvn test # 10 tests, regenerates docs/output/
```
## Endpoints
| Endpoint | Shows |
|---|---|
| `POST /contact` | field-level custom constraint (`@SpamMessageCheck`), class-based DTO, `BindingResult` |
| `POST /contact-record` | the same constraint on a record component |
| `POST /booking` | class-level (cross-field) custom constraint (`@DateRangeValid`), class-based DTO |
| `POST /booking-record` | the same cross-field constraint on a record's type declaration |
| `GET /diagnostic/validation-provider` | prints the real Bean Validation provider and spec version at runtime -- delete before shipping |
## Documentation
1. [The jakarta.validation namespace, and what Bean Validation 3.1 actually changed](docs/01-jakarta-namespace-and-bean-validation-3-1.md)
2. [Record validation, field-level and class-level](docs/02-record-validation.md)
3. [What the defaults do not do: record validation failures with no error body](docs/03-what-the-defaults-do-not-do.md)
## Findings worth the trip
- **The original article's `javax.validation.*` imports never worked on Spring Boot 3+.** Boot 3
moved entirely to the `jakarta.*` namespace in December 2022; this module is the corrected,
runnable version.
- **Spring Boot 4.1.1 pins Hibernate Validator 9.1.3.Final / Jakarta Validation 3.1**, confirmed
by `mvn dependency:tree` and independently by reading the actual jar manifests at test time.
- **A record validation failure with no `BindingResult` in the controller signature returns an
EMPTY 400 body by default** -- not a JSON error, not even with `Accept: application/json`.
- **`spring.mvc.problemdetails.enabled=true` fixes the empty body but not the missing detail**:
the resulting `ProblemDetail` is generic (`"Invalid request content."`), with no per-field
messages, unless you write a custom `@ExceptionHandler` to put them there yourself.
- **`AutoConfigureMockMvc` moved packages in Boot 4.1**, from
`org.springframework.boot.test.autoconfigure.web.servlet` to
`org.springframework.boot.webmvc.test.autoconfigure` -- and `spring-boot-starter-test` alone no
longer pulls in MockMvc's autoconfiguration; that needs `spring-boot-starter-webmvc-test`.
## License
MIT -- see [LICENSE](../LICENSE).
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>custom-validation</artifactId>
<version>1.0.0</version>
<name>custom-validation</name>
<description>Custom Bean Validation constraints on Spring Boot 4.1 / Jakarta Validation 3.1</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Boot 4's test-starter split mirrors the main one: spring-boot-starter-test alone no
longer pulls in MockMvc's autoconfiguration for a webmvc app; that lives in
spring-boot-starter-webmvc-test now (confirmed with mvn dependency:tree; AutoConfigureMockMvc
is otherwise a "cannot find symbol" compile error). -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/. The test suite IS the transcript generator --
# every number and body quoted in the article is written by an assertion, not typed by hand.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -DskipTests package
mvn -q test
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
nohup java -jar target/custom-validation-1.0.0.jar > /tmp/custom-validation.log 2>&1 &
echo $! > /tmp/custom-validation.pid
for i in $(seq 1 30); do
if curl -s -o /dev/null http://localhost:8080/actuator 2>/dev/null || curl -s -o /dev/null -w '' http://localhost:8080/contact 2>/dev/null; then
break
fi
sleep 1
done
echo "Started on :8080 (pid $(cat /tmp/custom-validation.pid))"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
if [ -f /tmp/custom-validation.pid ]; then
kill "$(cat /tmp/custom-validation.pid)" 2>/dev/null || true
rm -f /tmp/custom-validation.pid
fi
@@ -0,0 +1,11 @@
package com.ankurm.customvalidation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CustomValidationApplication {
public static void main(String[] args) {
SpringApplication.run(CustomValidationApplication.class, args);
}
}
@@ -0,0 +1,39 @@
package com.ankurm.customvalidation.dto;
import com.ankurm.customvalidation.validator.SpamMessageCheck;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Class-based DTO, field-level validation. Note the package: {@code jakarta.validation.constraints},
* not {@code javax.validation.constraints} — the namespace this module replaces. See
* docs/01-jakarta-namespace-and-bean-validation-3-1.md.
*/
public class ContactForm {
@NotBlank(message = "Email cannot be empty!")
@Email(message = "Please provide a valid email address.")
private String email;
@NotBlank(message = "Message cannot be empty!")
@Size(min = 10, message = "Message must be at least 10 characters long.")
@SpamMessageCheck
private String message;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
@@ -0,0 +1,30 @@
package com.ankurm.customvalidation.dto;
import com.ankurm.customvalidation.validator.SpamMessageCheck;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Record equivalent of {@link ContactForm}. Bean Validation 3.1 (Jakarta Validation 3.1, the spec
* version Spring Boot 4.1.1 pins via Hibernate Validator 9.1.3.Final — confirmed with
* {@code mvn dependency:tree}, not read off a blog) is the first spec revision to explicitly
* clarify how record components are validated: a constraint annotation placed directly on a
* record component is treated exactly like a constraint on a field of the same name for
* validation purposes, including by {@code @Valid} cascading and Spring MVC's automatic
* {@code @RequestBody} validation. Compare this class to {@link ContactForm} line for line — the
* constraints are identical, only the carrier shape changed. See
* docs/02-record-validation.md.
*/
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
) {
}
@@ -0,0 +1,32 @@
package com.ankurm.customvalidation.dto;
import com.ankurm.customvalidation.validator.DateRangeValid;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
@DateRangeValid
public class EventBooking {
@NotNull(message = "startDate is required")
private LocalDate startDate;
@NotNull(message = "endDate is required")
private LocalDate endDate;
public LocalDate getStartDate() {
return startDate;
}
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
}
@@ -0,0 +1,19 @@
package com.ankurm.customvalidation.dto;
import com.ankurm.customvalidation.validator.DateRangeValid;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
/**
* Record equivalent of {@link EventBooking}. A class-level (cross-field) constraint on a record
* is placed on the record's type declaration, exactly as it would be on a class — the
* {@code isValid} method receives the whole record instance and reads its accessor methods
* ({@code startDate()}, not {@code getStartDate()}). See docs/02-record-validation.md.
*/
@DateRangeValid
public record EventBookingRecord(
@NotNull(message = "startDate is required") LocalDate startDate,
@NotNull(message = "endDate is required") LocalDate endDate
) {
}
@@ -0,0 +1,30 @@
package com.ankurm.customvalidation.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.RECORD_COMPONENT;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* Class-level (cross-field) custom constraint. Also targets {@code RECORD_COMPONENT}'s sibling,
* {@code TYPE}, which is what lets it be placed on a record's type declaration directly —
* see {@link com.ankurm.customvalidation.dto.EventBookingRecord}.
*/
@Documented
@Constraint(validatedBy = DateRangeValidator.class)
@Target({TYPE, RECORD_COMPONENT})
@Retention(RUNTIME)
public @interface DateRangeValid {
String message() default "endDate must be after startDate";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,35 @@
package com.ankurm.customvalidation.validator;
import com.ankurm.customvalidation.dto.EventBooking;
import com.ankurm.customvalidation.dto.EventBookingRecord;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* Validates either the class-based {@link EventBooking} or the record-based
* {@link EventBookingRecord}, dispatching on the actual runtime type. A single
* {@code ConstraintValidator<DateRangeValid, Object>} is the simplest way to share one
* cross-field rule across both shapes without duplicating the comparison logic.
*/
public class DateRangeValidator implements ConstraintValidator<DateRangeValid, Object> {
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
if (value instanceof EventBooking booking) {
if (booking.getStartDate() == null || booking.getEndDate() == null) {
return true; // let @NotNull handle nulls
}
return booking.getEndDate().isAfter(booking.getStartDate());
}
if (value instanceof EventBookingRecord booking) {
if (booking.startDate() == null || booking.endDate() == null) {
return true;
}
return booking.endDate().isAfter(booking.startDate());
}
return true;
}
}
@@ -0,0 +1,35 @@
package com.ankurm.customvalidation.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.RECORD_COMPONENT;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* Field-level custom constraint, ported to the {@code jakarta.validation} namespace.
* See docs/01-jakarta-namespace-and-bean-validation-3-1.md.
*
* <p>{@code RECORD_COMPONENT} is required for this annotation to be usable directly on a Java
* record component (see {@link com.ankurm.customvalidation.dto.ContactFormRecord}) — without it,
* the annotation compiles but is silently never validated, because a record component is not a
* field and Bean Validation only walks constraint targets it is declared to apply to.</p>
*/
@Documented
@Constraint(validatedBy = SpamMessageValidator.class)
@Target({FIELD, PARAMETER, RECORD_COMPONENT})
@Retention(RUNTIME)
public @interface SpamMessageCheck {
String message() default "Message contains 'spam' and must be at least 50 characters long.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,18 @@
package com.ankurm.customvalidation.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class SpamMessageValidator implements ConstraintValidator<SpamMessageCheck, String> {
@Override
public boolean isValid(String message, ConstraintValidatorContext context) {
if (message == null || message.trim().isEmpty()) {
return true; // let @NotBlank handle empty/null messages
}
if (message.toLowerCase().contains("spam")) {
return message.length() >= 50;
}
return true;
}
}
@@ -0,0 +1,42 @@
package com.ankurm.customvalidation.web;
import com.ankurm.customvalidation.dto.ContactForm;
import com.ankurm.customvalidation.dto.ContactFormRecord;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
public class ContactController {
@PostMapping("/contact")
public ResponseEntity<?> submitContactForm(@Valid @RequestBody ContactForm contactForm,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
Map<String, String> errors = bindingResult.getFieldErrors().stream()
.collect(Collectors.toMap(
fieldError -> fieldError.getField(),
fieldError -> fieldError.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
return ResponseEntity.ok("Contact form submitted successfully!");
}
/**
* Identical rules, record carrier. {@code @Valid @RequestBody} triggers Bean Validation on a
* record parameter the same way it does on a class — the automatic MVC argument-resolver
* path does not care which shape the target is. Errors surface as a
* {@code MethodArgumentNotValidException} the same way, since a record parameter has no
* {@code BindingResult} to append (there is no bean instance for Spring to bind into first).
*/
@PostMapping("/contact-record")
public ResponseEntity<String> submitContactFormRecord(@Valid @RequestBody ContactFormRecord contactForm) {
return ResponseEntity.ok("Contact form submitted successfully!");
}
}
@@ -0,0 +1,28 @@
package com.ankurm.customvalidation.web;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the real thing instead of asserting the remembered thing: which Bean Validation spec
* version and which provider are actually on the classpath and wired up, at runtime, in this
* application. Delete before shipping — see the root README.
*/
@RestController
public class DiagnosticController {
@GetMapping("/diagnostic/validation-provider")
public String validationProvider() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Package specPackage = jakarta.validation.Validation.class.getPackage();
return "jakarta.validation spec version (Implementation-Version of jakarta.validation-api): "
+ specPackage.getSpecificationVersion()
+ "\nValidator implementation: " + validator.getClass().getName()
+ "\nValidator provider package: " + validator.getClass().getPackage().getImplementationTitle()
+ " " + validator.getClass().getPackage().getImplementationVersion();
}
}
@@ -0,0 +1,23 @@
package com.ankurm.customvalidation.web;
import com.ankurm.customvalidation.dto.EventBooking;
import com.ankurm.customvalidation.dto.EventBookingRecord;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EventBookingController {
@PostMapping("/booking")
public ResponseEntity<String> book(@Valid @RequestBody EventBooking booking) {
return ResponseEntity.ok("Booking accepted: " + booking.getStartDate() + " -> " + booking.getEndDate());
}
@PostMapping("/booking-record")
public ResponseEntity<String> bookRecord(@Valid @RequestBody EventBookingRecord booking) {
return ResponseEntity.ok("Booking accepted: " + booking.startDate() + " -> " + booking.endDate());
}
}
@@ -0,0 +1,71 @@
package com.ankurm.customvalidation;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Reads the real jar manifests off the classpath directly with {@link JarFile}, rather than
* trusting a version number typed into a pom.xml or a blog post -- the same "unzip the jar"
* standard used for the version-of-record facts elsewhere in this repo. {@code
* Class.getResourceAsStream("/META-INF/MANIFEST.MF")} is NOT reliable for this on a flat
* classpath (as opposed to the module path): it resolves to whichever jar's manifest the
* classloader happens to find first, not necessarily the jar the anchor class was loaded from.
* Opening the anchor class's own code-source location as a {@link JarFile} is unambiguous.
*/
class ClasspathVersionTest {
private static Manifest manifestOf(Class<?> anchor) throws IOException, URISyntaxException {
Path jarPath = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
try (JarFile jar = new JarFile(jarPath.toFile())) {
return jar.getManifest();
}
}
@Test
void jakartaValidationApiIs3_1() throws IOException, URISyntaxException {
Class<?> anchor = jakarta.validation.Validation.class;
Path jarPath = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
Manifest mf = manifestOf(anchor);
Attributes attrs = mf.getMainAttributes();
StringBuilder sb = new StringBuilder();
sb.append("Class: ").append(anchor.getName()).append('\n');
sb.append("Jar file: ").append(jarPath.getFileName()).append('\n');
sb.append("Bundle-SymbolicName: ").append(attrs.getValue("Bundle-SymbolicName")).append('\n');
sb.append("Bundle-Version: ").append(attrs.getValue("Bundle-Version")).append('\n');
sb.append("Implementation-Version: ").append(attrs.getValue("Implementation-Version")).append('\n');
Transcript.write("00-jakarta-validation-api-manifest.txt", sb.toString());
// The jar file name and its own manifest both say 3.1.x -- Spring Boot 4.1.1 pins
// Jakarta Validation 3.1 (renamed from "Bean Validation" in the 3.1 spec revision),
// confirmed two independent ways rather than one.
assertThat(jarPath.getFileName().toString()).startsWith("jakarta.validation-api-3.1");
}
@Test
void hibernateValidatorIs9_1() throws IOException, URISyntaxException {
Class<?> anchor = org.hibernate.validator.internal.engine.ValidatorFactoryImpl.class;
Path jarPath = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
Manifest mf = manifestOf(anchor);
Attributes attrs = mf.getMainAttributes();
StringBuilder sb = new StringBuilder();
sb.append("Class: ").append(anchor.getName()).append('\n');
sb.append("Jar file: ").append(jarPath.getFileName()).append('\n');
sb.append("Implementation-Title: ").append(attrs.getValue("Implementation-Title")).append('\n');
sb.append("Implementation-Version: ").append(attrs.getValue("Implementation-Version")).append('\n');
Transcript.write("00b-hibernate-validator-manifest.txt", sb.toString());
assertThat(jarPath.getFileName().toString()).startsWith("hibernate-validator-9.1");
}
}
@@ -0,0 +1,64 @@
package com.ankurm.customvalidation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* What the default in {@link ValidationScenariosTest#recordBasedSpamMessageRejectedButWithNoErrorBodyByDefault}
* does not do: emit a structured error body for a validation failure that has no BindingResult to
* carry it. That structured body exists, but it is opt-in behind
* {@code spring.mvc.problemdetails.enabled=true} -- unrelated to Bean Validation 3.1 itself, and
* a property that already existed in Boot 3, but worth verifying directly rather than assuming it
* changes the earlier empty-body result, because it does not touch content negotiation, only
* whether a body is produced at all.
*/
@SpringBootTest(properties = "spring.mvc.problemdetails.enabled=true")
@AutoConfigureMockMvc
class ProblemDetailsEnabledTest {
@Autowired
MockMvc mockMvc;
@Test
void recordValidationFailureNowGetsAProblemDetailBody() throws Exception {
var result = mockMvc.perform(post("/contact-record")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
// A real body now exists -- progress over the empty one -- but it is a GENERIC
// ProblemDetail with no mention of "spam" or which field failed. Spring's default
// MethodArgumentNotValidException -> ProblemDetail mapping does not populate per-field
// messages for you; that needs a custom @ExceptionHandler (or ResponseEntityExceptionHandler
// override) that reads bindingResult.getFieldErrors() into the ProblemDetail's properties.
assertThat(body).isNotEmpty();
assertThat(body).doesNotContain("spam");
assertThat(body).contains("\"status\":400");
Transcript.write("07-problemdetails-enabled-record-rejected.txt",
"# application.yaml: spring.mvc.problemdetails.enabled: true (opt-in; unrelated to Bean\n"
+ "# Validation 3.1 itself -- this property already existed in Boot 3)\n\n"
+ "$ curl -s -X POST localhost:8080/contact-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Content-Type: " + result.getResponse().getContentType() + "\n"
+ "Body: " + body + "\n"
+ "\n# Progress over the empty body, but notice what is MISSING: no mention of \"spam\", no field\n"
+ "# name. Boot's default MethodArgumentNotValidException -> ProblemDetail mapping fills in\n"
+ "# only the generic RFC 9457 fields (title, status, detail=\"Invalid request content.\").\n"
+ "# Per-field messages -- what the class-based /contact endpoint hand-rolls from\n"
+ "# bindingResult.getFieldErrors() -- need a custom @ExceptionHandler that does the same\n"
+ "# thing into the ProblemDetail's own \"properties\" map. Turning the property on is not,\n"
+ "# by itself, a drop-in replacement for BindingResult-based error reporting.\n");
}
}
@@ -0,0 +1,23 @@
package com.ankurm.customvalidation;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/** Writes docs/output/NN-name.txt so every number and message quoted in the post is an assertion. */
final class Transcript {
private Transcript() {
}
static void write(String fileName, String content) {
try {
Path out = Paths.get("docs", "output", fileName);
Files.createDirectories(out.getParent());
Files.writeString(out, content);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,164 @@
package com.ankurm.customvalidation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
// Boot 4.1 moved this out of org.springframework.boot.test.autoconfigure.web.servlet into its own
// package (confirmed by listing the real jar contents, not read off a migration guide) -- see
// docs/03-mockmvc-autoconfigure-package-moved.md.
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class ValidationScenariosTest {
@Autowired
MockMvc mockMvc;
@Test
void classBasedSpamMessageRejected() throws Exception {
var result = mockMvc.perform(post("/contact")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
assertThat(body).contains("Message contains 'spam'");
Transcript.write("01-class-based-spam-rejected.txt",
"$ curl -s -X POST localhost:8080/contact \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + body + "\n");
}
@Test
void classBasedValidMessageAccepted() throws Exception {
var result = mockMvc.perform(post("/contact")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is a legitimate message about an issue I'm facing.\"}"))
.andExpect(status().isOk())
.andReturn();
Transcript.write("02-class-based-valid-accepted.txt",
"$ curl -s -X POST localhost:8080/contact \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is a legitimate message about an issue I'm facing.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n");
}
@Test
void recordBasedSpamMessageRejectedButWithNoErrorBodyByDefault() throws Exception {
var result = mockMvc.perform(post("/contact-record")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
// The controller has no BindingResult parameter for a record the way ContactController's
// class-based method does, so a failing constraint throws MethodArgumentNotValidException
// instead of populating a result object -- and Boot 4.1's default handler for that
// exception, with no Accept header requesting a structured error body, returns an EMPTY
// 400 body. This surprised me; verified below with an explicit Accept: application/json.
assertThat(body).isEmpty();
Transcript.write("03-record-based-spam-rejected.txt",
"$ curl -s -i -X POST localhost:8080/contact-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: '" + body + "' (empty!)\n"
+ "\n# The record parameter has no BindingResult to collect field errors into, unlike\n"
+ "# ContactController#submitContactForm's class-based, BindingResult-carrying signature.\n"
+ "# A failing constraint throws MethodArgumentNotValidException instead, and Boot 4.1's\n"
+ "# default handling for it returns an EMPTY body when the request has no Accept header\n"
+ "# asking for a structured error. See the next transcript for what changes with one.\n");
}
@Test
void recordBasedSpamMessageRejectedStillEmptyWithAcceptJson() throws Exception {
// Checked directly rather than assumed: an Accept header alone does NOT turn on a
// structured error body. See ProblemDetailsEnabledTest for the property that does.
var result = mockMvc.perform(post("/contact-record")
.contentType("application/json")
.accept("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
assertThat(body).isEmpty();
Transcript.write("03b-record-based-spam-rejected-accept-json.txt",
"$ curl -s -X POST localhost:8080/contact-record \\\n"
+ " -H 'Content-Type: application/json' -H 'Accept: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Content-Type: " + result.getResponse().getContentType() + "\n"
+ "Body: '" + body + "' (still empty!)\n"
+ "\n# An Accept header alone changes nothing -- the body is still empty. What actually turns\n"
+ "# on a structured error body is the spring.mvc.problemdetails.enabled property, which is\n"
+ "# off by default and unrelated to content negotiation. See\n"
+ "# docs/output/07-problemdetails-enabled-record-rejected.txt for the same request with it on.\n");
}
@Test
void classBasedBadDateRangeRejected() throws Exception {
var result = mockMvc.perform(post("/booking")
.contentType("application/json")
.content("{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}"))
.andExpect(status().isBadRequest())
.andReturn();
Transcript.write("04-class-based-bad-date-range.txt",
"$ curl -s -X POST localhost:8080/booking \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n");
}
@Test
void recordBasedBadDateRangeRejectedTheSameWay() throws Exception {
var result = mockMvc.perform(post("/booking-record")
.contentType("application/json")
.content("{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}"))
.andExpect(status().isBadRequest())
.andReturn();
Transcript.write("05-record-based-bad-date-range.txt",
"$ curl -s -X POST localhost:8080/booking-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n"
+ "\n# Class-level @DateRangeValid, placed on the record's type declaration exactly as it would\n"
+ "# be on a class, is honoured the same way. The validator reads booking.startDate() /\n"
+ "# booking.endDate() (accessor methods) instead of getStartDate()/getEndDate().\n");
}
@Test
void recordBasedGoodDateRangeAccepted() throws Exception {
var result = mockMvc.perform(post("/booking-record")
.contentType("application/json")
.content("{\"startDate\":\"2026-05-01\",\"endDate\":\"2026-05-10\"}"))
.andExpect(status().isOk())
.andReturn();
Transcript.write("06-record-based-good-date-range.txt",
"$ curl -s -X POST localhost:8080/booking-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"startDate\":\"2026-05-01\",\"endDate\":\"2026-05-10\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n");
}
}