Add custom-validation, etag-caching, restclient-basic-auth: Boot 4.1 API pass
Three companion modules verifying and rewriting the Boot 4.1.1 / Framework 7.0.9 story for three older articles: the javax->jakarta.validation namespace fix plus Jakarta Validation 3.1 record-validation clarification, ETag/ conditional-request APIs re-verified unchanged plus the starter rename, and RestTemplate Basic Auth rebuilt on RestClient with the exchange() trap called out. 19 real passing tests generate every transcript quoted from the three companion articles. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user