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
+54
View File
@@ -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)
+49
View File
@@ -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>
+6
View File
@@ -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/"
+7
View File
@@ -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))"
+6
View File
@@ -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);
}
}
}