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,71 @@
package com.ankurm.customvalidation;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Reads the real jar manifests off the classpath directly with {@link JarFile}, rather than
* trusting a version number typed into a pom.xml or a blog post -- the same "unzip the jar"
* standard used for the version-of-record facts elsewhere in this repo. {@code
* Class.getResourceAsStream("/META-INF/MANIFEST.MF")} is NOT reliable for this on a flat
* classpath (as opposed to the module path): it resolves to whichever jar's manifest the
* classloader happens to find first, not necessarily the jar the anchor class was loaded from.
* Opening the anchor class's own code-source location as a {@link JarFile} is unambiguous.
*/
class ClasspathVersionTest {
private static Manifest manifestOf(Class<?> anchor) throws IOException, URISyntaxException {
Path jarPath = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
try (JarFile jar = new JarFile(jarPath.toFile())) {
return jar.getManifest();
}
}
@Test
void jakartaValidationApiIs3_1() throws IOException, URISyntaxException {
Class<?> anchor = jakarta.validation.Validation.class;
Path jarPath = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
Manifest mf = manifestOf(anchor);
Attributes attrs = mf.getMainAttributes();
StringBuilder sb = new StringBuilder();
sb.append("Class: ").append(anchor.getName()).append('\n');
sb.append("Jar file: ").append(jarPath.getFileName()).append('\n');
sb.append("Bundle-SymbolicName: ").append(attrs.getValue("Bundle-SymbolicName")).append('\n');
sb.append("Bundle-Version: ").append(attrs.getValue("Bundle-Version")).append('\n');
sb.append("Implementation-Version: ").append(attrs.getValue("Implementation-Version")).append('\n');
Transcript.write("00-jakarta-validation-api-manifest.txt", sb.toString());
// The jar file name and its own manifest both say 3.1.x -- Spring Boot 4.1.1 pins
// Jakarta Validation 3.1 (renamed from "Bean Validation" in the 3.1 spec revision),
// confirmed two independent ways rather than one.
assertThat(jarPath.getFileName().toString()).startsWith("jakarta.validation-api-3.1");
}
@Test
void hibernateValidatorIs9_1() throws IOException, URISyntaxException {
Class<?> anchor = org.hibernate.validator.internal.engine.ValidatorFactoryImpl.class;
Path jarPath = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
Manifest mf = manifestOf(anchor);
Attributes attrs = mf.getMainAttributes();
StringBuilder sb = new StringBuilder();
sb.append("Class: ").append(anchor.getName()).append('\n');
sb.append("Jar file: ").append(jarPath.getFileName()).append('\n');
sb.append("Implementation-Title: ").append(attrs.getValue("Implementation-Title")).append('\n');
sb.append("Implementation-Version: ").append(attrs.getValue("Implementation-Version")).append('\n');
Transcript.write("00b-hibernate-validator-manifest.txt", sb.toString());
assertThat(jarPath.getFileName().toString()).startsWith("hibernate-validator-9.1");
}
}
@@ -0,0 +1,64 @@
package com.ankurm.customvalidation;
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.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* What the default in {@link ValidationScenariosTest#recordBasedSpamMessageRejectedButWithNoErrorBodyByDefault}
* does not do: emit a structured error body for a validation failure that has no BindingResult to
* carry it. That structured body exists, but it is opt-in behind
* {@code spring.mvc.problemdetails.enabled=true} -- unrelated to Bean Validation 3.1 itself, and
* a property that already existed in Boot 3, but worth verifying directly rather than assuming it
* changes the earlier empty-body result, because it does not touch content negotiation, only
* whether a body is produced at all.
*/
@SpringBootTest(properties = "spring.mvc.problemdetails.enabled=true")
@AutoConfigureMockMvc
class ProblemDetailsEnabledTest {
@Autowired
MockMvc mockMvc;
@Test
void recordValidationFailureNowGetsAProblemDetailBody() throws Exception {
var result = mockMvc.perform(post("/contact-record")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
// A real body now exists -- progress over the empty one -- but it is a GENERIC
// ProblemDetail with no mention of "spam" or which field failed. Spring's default
// MethodArgumentNotValidException -> ProblemDetail mapping does not populate per-field
// messages for you; that needs a custom @ExceptionHandler (or ResponseEntityExceptionHandler
// override) that reads bindingResult.getFieldErrors() into the ProblemDetail's properties.
assertThat(body).isNotEmpty();
assertThat(body).doesNotContain("spam");
assertThat(body).contains("\"status\":400");
Transcript.write("07-problemdetails-enabled-record-rejected.txt",
"# application.yaml: spring.mvc.problemdetails.enabled: true (opt-in; unrelated to Bean\n"
+ "# Validation 3.1 itself -- this property already existed in Boot 3)\n\n"
+ "$ curl -s -X POST localhost:8080/contact-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Content-Type: " + result.getResponse().getContentType() + "\n"
+ "Body: " + body + "\n"
+ "\n# Progress over the empty body, but notice what is MISSING: no mention of \"spam\", no field\n"
+ "# name. Boot's default MethodArgumentNotValidException -> ProblemDetail mapping fills in\n"
+ "# only the generic RFC 9457 fields (title, status, detail=\"Invalid request content.\").\n"
+ "# Per-field messages -- what the class-based /contact endpoint hand-rolls from\n"
+ "# bindingResult.getFieldErrors() -- need a custom @ExceptionHandler that does the same\n"
+ "# thing into the ProblemDetail's own \"properties\" map. Turning the property on is not,\n"
+ "# by itself, a drop-in replacement for BindingResult-based error reporting.\n");
}
}
@@ -0,0 +1,23 @@
package com.ankurm.customvalidation;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/** Writes docs/output/NN-name.txt so every number and message quoted in the post is an assertion. */
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);
}
}
}
@@ -0,0 +1,164 @@
package com.ankurm.customvalidation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
// Boot 4.1 moved this out of org.springframework.boot.test.autoconfigure.web.servlet into its own
// package (confirmed by listing the real jar contents, not read off a migration guide) -- see
// docs/03-mockmvc-autoconfigure-package-moved.md.
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class ValidationScenariosTest {
@Autowired
MockMvc mockMvc;
@Test
void classBasedSpamMessageRejected() throws Exception {
var result = mockMvc.perform(post("/contact")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
assertThat(body).contains("Message contains 'spam'");
Transcript.write("01-class-based-spam-rejected.txt",
"$ curl -s -X POST localhost:8080/contact \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + body + "\n");
}
@Test
void classBasedValidMessageAccepted() throws Exception {
var result = mockMvc.perform(post("/contact")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is a legitimate message about an issue I'm facing.\"}"))
.andExpect(status().isOk())
.andReturn();
Transcript.write("02-class-based-valid-accepted.txt",
"$ curl -s -X POST localhost:8080/contact \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is a legitimate message about an issue I'm facing.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n");
}
@Test
void recordBasedSpamMessageRejectedButWithNoErrorBodyByDefault() throws Exception {
var result = mockMvc.perform(post("/contact-record")
.contentType("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
// The controller has no BindingResult parameter for a record the way ContactController's
// class-based method does, so a failing constraint throws MethodArgumentNotValidException
// instead of populating a result object -- and Boot 4.1's default handler for that
// exception, with no Accept header requesting a structured error body, returns an EMPTY
// 400 body. This surprised me; verified below with an explicit Accept: application/json.
assertThat(body).isEmpty();
Transcript.write("03-record-based-spam-rejected.txt",
"$ curl -s -i -X POST localhost:8080/contact-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: '" + body + "' (empty!)\n"
+ "\n# The record parameter has no BindingResult to collect field errors into, unlike\n"
+ "# ContactController#submitContactForm's class-based, BindingResult-carrying signature.\n"
+ "# A failing constraint throws MethodArgumentNotValidException instead, and Boot 4.1's\n"
+ "# default handling for it returns an EMPTY body when the request has no Accept header\n"
+ "# asking for a structured error. See the next transcript for what changes with one.\n");
}
@Test
void recordBasedSpamMessageRejectedStillEmptyWithAcceptJson() throws Exception {
// Checked directly rather than assumed: an Accept header alone does NOT turn on a
// structured error body. See ProblemDetailsEnabledTest for the property that does.
var result = mockMvc.perform(post("/contact-record")
.contentType("application/json")
.accept("application/json")
.content("{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}"))
.andExpect(status().isBadRequest())
.andReturn();
String body = result.getResponse().getContentAsString();
assertThat(body).isEmpty();
Transcript.write("03b-record-based-spam-rejected-accept-json.txt",
"$ curl -s -X POST localhost:8080/contact-record \\\n"
+ " -H 'Content-Type: application/json' -H 'Accept: application/json' \\\n"
+ " -d '{\"email\":\"[email protected]\",\"message\":\"This is spam.\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Content-Type: " + result.getResponse().getContentType() + "\n"
+ "Body: '" + body + "' (still empty!)\n"
+ "\n# An Accept header alone changes nothing -- the body is still empty. What actually turns\n"
+ "# on a structured error body is the spring.mvc.problemdetails.enabled property, which is\n"
+ "# off by default and unrelated to content negotiation. See\n"
+ "# docs/output/07-problemdetails-enabled-record-rejected.txt for the same request with it on.\n");
}
@Test
void classBasedBadDateRangeRejected() throws Exception {
var result = mockMvc.perform(post("/booking")
.contentType("application/json")
.content("{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}"))
.andExpect(status().isBadRequest())
.andReturn();
Transcript.write("04-class-based-bad-date-range.txt",
"$ curl -s -X POST localhost:8080/booking \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n");
}
@Test
void recordBasedBadDateRangeRejectedTheSameWay() throws Exception {
var result = mockMvc.perform(post("/booking-record")
.contentType("application/json")
.content("{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}"))
.andExpect(status().isBadRequest())
.andReturn();
Transcript.write("05-record-based-bad-date-range.txt",
"$ curl -s -X POST localhost:8080/booking-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"startDate\":\"2026-05-10\",\"endDate\":\"2026-05-01\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n"
+ "\n# Class-level @DateRangeValid, placed on the record's type declaration exactly as it would\n"
+ "# be on a class, is honoured the same way. The validator reads booking.startDate() /\n"
+ "# booking.endDate() (accessor methods) instead of getStartDate()/getEndDate().\n");
}
@Test
void recordBasedGoodDateRangeAccepted() throws Exception {
var result = mockMvc.perform(post("/booking-record")
.contentType("application/json")
.content("{\"startDate\":\"2026-05-01\",\"endDate\":\"2026-05-10\"}"))
.andExpect(status().isOk())
.andReturn();
Transcript.write("06-record-based-good-date-range.txt",
"$ curl -s -X POST localhost:8080/booking-record \\\n"
+ " -H 'Content-Type: application/json' \\\n"
+ " -d '{\"startDate\":\"2026-05-01\",\"endDate\":\"2026-05-10\"}'\n\n"
+ "HTTP status: " + result.getResponse().getStatus() + "\n"
+ "Body: " + result.getResponse().getContentAsString() + "\n");
}
}