Add custom-validation, etag-caching, restclient-basic-auth: Boot 4.1 API pass

Three companion modules verifying and rewriting the Boot 4.1.1 / Framework
7.0.9 story for three older articles: the javax->jakarta.validation namespace
fix plus Jakarta Validation 3.1 record-validation clarification, ETag/
conditional-request APIs re-verified unchanged plus the starter rename, and
RestTemplate Basic Auth rebuilt on RestClient with the exchange() trap called
out. 19 real passing tests generate every transcript quoted from the three
companion articles.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
Claude
2026-09-19 10:17:09 +00:00
parent 03bdf7ee87
commit e4b5636f7c
75 changed files with 2374 additions and 0 deletions
@@ -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)