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,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