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:
@@ -27,6 +27,9 @@ files.
|
|||||||
| [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown |
|
| [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown |
|
||||||
| [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/springdoc-openapi-spring-boot-4-1-versioning/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions |
|
| [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/springdoc-openapi-spring-boot-4-1-versioning/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions |
|
||||||
| [`graphql-dataloader/`](graphql-dataloader) | [Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1](https://ankurm.com/) | a naive `@SchemaMapping` resolver measured at 21 SQL statements for 20 books versus a `@BatchMapping` resolver's flat 2, a dangling foreign key nulling an entire GraphQL response via non-null propagation identically under both resolver strategies, and two Spring Boot 4.1 packaging changes (`DataSourceAutoConfiguration`'s new package, Jackson 3 by default) hit along the way |
|
| [`graphql-dataloader/`](graphql-dataloader) | [Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1](https://ankurm.com/) | a naive `@SchemaMapping` resolver measured at 21 SQL statements for 20 books versus a `@BatchMapping` resolver's flat 2, a dangling foreign key nulling an entire GraphQL response via non-null propagation identically under both resolver strategies, and two Spring Boot 4.1 packaging changes (`DataSourceAutoConfiguration`'s new package, Jackson 3 by default) hit along the way |
|
||||||
|
| [`custom-validation/`](custom-validation) | [Custom Validation in Spring Boot: Beyond the Basics!](https://ankurm.com/custom-validation-in-spring-boot-beyond-the-basics/) | the `javax.validation` to `jakarta.validation` namespace fix Boot 3 already required, Jakarta Validation 3.1's record-validation clarification proven on both field- and class-level custom constraints, a record validation failure's empty 400 body by default, and what `spring.mvc.problemdetails.enabled` does and does not fix |
|
||||||
|
| [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking |
|
||||||
|
| [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) |
|
||||||
|
|
||||||
Articles whose text is kept here rather than only on the blog have it under
|
Articles whose text is kept here rather than only on the blog have it under
|
||||||
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
|
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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/"
|
||||||
@@ -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))"
|
||||||
@@ -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
|
||||||
+11
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
+30
@@ -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 {};
|
||||||
|
}
|
||||||
+35
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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 {};
|
||||||
|
}
|
||||||
+18
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# etag-caching
|
||||||
|
|
||||||
|
Companion module for [**Mastering Cache Control with ETag in Spring Boot RESTful APIs**](https://ankurm.com/etag-cache-control-rest-api-spring-boot/)
|
||||||
|
on ankurm.com, re-verified against Spring Boot 4.1.1 / Spring Framework 7.0.9.
|
||||||
|
|
||||||
|
`mvn test` regenerates every transcript in [`docs/output/`](docs/output).
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Spring Boot | 4.1.1 |
|
||||||
|
| Spring Framework | 7.0.9 |
|
||||||
|
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
|
mvn -DskipTests package
|
||||||
|
./scripts/run.sh
|
||||||
|
curl -i http://localhost:8080/api/products/42
|
||||||
|
mvn test # 5 tests, regenerates docs/output/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Shows |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/products/{id}` | deep cache: `WebRequest.checkNotModified()` before the "expensive" part |
|
||||||
|
| `PUT /api/products/{id}` | conditional update with `If-Match`, optimistic locking, ETag rotation |
|
||||||
|
| `GET /api/echo/{message}` | shallow cache: zero-code `ShallowEtagHeaderFilter` on a scoped URL pattern |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
1. [spring-boot-starter-web is deprecated in favour of spring-boot-starter-webmvc](docs/01-starter-web-renamed.md)
|
||||||
|
2. [ETags are unchanged, verified rather than assumed](docs/02-etags-unchanged-verified.md)
|
||||||
|
3. [Deep cache vs shallow cache, and conditional PUT with If-Match](docs/03-deep-vs-shallow-cache.md)
|
||||||
|
|
||||||
|
## Findings worth the trip
|
||||||
|
|
||||||
|
- **`spring-boot-starter-web`'s own published POM now says "deprecated in favor of
|
||||||
|
spring-boot-starter-webmvc"** -- read directly off Maven Central, not a migration guide. Both
|
||||||
|
resolve to an identical dependency set today.
|
||||||
|
- **`ShallowEtagHeaderFilter`, `WebRequest.checkNotModified()`, and `ResponseEntity.eTag()` are
|
||||||
|
all unchanged** on Spring Framework 7.0.9 -- same packages, same behaviour, confirmed by
|
||||||
|
compiling and running against them rather than assumed from the Boot 3 version of this article.
|
||||||
|
- **Registering `ShallowEtagHeaderFilter` as a plain `@Bean` applies it globally**; this module
|
||||||
|
scopes it to one URL pattern with `FilterRegistrationBean` instead, so it does not shadow the
|
||||||
|
deliberately deeper caching on `/api/products`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT -- see [LICENSE](../LICENSE).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 1. spring-boot-starter-web is deprecated in favour of spring-boot-starter-webmvc
|
||||||
|
|
||||||
|
[README](../README.md) | Next: [ETags are unchanged, verified](02-etags-unchanged-verified.md)
|
||||||
|
|
||||||
|
Source: [`pom.xml`](../pom.xml).
|
||||||
|
|
||||||
|
## The fact, checked at the source
|
||||||
|
|
||||||
|
The original article this module backs declared `spring-boot-starter-web`, the starter every
|
||||||
|
Spring MVC tutorial has used for over a decade. It still works on Spring Boot 4.1.1 -- but its own
|
||||||
|
published `pom.xml` now says so directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -s https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-web/4.1.1/spring-boot-starter-web-4.1.1.pom | grep description
|
||||||
|
<description>Starter for building web, including RESTful, applications using Spring MVC.
|
||||||
|
Uses Tomcat as the default embedded container (deprecated in favor of spring-boot-starter-webmvc)</description>
|
||||||
|
```
|
||||||
|
|
||||||
|
Diffing the two starters' dependency lists (both via `mvn dependency:tree` against a throwaway
|
||||||
|
project) shows they resolve to an **identical set**: `spring-boot-starter-jackson`,
|
||||||
|
`spring-boot-starter-tomcat`, `spring-boot-http-converter`, `spring-boot-webmvc` (plus
|
||||||
|
`spring-boot-starter` itself, which `-webmvc`'s own POM lists explicitly and `-web`'s POM picks up
|
||||||
|
transitively through it). This is a rename for clarity, not a behavioural change --
|
||||||
|
`spring-boot-starter-webmvc` is simply the name Boot 4 wants new code to reach for, matching the
|
||||||
|
naming pattern of the reactive equivalent (`spring-boot-starter-webflux`, unchanged) and the newer
|
||||||
|
`spring-boot-starter-restclient`. This module uses `spring-boot-starter-webmvc` throughout.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- [Spring Boot starters reference](https://docs.spring.io/spring-boot/reference/using/build-systems.html#using.build-systems.starters) (rel="nofollow")
|
||||||
|
- Next: [ETags are unchanged, verified](02-etags-unchanged-verified.md)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 2. ETag support itself: unchanged, verified rather than assumed
|
||||||
|
|
||||||
|
[Prev: spring-boot-starter-web renamed](01-starter-web-renamed.md) | [README](../README.md) | Next: [Deep cache vs shallow cache, and the cost difference](03-deep-vs-shallow-cache.md)
|
||||||
|
|
||||||
|
Source: [`ProductController.java`](../src/main/java/com/ankurm/etagcaching/web/ProductController.java),
|
||||||
|
[`WebConfig.java`](../src/main/java/com/ankurm/etagcaching/config/WebConfig.java).
|
||||||
|
Transcripts: [`docs/output/01-first-get-returns-etag.txt`](output/01-first-get-returns-etag.txt),
|
||||||
|
[`docs/output/02-conditional-get-304.txt`](output/02-conditional-get-304.txt),
|
||||||
|
[`docs/output/05-shallow-etag-header-filter.txt`](output/05-shallow-etag-header-filter.txt).
|
||||||
|
|
||||||
|
Three APIs from the original article, all confirmed to compile and behave the same way against
|
||||||
|
Spring Framework 7.0.9 / Spring Boot 4.1.1:
|
||||||
|
|
||||||
|
- **`org.springframework.web.filter.ShallowEtagHeaderFilter`** -- same package, same class, same
|
||||||
|
behaviour (compute an MD5 hash of the full response body after the handler runs, write it as the
|
||||||
|
`ETag` header, and turn a matching `If-None-Match` into a 304). Nothing about Boot 4's starter
|
||||||
|
renames or Framework 7's other changes touched this class.
|
||||||
|
- **`WebRequest.checkNotModified(String)`** -- same signature, same contract: pass it your own
|
||||||
|
precomputed ETag value, and if it matches the client's `If-None-Match`, Spring writes the 304
|
||||||
|
itself and the method should return without doing further work.
|
||||||
|
- **`ResponseEntity.eTag(String)`** -- unchanged fluent builder method for setting the header on a
|
||||||
|
200 response.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -i http://localhost:8080/api/products/42
|
||||||
|
|
||||||
|
HTTP status: 200
|
||||||
|
ETag: "4e47fa7e"
|
||||||
|
```
|
||||||
|
|
||||||
|
([`docs/output/01-first-get-returns-etag.txt`](output/01-first-get-returns-etag.txt))
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -i http://localhost:8080/api/products/42 -H 'If-None-Match: "4e47fa7e"'
|
||||||
|
|
||||||
|
HTTP status: 304
|
||||||
|
Body: '' (empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
([`docs/output/02-conditional-get-304.txt`](output/02-conditional-get-304.txt))
|
||||||
|
|
||||||
|
The zero-code filter option produces the identical 200-then-304 pair from a plain string-returning
|
||||||
|
endpoint with no ETag-aware code in the handler at all -- see
|
||||||
|
[`docs/output/05-shallow-etag-header-filter.txt`](output/05-shallow-etag-header-filter.txt) and the
|
||||||
|
next chapter for why you would pick one approach over the other.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- [`ShallowEtagHeaderFilter` Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/ShallowEtagHeaderFilter.html) (rel="nofollow")
|
||||||
|
- Prev: [spring-boot-starter-web renamed](01-starter-web-renamed.md)
|
||||||
|
- Next: [Deep cache vs shallow cache](03-deep-vs-shallow-cache.md)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 3. Deep cache vs shallow cache, and conditional PUT with If-Match
|
||||||
|
|
||||||
|
[Prev: ETags are unchanged, verified](02-etags-unchanged-verified.md) | [README](../README.md)
|
||||||
|
|
||||||
|
Source: [`ProductController.java`](../src/main/java/com/ankurm/etagcaching/web/ProductController.java),
|
||||||
|
[`EchoController.java`](../src/main/java/com/ankurm/etagcaching/web/EchoController.java).
|
||||||
|
Transcripts: [`docs/output/03-conditional-put-412.txt`](output/03-conditional-put-412.txt),
|
||||||
|
[`docs/output/04-conditional-put-success.txt`](output/04-conditional-put-success.txt).
|
||||||
|
|
||||||
|
## Shallow: correct, but still does the work
|
||||||
|
|
||||||
|
`ShallowEtagHeaderFilter` (previous chapter) computes its hash from the response body **after**
|
||||||
|
the handler has already produced it. For `GET /api/echo/{message}`, that means the string
|
||||||
|
concatenation always runs -- the filter only saves the bytes actually sent over the wire on a 304,
|
||||||
|
not the work of producing them. For a handler backed by a real database query or a slow downstream
|
||||||
|
call, this saves bandwidth but not latency or load on the resource that matters most.
|
||||||
|
|
||||||
|
## Deep: `WebRequest.checkNotModified()`, checked before the expensive part
|
||||||
|
|
||||||
|
`ProductController#getProduct` computes just enough to know the current ETag, then calls
|
||||||
|
`checkNotModified()` **before** doing anything a real system would consider expensive:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Product current = productService.findById(id); // stand-in for "cheap enough to always do"
|
||||||
|
String etagValue = productService.etagFor(current);
|
||||||
|
if (webRequest.checkNotModified(etagValue)) {
|
||||||
|
return null; // 304 already written
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this demo, `findById` is a map read, so the distinction is illustrative rather than measured --
|
||||||
|
the real design point is architectural: a production version needs a genuinely cheap way to derive
|
||||||
|
the comparison value (a stored `version` column, a `last_modified` timestamp) *without* running the
|
||||||
|
full query the ETag is meant to let you skip. Get that split wrong and "deep" caching degrades back
|
||||||
|
to "shallow" in every way that matters, while looking like it should be faster.
|
||||||
|
|
||||||
|
## Conditional PUT: `If-Match` as optimistic locking
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -i -X PUT http://localhost:8080/api/products/42 \
|
||||||
|
-H 'Content-Type: application/json' -H 'If-Match: "stale-etag-from-a-while-ago"' \
|
||||||
|
-d '{"name":"Laptop Pro","price":1199}'
|
||||||
|
|
||||||
|
HTTP status: 412
|
||||||
|
```
|
||||||
|
|
||||||
|
([`docs/output/03-conditional-put-412.txt`](output/03-conditional-put-412.txt)) A stale `If-Match`
|
||||||
|
is rejected before the write happens -- the response also carries the current `ETag` header so a
|
||||||
|
well-behaved client can re-fetch and retry. With the current ETag supplied instead, the update
|
||||||
|
succeeds and the ETag rotates to a new value derived from the new content:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -i -X PUT http://localhost:8080/api/products/42 \
|
||||||
|
-H 'Content-Type: application/json' -H 'If-Match: "4e47fa7e"' \
|
||||||
|
-d '{"name":"Laptop Pro","price":1199}'
|
||||||
|
|
||||||
|
HTTP status: 200
|
||||||
|
New ETag: "0087226b"
|
||||||
|
```
|
||||||
|
|
||||||
|
([`docs/output/04-conditional-put-success.txt`](output/04-conditional-put-success.txt)) A second
|
||||||
|
`PUT` reusing the old `If-Match` value now 412s, exactly like the first case -- the rotation is
|
||||||
|
what makes this a real optimistic-locking mechanism rather than a one-time check.
|
||||||
|
|
||||||
|
<blockquote>This module builds the ETag by hand from a version counter to keep the example
|
||||||
|
self-contained. A JPA entity's own <code>@Version</code> column is the natural real-world source
|
||||||
|
for the same value -- hash it, or use it directly as a weak ETag, instead of re-deriving a content
|
||||||
|
hash on every request.</blockquote>
|
||||||
|
|
||||||
|
## Should you build this by hand?
|
||||||
|
|
||||||
|
For a handful of endpoints, yes -- the pattern above is a few lines. For an API with dozens of
|
||||||
|
resources needing the same If-Match/If-None-Match discipline, wrap the comparison logic in one
|
||||||
|
reusable helper rather than repeating `checkNotModified()` calls; Spring does not ship one, because
|
||||||
|
what counts as "the resource's current version" is domain-specific.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- [MDN: HTTP conditional requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) (rel="nofollow")
|
||||||
|
- [RFC 9110 §8.8: Validators](https://www.rfc-editor.org/rfc/rfc9110#section-8.8) (rel="nofollow")
|
||||||
|
- Prev: [ETags are unchanged, verified](02-etags-unchanged-verified.md)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ curl -i http://localhost:8080/api/products/42
|
||||||
|
|
||||||
|
HTTP status: 200
|
||||||
|
ETag: "4e47fa7e"
|
||||||
|
Body: {"id":42,"name":"Laptop","price":999,"version":1}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ curl -i http://localhost:8080/api/products/42 -H 'If-None-Match: "4e47fa7e"'
|
||||||
|
|
||||||
|
HTTP status: 304
|
||||||
|
Body: '' (empty)
|
||||||
|
|
||||||
|
# WebRequest.checkNotModified(...) wrote the 304 and short-circuited the handler BEFORE
|
||||||
|
# the controller method's own body ran any further -- this is the "deep cache" case: a
|
||||||
|
# real database read behind productService.findById(id) is only avoided if you compute
|
||||||
|
# the comparison value (e.g. a stored version/timestamp) more cheaply than the full fetch,
|
||||||
|
# which this in-memory demo simplifies but a real service must design around explicitly.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
$ curl -i -X PUT http://localhost:8080/api/products/42 \
|
||||||
|
-H 'Content-Type: application/json' -H 'If-Match: "stale-etag-from-a-while-ago"' \
|
||||||
|
-d '{"name":"Laptop Pro","price":1199}'
|
||||||
|
|
||||||
|
HTTP status: 412
|
||||||
|
Current ETag header returned: "4e47fa7e"
|
||||||
|
Body: {"currentEtag":"\"4e47fa7e\"","error":"Resource was modified since you last read it"}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
$ curl -i -X PUT http://localhost:8080/api/products/42 \
|
||||||
|
-H 'Content-Type: application/json' -H 'If-Match: "4e47fa7e"' \
|
||||||
|
-d '{"name":"Laptop Pro","price":1199}'
|
||||||
|
|
||||||
|
HTTP status: 200
|
||||||
|
Old ETag: "4e47fa7e"
|
||||||
|
New ETag: "0087226b" (rotated -- a stale If-Match sent after this point 412s)
|
||||||
|
Body: {"id":42,"name":"Laptop Pro","price":1199,"version":2}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
$ curl -i http://localhost:8080/api/echo/hello
|
||||||
|
|
||||||
|
First request -> status 200, ETag "04b614fb02225a0cb24f7520b58e80cb1", body 'echo: hello'
|
||||||
|
|
||||||
|
$ curl -i http://localhost:8080/api/echo/hello -H 'If-None-Match: "04b614fb02225a0cb24f7520b58e80cb1"'
|
||||||
|
|
||||||
|
Second request -> status 304 (org.springframework.web.filter.ShallowEtagHeaderFilter, package unchanged on Spring
|
||||||
|
Framework 7.0.9 -- confirmed by compiling against it here, not assumed)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?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>etag-caching</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>etag-caching</name>
|
||||||
|
<description>ETag cache control (ShallowEtagHeaderFilter, checkNotModified, If-Match) on Spring Boot 4.1</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- spring-boot-starter-web still works on Boot 4.1, but its own published POM description
|
||||||
|
now reads "deprecated in favor of spring-boot-starter-webmvc", confirmed by reading
|
||||||
|
the artifact's own pom.xml on Maven Central, not a migration guide. Both resolve to an
|
||||||
|
identical dependency set; -webmvc is simply the name Boot 4 wants going forward. See
|
||||||
|
docs/01-starter-web-renamed.md. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mvn -q -DskipTests package
|
||||||
|
mvn -q test
|
||||||
|
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
nohup java -jar target/etag-caching-1.0.0.jar > /tmp/etag-caching.log 2>&1 &
|
||||||
|
echo $! > /tmp/etag-caching.pid
|
||||||
|
sleep 3
|
||||||
|
echo "Started on :8080 (pid $(cat /tmp/etag-caching.pid))"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -f /tmp/etag-caching.pid ]; then
|
||||||
|
kill "$(cat /tmp/etag-caching.pid)" 2>/dev/null || true
|
||||||
|
rm -f /tmp/etag-caching.pid
|
||||||
|
fi
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.etagcaching;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class EtagCachingApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(EtagCachingApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ankurm.etagcaching.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.Filter;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.filter.ShallowEtagHeaderFilter;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class WebConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zero-code option: still {@code org.springframework.web.filter.ShallowEtagHeaderFilter}
|
||||||
|
* on Spring Framework 7.0.9, unchanged package, unchanged behaviour -- verified by compiling
|
||||||
|
* against it and hitting it below, not assumed because Boot 3 code looked the same. Scoped to
|
||||||
|
* {@code /api/echo/*} only, so it does not shadow the deliberately deeper {@code
|
||||||
|
* /api/products} caching in {@link com.ankurm.etagcaching.web.ProductController} -- registering
|
||||||
|
* it as a plain {@code @Bean} the way older tutorials do applies it to every request.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<Filter> shallowEtagHeaderFilter() {
|
||||||
|
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
|
||||||
|
registration.addUrlPatterns("/api/echo/*");
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.ankurm.etagcaching.service;
|
||||||
|
|
||||||
|
public record Product(long id, String name, int price, int version) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.ankurm.etagcaching.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory store standing in for a real repository. The "version" field is what a real
|
||||||
|
* system would keep as an optimistic-locking column (JPA's {@code @Version} is the obvious real
|
||||||
|
* equivalent) -- the ETag is a hash of the resource's current content, which changes whenever
|
||||||
|
* the version does.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ProductService {
|
||||||
|
|
||||||
|
private final Map<Long, Product> products = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public ProductService() {
|
||||||
|
products.put(42L, new Product(42L, "Laptop", 999, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Product findById(long id) {
|
||||||
|
Product product = products.get(id);
|
||||||
|
if (product == null) {
|
||||||
|
throw new java.util.NoSuchElementException("No product " + id);
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Product update(long id, String name, int price) {
|
||||||
|
Product current = findById(id);
|
||||||
|
Product updated = new Product(id, name, price, current.version() + 1);
|
||||||
|
products.put(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A strong ETag: an MD5 hash of the resource's own content-defining fields. */
|
||||||
|
public String etagFor(Product product) {
|
||||||
|
String content = product.id() + ":" + product.name() + ":" + product.price() + ":" + product.version();
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("MD5").digest(content.getBytes());
|
||||||
|
return HexFormat.of().formatHex(digest).substring(0, 8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return String.valueOf(content.hashCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ankurm.etagcaching.web;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately trivial endpoint to demonstrate {@link com.ankurm.etagcaching.config.WebConfig}'s
|
||||||
|
* {@code ShallowEtagHeaderFilter}: the filter computes the ETag from the RESPONSE BODY after the
|
||||||
|
* handler has already run in full, unlike {@link ProductController#getProduct}, which checks the
|
||||||
|
* ETag before doing the equivalent of the "expensive" work. Same header, opposite cost profile.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class EchoController {
|
||||||
|
|
||||||
|
@GetMapping("/api/echo/{message}")
|
||||||
|
public String echo(@org.springframework.web.bind.annotation.PathVariable String message) {
|
||||||
|
return "echo: " + message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.ankurm.etagcaching.web;
|
||||||
|
|
||||||
|
import com.ankurm.etagcaching.service.Product;
|
||||||
|
import com.ankurm.etagcaching.service.ProductService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/products")
|
||||||
|
public class ProductController {
|
||||||
|
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
|
public ProductController(ProductService productService) {
|
||||||
|
this.productService = productService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep cache: the ETag is computed and checked BEFORE the "expensive" lookup below is
|
||||||
|
* reached, via {@link WebRequest#checkNotModified(String)}. In this demo the lookup is a map
|
||||||
|
* read, but the point generalises to a real database query or downstream call: a 304 short-
|
||||||
|
* circuits the method and never touches it.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Product> getProduct(@PathVariable long id, WebRequest webRequest) {
|
||||||
|
// 1. Compute just enough to know the current ETag without doing the full "expensive" fetch.
|
||||||
|
Product current = productService.findById(id);
|
||||||
|
String etagValue = productService.etagFor(current);
|
||||||
|
|
||||||
|
// 2. Ask Spring to compare against If-None-Match and, if unchanged, write 304 itself.
|
||||||
|
if (webRequest.checkNotModified(etagValue)) {
|
||||||
|
return null; // Spring has already committed the 304 response; returning null is correct here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Only reached when the resource actually changed.
|
||||||
|
return ResponseEntity.ok().eTag(etagValue).body(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> updateProduct(@PathVariable long id,
|
||||||
|
@RequestBody UpdateRequest updated,
|
||||||
|
@RequestHeader(value = "If-Match", required = false) String ifMatch) {
|
||||||
|
Product current = productService.findById(id);
|
||||||
|
String currentEtag = '"' + productService.etagFor(current) + '"';
|
||||||
|
|
||||||
|
if (ifMatch != null && !ifMatch.equals(currentEtag)) {
|
||||||
|
return ResponseEntity.status(412) // Precondition Failed
|
||||||
|
.header("ETag", currentEtag)
|
||||||
|
.body(Map.of("error", "Resource was modified since you last read it", "currentEtag", currentEtag));
|
||||||
|
}
|
||||||
|
|
||||||
|
Product saved = productService.update(id, updated.name(), updated.price());
|
||||||
|
String newEtag = '"' + productService.etagFor(saved) + '"';
|
||||||
|
return ResponseEntity.ok().eTag(newEtag).body(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateRequest(String name, int price) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.ankurm.etagcaching;
|
||||||
|
|
||||||
|
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.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
|
||||||
|
// Each test starts from the same fresh in-memory product (id 42, version 1) rather than sharing
|
||||||
|
// mutated state left over by an earlier test method in this class -- @DirtiesContext trades a
|
||||||
|
// slower suite (a new context per test) for transcripts that are each an honest, independent
|
||||||
|
// before/after story instead of accidentally depending on JUnit's method execution order.
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class EtagScenariosTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstGetReturnsEtag() throws Exception {
|
||||||
|
MvcResult result = mockMvc.perform(get("/api/products/42")).andReturn();
|
||||||
|
String etag = result.getResponse().getHeader("ETag");
|
||||||
|
assertThat(etag).isNotBlank();
|
||||||
|
assertThat(result.getResponse().getStatus()).isEqualTo(200);
|
||||||
|
|
||||||
|
Transcript.write("01-first-get-returns-etag.txt",
|
||||||
|
"$ curl -i http://localhost:8080/api/products/42\n\n"
|
||||||
|
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
|
||||||
|
+ "ETag: " + etag + "\n"
|
||||||
|
+ "Body: " + result.getResponse().getContentAsString() + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conditionalGetWithMatchingEtagReturns304WithEmptyBody() throws Exception {
|
||||||
|
MvcResult first = mockMvc.perform(get("/api/products/42")).andReturn();
|
||||||
|
String etag = first.getResponse().getHeader("ETag");
|
||||||
|
|
||||||
|
MvcResult second = mockMvc.perform(get("/api/products/42").header("If-None-Match", etag)).andReturn();
|
||||||
|
assertThat(second.getResponse().getStatus()).isEqualTo(304);
|
||||||
|
assertThat(second.getResponse().getContentAsString()).isEmpty();
|
||||||
|
|
||||||
|
Transcript.write("02-conditional-get-304.txt",
|
||||||
|
"$ curl -i http://localhost:8080/api/products/42 -H 'If-None-Match: " + etag + "'\n\n"
|
||||||
|
+ "HTTP status: " + second.getResponse().getStatus() + "\n"
|
||||||
|
+ "Body: '" + second.getResponse().getContentAsString() + "' (empty)\n"
|
||||||
|
+ "\n# WebRequest.checkNotModified(...) wrote the 304 and short-circuited the handler BEFORE\n"
|
||||||
|
+ "# the controller method's own body ran any further -- this is the \"deep cache\" case: a\n"
|
||||||
|
+ "# real database read behind productService.findById(id) is only avoided if you compute\n"
|
||||||
|
+ "# the comparison value (e.g. a stored version/timestamp) more cheaply than the full fetch,\n"
|
||||||
|
+ "# which this in-memory demo simplifies but a real service must design around explicitly.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conditionalPutWithStaleIfMatchReturns412() throws Exception {
|
||||||
|
MvcResult first = mockMvc.perform(get("/api/products/42")).andReturn();
|
||||||
|
String currentEtag = first.getResponse().getHeader("ETag");
|
||||||
|
assertThat(currentEtag).isNotEqualTo("\"stale-etag-from-a-while-ago\"");
|
||||||
|
|
||||||
|
MvcResult result = mockMvc.perform(put("/api/products/42")
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("If-Match", "\"stale-etag-from-a-while-ago\"")
|
||||||
|
.content("{\"name\":\"Laptop Pro\",\"price\":1199}"))
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
assertThat(result.getResponse().getStatus()).isEqualTo(412);
|
||||||
|
|
||||||
|
Transcript.write("03-conditional-put-412.txt",
|
||||||
|
"$ curl -i -X PUT http://localhost:8080/api/products/42 \\\n"
|
||||||
|
+ " -H 'Content-Type: application/json' -H 'If-Match: \"stale-etag-from-a-while-ago\"' \\\n"
|
||||||
|
+ " -d '{\"name\":\"Laptop Pro\",\"price\":1199}'\n\n"
|
||||||
|
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
|
||||||
|
+ "Current ETag header returned: " + result.getResponse().getHeader("ETag") + "\n"
|
||||||
|
+ "Body: " + result.getResponse().getContentAsString() + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conditionalPutWithFreshIfMatchSucceedsAndRotatesEtag() throws Exception {
|
||||||
|
MvcResult first = mockMvc.perform(get("/api/products/42")).andReturn();
|
||||||
|
String currentEtag = first.getResponse().getHeader("ETag");
|
||||||
|
|
||||||
|
MvcResult result = mockMvc.perform(put("/api/products/42")
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("If-Match", currentEtag)
|
||||||
|
.content("{\"name\":\"Laptop Pro\",\"price\":1199}"))
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
assertThat(result.getResponse().getStatus()).isEqualTo(200);
|
||||||
|
String newEtag = result.getResponse().getHeader("ETag");
|
||||||
|
assertThat(newEtag).isNotEqualTo(currentEtag);
|
||||||
|
|
||||||
|
Transcript.write("04-conditional-put-success.txt",
|
||||||
|
"$ curl -i -X PUT http://localhost:8080/api/products/42 \\\n"
|
||||||
|
+ " -H 'Content-Type: application/json' -H 'If-Match: " + currentEtag + "' \\\n"
|
||||||
|
+ " -d '{\"name\":\"Laptop Pro\",\"price\":1199}'\n\n"
|
||||||
|
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
|
||||||
|
+ "Old ETag: " + currentEtag + "\n"
|
||||||
|
+ "New ETag: " + newEtag + " (rotated -- a stale If-Match sent after this point 412s)\n"
|
||||||
|
+ "Body: " + result.getResponse().getContentAsString() + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shallowEtagHeaderFilterStillWorksUnderThisPackageInFramework7() throws Exception {
|
||||||
|
MvcResult first = mockMvc.perform(get("/api/echo/hello")).andReturn();
|
||||||
|
String etag = first.getResponse().getHeader("ETag");
|
||||||
|
assertThat(etag).isNotBlank();
|
||||||
|
|
||||||
|
MvcResult second = mockMvc.perform(get("/api/echo/hello").header("If-None-Match", etag)).andReturn();
|
||||||
|
assertThat(second.getResponse().getStatus()).isEqualTo(304);
|
||||||
|
|
||||||
|
Transcript.write("05-shallow-etag-header-filter.txt",
|
||||||
|
"$ curl -i http://localhost:8080/api/echo/hello\n\n"
|
||||||
|
+ "First request -> status " + first.getResponse().getStatus()
|
||||||
|
+ ", ETag " + etag + ", body '" + first.getResponse().getContentAsString() + "'\n\n"
|
||||||
|
+ "$ curl -i http://localhost:8080/api/echo/hello -H 'If-None-Match: " + etag + "'\n\n"
|
||||||
|
+ "Second request -> status " + second.getResponse().getStatus()
|
||||||
|
+ " (org.springframework.web.filter.ShallowEtagHeaderFilter, package unchanged on Spring\n"
|
||||||
|
+ "Framework 7.0.9 -- confirmed by compiling against it here, not assumed)\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.etagcaching;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
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,61 @@
|
|||||||
|
# restclient-basic-auth
|
||||||
|
|
||||||
|
Companion module for [**Spring Boot RestTemplate with Basic Auth: A Modern Guide**](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/)
|
||||||
|
on ankurm.com, rewritten around **RestClient** -- Spring Boot 4's recommended synchronous HTTP
|
||||||
|
client, now that `RestTemplate` is out of the recommended path. Cross-linked with the deeper
|
||||||
|
[RestTemplate to RestClient Migration Guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/).
|
||||||
|
|
||||||
|
`mvn test` starts a real embedded Tomcat with a real Spring Security filter chain on a random port
|
||||||
|
and makes real HTTP calls against it -- every transcript in [`docs/output/`](docs/output) is a
|
||||||
|
genuine request/response pair, not a mocked one.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Spring Boot | 4.1.1 |
|
||||||
|
| Spring Framework | 7.0.9 |
|
||||||
|
| Spring Security | managed by Boot 4.1.1 |
|
||||||
|
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
|
mvn -DskipTests package
|
||||||
|
./scripts/run.sh
|
||||||
|
curl -i -u admin:password123 http://localhost:8080/api/hello
|
||||||
|
mvn test # 4 tests, regenerates docs/output/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints and beans
|
||||||
|
|
||||||
|
| | Shows |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/hello` (server side) | Spring Security `httpBasic()`, unchanged from the original article |
|
||||||
|
| `restClientWithDefaultHeaders` bean | `RestClient.Builder.defaultHeaders(h -> h.setBasicAuth(...))` |
|
||||||
|
| `restClientWithInterceptor` bean | `BasicAuthenticationInterceptor`, ported unchanged from RestTemplate |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
1. [{noop} passwords: still work, still deprecated, no runtime warning](docs/01-password-encoding.md)
|
||||||
|
2. [RestClient with Basic Auth, two ways](docs/02-restclient-basic-auth-patterns.md)
|
||||||
|
3. [The Boot 4 starter split, and the exchange() trap](docs/03-starter-split-and-exchange-trap.md)
|
||||||
|
|
||||||
|
## Findings worth the trip
|
||||||
|
|
||||||
|
- **`{noop}` plaintext passwords still work on Boot 4.1 and emit no runtime warning at all** --
|
||||||
|
checked directly by running an app with one and reading the full startup and auth log. The
|
||||||
|
`@Deprecated` annotation on `NoOpPasswordEncoder` is a compile-time signal only.
|
||||||
|
- **`spring-boot-starter-webmvc` alone does not include HTTP client auto-configuration.**
|
||||||
|
`spring-boot-starter-restclient` is its own module in Boot 4 and must be declared explicitly, or
|
||||||
|
the `RestClient.Builder` bean this module depends on is not there.
|
||||||
|
- **`BasicAuthenticationInterceptor` needs zero changes to move from `RestTemplate` to
|
||||||
|
`RestClient`** -- both accept the same `ClientHttpRequestInterceptor` interface.
|
||||||
|
- **`RestClient.retrieve()` throws on 4xx/5xx by default, same as `RestTemplate`** -- it is
|
||||||
|
`RestClient.exchange()` specifically that disables that default, a trap covered in depth in the
|
||||||
|
[migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT -- see [LICENSE](../LICENSE).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 1. {noop} passwords: still work, still deprecated, no runtime warning
|
||||||
|
|
||||||
|
[README](../README.md) | Next: [RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md)
|
||||||
|
|
||||||
|
Source: [`AppSecurityConfig.java`](../src/main/java/com/ankurm/restclientbasicauth/config/AppSecurityConfig.java).
|
||||||
|
|
||||||
|
## What was checked, and why
|
||||||
|
|
||||||
|
The original article used `User.builder().password("{noop}password123")` with the comment "for
|
||||||
|
demonstration purposes only." Before repeating that pattern in a rewrite, it seemed worth checking
|
||||||
|
whether Boot 4.1 actually does anything different with it now -- log a deprecation warning, refuse
|
||||||
|
to start, anything.
|
||||||
|
|
||||||
|
It does not. A throwaway application built with exactly that `{noop}` password, run standalone with
|
||||||
|
`java -jar`, produces no warning in the full startup log, and a subsequent Basic-Auth request that
|
||||||
|
successfully authenticates against it produces no warning either. `NoOpPasswordEncoder` is
|
||||||
|
`@Deprecated` in Spring Security's own source and has been for years, but that annotation is a
|
||||||
|
compile-time signal to whoever writes the code, not a runtime one -- nothing tells an operator
|
||||||
|
watching logs in production that a demo shortcut is still live.
|
||||||
|
|
||||||
|
That is precisely the failure mode worth naming: a comment reading "for demonstration purposes
|
||||||
|
only" is not enforced by anything at runtime. This module uses
|
||||||
|
`PasswordEncoderFactories.createDelegatingPasswordEncoder()` (Spring Security's own recommended
|
||||||
|
default, currently BCrypt) instead, so the encoded value in the user store is not silently
|
||||||
|
reversible plaintext even in a demo.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- [Spring Security password storage reference](https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html) (rel="nofollow")
|
||||||
|
- Next: [RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2. RestClient with Basic Auth, two ways
|
||||||
|
|
||||||
|
[Prev: {noop} passwords](01-password-encoding.md) | [README](../README.md) | Next: [The starter split, and the exchange() trap](03-starter-split-and-exchange-trap.md)
|
||||||
|
|
||||||
|
Source: [`RestClientConfig.java`](../src/main/java/com/ankurm/restclientbasicauth/client/RestClientConfig.java),
|
||||||
|
[`ApiClient.java`](../src/main/java/com/ankurm/restclientbasicauth/client/ApiClient.java).
|
||||||
|
Test: [`RestClientBasicAuthTest.java`](../src/test/java/com/ankurm/restclientbasicauth/RestClientBasicAuthTest.java).
|
||||||
|
Transcripts: [`docs/output/01-restclient-basic-auth-default-headers.txt`](output/01-restclient-basic-auth-default-headers.txt),
|
||||||
|
[`docs/output/02-restclient-basic-auth-interceptor.txt`](output/02-restclient-basic-auth-interceptor.txt).
|
||||||
|
|
||||||
|
The original article's two `RestTemplate` patterns -- `RestTemplateBuilder.basicAuthentication(...)`
|
||||||
|
and a hand-added `BasicAuthenticationInterceptor` -- both have a direct `RestClient` equivalent.
|
||||||
|
Both are built on the **auto-configured `RestClient.Builder`** injected as a constructor parameter,
|
||||||
|
never `RestClient.create()`, which carries none of Boot's message converters, observability, or
|
||||||
|
customizer beans -- the same rule the
|
||||||
|
[RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/)
|
||||||
|
covers in more general depth.
|
||||||
|
|
||||||
|
## Idiomatic on RestClient: `defaultHeaders` + `setBasicAuth`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Bean
|
||||||
|
public RestClient restClientWithDefaultHeaders(RestClient.Builder builder) {
|
||||||
|
return builder
|
||||||
|
.defaultHeaders(headers -> headers.setBasicAuth("admin", "password123"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Response: Hello, you have accessed a secured endpoint!
|
||||||
|
```
|
||||||
|
|
||||||
|
([`docs/output/01-restclient-basic-auth-default-headers.txt`](output/01-restclient-basic-auth-default-headers.txt))
|
||||||
|
-- a real HTTP call against a real embedded Tomcat with a real Spring Security filter chain, not a
|
||||||
|
mock.
|
||||||
|
|
||||||
|
## Ported unchanged: `BasicAuthenticationInterceptor`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Bean
|
||||||
|
public RestClient restClientWithInterceptor(RestClient.Builder builder) {
|
||||||
|
return builder
|
||||||
|
.requestInterceptor(new BasicAuthenticationInterceptor("admin", "password123"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`BasicAuthenticationInterceptor` implements `ClientHttpRequestInterceptor` -- the exact same
|
||||||
|
interface both `RestTemplate.getInterceptors()` and `RestClient.Builder.requestInterceptor(...)`
|
||||||
|
accept, so this class needs no changes at all to move from one client to the other. Same result:
|
||||||
|
[`docs/output/02-restclient-basic-auth-interceptor.txt`](output/02-restclient-basic-auth-interceptor.txt).
|
||||||
|
|
||||||
|
Prefer `defaultHeaders` when the credentials are fixed at bean-creation time; keep the interceptor
|
||||||
|
form when credentials must be resolved per request (a token fetched from a vault, say) --
|
||||||
|
an interceptor runs on every call, a `defaultHeaders` value is captured once.
|
||||||
|
|
||||||
|
## The negative cases, checked too
|
||||||
|
|
||||||
|
`docs/output/03-restclient-no-credentials-401.txt` and `04-restclient-wrong-password-401.txt`
|
||||||
|
confirm what actually happens on the failure path: `retrieve()` throws
|
||||||
|
`HttpClientErrorException.Unauthorized` on a 401, exactly like `RestTemplate` did -- this is the
|
||||||
|
default `retrieve()` behaviour, not the `exchange()` behaviour covered in the next chapter.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- [`RestClient` Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestClient.html) (rel="nofollow")
|
||||||
|
- Prev: [{noop} passwords](01-password-encoding.md)
|
||||||
|
- Next: [The starter split, and the exchange() trap](03-starter-split-and-exchange-trap.md)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 3. The Boot 4 starter split, and the exchange() trap
|
||||||
|
|
||||||
|
[Prev: RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md) | [README](../README.md)
|
||||||
|
|
||||||
|
Source: [`pom.xml`](../pom.xml).
|
||||||
|
|
||||||
|
## `spring-boot-starter-restclient` is not optional in Boot 4
|
||||||
|
|
||||||
|
The original article's `spring-boot-starter-web` dependency was, on Boot 3, sufficient to get an
|
||||||
|
auto-configured `RestTemplateBuilder` bean for free. On Boot 4.1, HTTP client support moved into
|
||||||
|
its own starter, confirmed the same way every version fact in this repository is confirmed --
|
||||||
|
build a throwaway project and read the real dependency tree:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mvn dependency:tree # against spring-boot-starter-parent:4.1.1 + spring-boot-starter-webmvc only
|
||||||
|
```
|
||||||
|
|
||||||
|
`spring-boot-starter-webmvc` alone does **not** pull in `spring-boot-restclient`. Leave
|
||||||
|
`spring-boot-starter-restclient` off this module's `pom.xml` and the auto-configured
|
||||||
|
`RestClient.Builder` this chapter's code depends on is simply not there -- a `NoSuchBeanDefinitionException`
|
||||||
|
at startup, not a subtle behavioural difference. This module declares it explicitly.
|
||||||
|
|
||||||
|
## The trap this module deliberately avoids
|
||||||
|
|
||||||
|
`RestClient` has its own `exchange()` method, and it means something different from
|
||||||
|
`RestTemplate.exchange()`: **RestClient's `exchange()` disables the default status handlers**, so a
|
||||||
|
4xx or 5xx response is silently returned to you instead of thrown. Every example in this module
|
||||||
|
uses `retrieve()` for exactly that reason -- `retrieve()` keeps the throw-on-4xx/5xx behaviour this
|
||||||
|
Basic Auth demo relies on (see [`docs/output/03-restclient-no-credentials-401.txt`](output/03-restclient-no-credentials-401.txt)).
|
||||||
|
A team that mechanically renames `restTemplate.exchange(...)` call sites to
|
||||||
|
`restClient.exchange(...)` during a migration ships code that stops noticing failed requests. The
|
||||||
|
[RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/)
|
||||||
|
covers this trap, the full method-mapping table, and the three behavioural differences that matter
|
||||||
|
in more depth than a Basic Auth-focused rewrite has room for.
|
||||||
|
|
||||||
|
## Going deeper
|
||||||
|
|
||||||
|
- [RestTemplate to RestClient Migration Guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) -- the exchange() trap, timeouts, and the full mapping table
|
||||||
|
- [Spring Boot 4 HTTP Service Clients (@HttpExchange)](https://ankurm.com/spring-boot-4-http-service-clients/) -- the declarative layer built on top of RestClient
|
||||||
|
- Prev: [RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// RestClient.Builder builder = ...;
|
||||||
|
// RestClient client = builder.defaultHeaders(h -> h.setBasicAuth("admin", "password123")).build();
|
||||||
|
// client.get().uri(baseUrl + "/api/hello").retrieve().body(String.class);
|
||||||
|
|
||||||
|
Response: Hello, you have accessed a secured endpoint!
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// RestClient.Builder builder = ...;
|
||||||
|
// RestClient client = builder.requestInterceptor(
|
||||||
|
// new BasicAuthenticationInterceptor("admin", "password123")).build();
|
||||||
|
// client.get().uri(baseUrl + "/api/hello").retrieve().body(String.class);
|
||||||
|
|
||||||
|
Response: Hello, you have accessed a secured endpoint!
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// RestClient client = builder.build(); // no basic auth
|
||||||
|
// client.get().uri(baseUrl + "/api/hello").retrieve().body(String.class);
|
||||||
|
|
||||||
|
Thrown: org.springframework.web.client.HttpClientErrorException$Unauthorized
|
||||||
|
Status: 401 UNAUTHORIZED
|
||||||
|
|
||||||
|
# retrieve() throws HttpClientErrorException on 4xx by default -- same default as
|
||||||
|
# RestTemplate, unlike RestClient's own exchange() method, which disables that default.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Thrown: org.springframework.web.client.HttpClientErrorException$Unauthorized
|
||||||
|
Status: 401 UNAUTHORIZED
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?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>restclient-basic-auth</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>restclient-basic-auth</name>
|
||||||
|
<description>Consuming a Basic-Auth-secured REST API with RestClient on Spring Boot 4.1 (RestTemplate is out of the recommended path)</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-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Boot 4 split HTTP client support out of spring-boot-starter-web into its own module;
|
||||||
|
declare it explicitly or the auto-configured RestClient.Builder bean this module relies
|
||||||
|
on simply is not there. Confirmed with mvn dependency:tree against a bare
|
||||||
|
spring-boot-starter-parent:4.1.1 project. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mvn -q -DskipTests package
|
||||||
|
mvn -q test
|
||||||
|
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
nohup java -jar target/restclient-basic-auth-1.0.0.jar > /tmp/restclient-basic-auth.log 2>&1 &
|
||||||
|
echo $! > /tmp/restclient-basic-auth.pid
|
||||||
|
sleep 3
|
||||||
|
echo "Started on :8080 (pid $(cat /tmp/restclient-basic-auth.pid))"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -f /tmp/restclient-basic-auth.pid ]; then
|
||||||
|
kill "$(cat /tmp/restclient-basic-auth.pid)" 2>/dev/null || true
|
||||||
|
rm -f /tmp/restclient-basic-auth.pid
|
||||||
|
fi
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.restclientbasicauth;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class RestclientBasicAuthApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(RestclientBasicAuthApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.ankurm.restclientbasicauth.client;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ApiClient {
|
||||||
|
|
||||||
|
private final RestClient viaDefaultHeaders;
|
||||||
|
private final RestClient viaInterceptor;
|
||||||
|
|
||||||
|
public ApiClient(@Qualifier("basicAuthViaDefaultHeaders") RestClient viaDefaultHeaders,
|
||||||
|
@Qualifier("basicAuthViaInterceptor") RestClient viaInterceptor) {
|
||||||
|
this.viaDefaultHeaders = viaDefaultHeaders;
|
||||||
|
this.viaInterceptor = viaInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String callSecuredEndpointViaDefaultHeaders(String baseUrl) {
|
||||||
|
return viaDefaultHeaders.get()
|
||||||
|
.uri(baseUrl + "/api/hello")
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String callSecuredEndpointViaInterceptor(String baseUrl) {
|
||||||
|
return viaInterceptor.get()
|
||||||
|
.uri(baseUrl + "/api/hello")
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.ankurm.restclientbasicauth.client;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.support.BasicAuthenticationInterceptor;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two ways to add HTTP Basic credentials to every request an injected {@link RestClient} makes --
|
||||||
|
* both built on the auto-configured {@link RestClient.Builder}, never {@code RestClient.create()}
|
||||||
|
* (that bypasses Boot's message converters, observability, and any customizer beans; see
|
||||||
|
* ankurm.com's <a href="https://ankurm.com/resttemplate-to-restclient-migration-guide/">RestTemplate
|
||||||
|
* to RestClient migration guide</a>, which covers this and the exchange() trap in depth).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RestClientConfig {
|
||||||
|
|
||||||
|
/** Idiomatic on RestClient specifically: {@code HttpHeaders.setBasicAuth(...)} via defaultHeaders(). */
|
||||||
|
@Bean
|
||||||
|
@Qualifier("basicAuthViaDefaultHeaders")
|
||||||
|
public RestClient restClientWithDefaultHeaders(RestClient.Builder builder) {
|
||||||
|
return builder
|
||||||
|
.defaultHeaders(headers -> headers.setBasicAuth("admin", "password123"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RestTemplate-era pattern, ported unchanged: {@link BasicAuthenticationInterceptor}
|
||||||
|
* implements {@code ClientHttpRequestInterceptor}, the same interface both RestTemplate and
|
||||||
|
* RestClient accept, so it plugs into {@code RestClient.Builder.requestInterceptor(...)} with
|
||||||
|
* no adaptation needed. Useful if credentials need to be resolved dynamically per request
|
||||||
|
* (a token that rotates, say) rather than fixed at bean-creation time.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Qualifier("basicAuthViaInterceptor")
|
||||||
|
public RestClient restClientWithInterceptor(RestClient.Builder builder) {
|
||||||
|
return builder
|
||||||
|
.requestInterceptor(new BasicAuthenticationInterceptor("admin", "password123"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.restclientbasicauth.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class AppSecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable()) // stateless REST API, no browser form submissions
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/**").authenticated()
|
||||||
|
.anyRequest().permitAll())
|
||||||
|
.httpBasic(Customizer.withDefaults());
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code {noop}} plaintext passwords (as the original article used) still work unchanged on
|
||||||
|
* Boot 4.1: {@link org.springframework.security.crypto.password.NoOpPasswordEncoder} is
|
||||||
|
* {@code @Deprecated} in source but not removed, and -- checked directly by running this
|
||||||
|
* application with one and inspecting the full startup and authentication log output -- it
|
||||||
|
* emits no runtime warning of any kind, at startup or on a successful login. This module uses
|
||||||
|
* a real {@link PasswordEncoder} anyway, not because the old code would warn you, but because
|
||||||
|
* "for demonstration purposes only" comments have a documented habit of reaching production
|
||||||
|
* unchanged. See docs/01-password-encoding.md.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
|
||||||
|
UserDetails user = User.builder()
|
||||||
|
.username("admin")
|
||||||
|
.password(passwordEncoder.encode("password123"))
|
||||||
|
.roles("USER", "ADMIN")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new InMemoryUserDetailsManager(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ankurm.restclientbasicauth.web;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class SecuredController {
|
||||||
|
|
||||||
|
@GetMapping("/api/hello")
|
||||||
|
public String getSecuredGreeting() {
|
||||||
|
return "Hello, you have accessed a secured endpoint!";
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.ankurm.restclientbasicauth;
|
||||||
|
|
||||||
|
import com.ankurm.restclientbasicauth.client.ApiClient;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real embedded Tomcat on a random port, a real Spring Security filter chain, and a real
|
||||||
|
* RestClient making real HTTP calls over loopback -- no MockMvc here, because the point is to
|
||||||
|
* prove the client actually authenticates over the wire, the same way the original article's
|
||||||
|
* CommandLineRunner did against RestTemplate.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
class RestClientBasicAuthTest {
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
int port;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ApiClient apiClient;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
RestClient.Builder builder;
|
||||||
|
|
||||||
|
private String baseUrl() {
|
||||||
|
return "http://localhost:" + port;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultHeadersBasicAuthSucceeds() {
|
||||||
|
String response = apiClient.callSecuredEndpointViaDefaultHeaders(baseUrl());
|
||||||
|
assertThat(response).isEqualTo("Hello, you have accessed a secured endpoint!");
|
||||||
|
|
||||||
|
Transcript.write("01-restclient-basic-auth-default-headers.txt",
|
||||||
|
"// RestClient.Builder builder = ...;\n"
|
||||||
|
+ "// RestClient client = builder.defaultHeaders(h -> h.setBasicAuth(\"admin\", \"password123\")).build();\n"
|
||||||
|
+ "// client.get().uri(baseUrl + \"/api/hello\").retrieve().body(String.class);\n\n"
|
||||||
|
+ "Response: " + response + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void interceptorBasicAuthSucceeds() {
|
||||||
|
String response = apiClient.callSecuredEndpointViaInterceptor(baseUrl());
|
||||||
|
assertThat(response).isEqualTo("Hello, you have accessed a secured endpoint!");
|
||||||
|
|
||||||
|
Transcript.write("02-restclient-basic-auth-interceptor.txt",
|
||||||
|
"// RestClient.Builder builder = ...;\n"
|
||||||
|
+ "// RestClient client = builder.requestInterceptor(\n"
|
||||||
|
+ "// new BasicAuthenticationInterceptor(\"admin\", \"password123\")).build();\n"
|
||||||
|
+ "// client.get().uri(baseUrl + \"/api/hello\").retrieve().body(String.class);\n\n"
|
||||||
|
+ "Response: " + response + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noCredentialsGets401() {
|
||||||
|
RestClient noAuthClient = builder.build(); // the auto-configured builder, no basic auth added
|
||||||
|
|
||||||
|
HttpClientErrorException ex = catchHttpClientErrorException(() ->
|
||||||
|
noAuthClient.get().uri(baseUrl() + "/api/hello").retrieve().body(String.class));
|
||||||
|
|
||||||
|
assertThat(ex.getStatusCode()).isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||||
|
|
||||||
|
Transcript.write("03-restclient-no-credentials-401.txt",
|
||||||
|
"// RestClient client = builder.build(); // no basic auth\n"
|
||||||
|
+ "// client.get().uri(baseUrl + \"/api/hello\").retrieve().body(String.class);\n\n"
|
||||||
|
+ "Thrown: " + ex.getClass().getName() + "\n"
|
||||||
|
+ "Status: " + ex.getStatusCode() + "\n"
|
||||||
|
+ "\n# retrieve() throws HttpClientErrorException on 4xx by default -- same default as\n"
|
||||||
|
+ "# RestTemplate, unlike RestClient's own exchange() method, which disables that default.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongPasswordAlsoGets401() {
|
||||||
|
RestClient wrongPasswordClient = builder
|
||||||
|
.defaultHeaders(h -> h.setBasicAuth("admin", "not-the-password"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpClientErrorException ex = catchHttpClientErrorException(() ->
|
||||||
|
wrongPasswordClient.get().uri(baseUrl() + "/api/hello").retrieve().body(String.class));
|
||||||
|
|
||||||
|
assertThat(ex.getStatusCode()).isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||||
|
|
||||||
|
Transcript.write("04-restclient-wrong-password-401.txt",
|
||||||
|
"Thrown: " + ex.getClass().getName() + "\n"
|
||||||
|
+ "Status: " + ex.getStatusCode() + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClientErrorException catchHttpClientErrorException(ThrowingRunnable runnable) {
|
||||||
|
try {
|
||||||
|
runnable.run();
|
||||||
|
} catch (HttpClientErrorException e) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected HttpClientErrorException but none was thrown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.restclientbasicauth;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user