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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user