diff --git a/README.md b/README.md index 332a429..6c7969f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ files. | [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown | | [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/springdoc-openapi-spring-boot-4-1-versioning/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions | | [`graphql-dataloader/`](graphql-dataloader) | [Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1](https://ankurm.com/) | a naive `@SchemaMapping` resolver measured at 21 SQL statements for 20 books versus a `@BatchMapping` resolver's flat 2, a dangling foreign key nulling an entire GraphQL response via non-null propagation identically under both resolver strategies, and two Spring Boot 4.1 packaging changes (`DataSourceAutoConfiguration`'s new package, Jackson 3 by default) hit along the way | +| [`custom-validation/`](custom-validation) | [Custom Validation in Spring Boot: Beyond the Basics!](https://ankurm.com/custom-validation-in-spring-boot-beyond-the-basics/) | the `javax.validation` to `jakarta.validation` namespace fix Boot 3 already required, Jakarta Validation 3.1's record-validation clarification proven on both field- and class-level custom constraints, a record validation failure's empty 400 body by default, and what `spring.mvc.problemdetails.enabled` does and does not fix | +| [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking | +| [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) | Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/custom-validation/README.md b/custom-validation/README.md new file mode 100644 index 0000000..dac25b7 --- /dev/null +++ b/custom-validation/README.md @@ -0,0 +1,66 @@ +# custom-validation + +Companion module for [**Custom Validation in Spring Boot: Beyond the Basics!**](https://ankurm.com/custom-validation-in-spring-boot-beyond-the-basics/) +on ankurm.com, rewritten around **Jakarta Validation 3.1** (Spring Boot 4.1.1 / Hibernate Validator +9.1.3.Final) and the `jakarta.validation` namespace the original article's `javax.validation` code +never actually ran under on Boot 3+. + +`mvn test` regenerates every transcript in [`docs/output/`](docs/output) -- the test suite is the +transcript generator. + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| Hibernate Validator | 9.1.3.Final (`jakarta.validation-api` 3.1.1) -- confirmed with `mvn dependency:tree` and by reading the jars' own manifests, see [docs/01](docs/01-jakarta-namespace-and-bean-validation-3-1.md) | +| JDK | Eclipse Temurin 25.0.4.1 (LTS) | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn -DskipTests package +./scripts/run.sh # port 8080 +curl -s -X POST localhost:8080/contact -H 'Content-Type: application/json' \ + -d '{"email":"user@example.com","message":"hello there, this message is long enough"}' +mvn test # 10 tests, regenerates docs/output/ +``` + +## Endpoints + +| Endpoint | Shows | +|---|---| +| `POST /contact` | field-level custom constraint (`@SpamMessageCheck`), class-based DTO, `BindingResult` | +| `POST /contact-record` | the same constraint on a record component | +| `POST /booking` | class-level (cross-field) custom constraint (`@DateRangeValid`), class-based DTO | +| `POST /booking-record` | the same cross-field constraint on a record's type declaration | +| `GET /diagnostic/validation-provider` | prints the real Bean Validation provider and spec version at runtime -- delete before shipping | + +## Documentation + +1. [The jakarta.validation namespace, and what Bean Validation 3.1 actually changed](docs/01-jakarta-namespace-and-bean-validation-3-1.md) +2. [Record validation, field-level and class-level](docs/02-record-validation.md) +3. [What the defaults do not do: record validation failures with no error body](docs/03-what-the-defaults-do-not-do.md) + +## Findings worth the trip + +- **The original article's `javax.validation.*` imports never worked on Spring Boot 3+.** Boot 3 + moved entirely to the `jakarta.*` namespace in December 2022; this module is the corrected, + runnable version. +- **Spring Boot 4.1.1 pins Hibernate Validator 9.1.3.Final / Jakarta Validation 3.1**, confirmed + by `mvn dependency:tree` and independently by reading the actual jar manifests at test time. +- **A record validation failure with no `BindingResult` in the controller signature returns an + EMPTY 400 body by default** -- not a JSON error, not even with `Accept: application/json`. +- **`spring.mvc.problemdetails.enabled=true` fixes the empty body but not the missing detail**: + the resulting `ProblemDetail` is generic (`"Invalid request content."`), with no per-field + messages, unless you write a custom `@ExceptionHandler` to put them there yourself. +- **`AutoConfigureMockMvc` moved packages in Boot 4.1**, from + `org.springframework.boot.test.autoconfigure.web.servlet` to + `org.springframework.boot.webmvc.test.autoconfigure` -- and `spring-boot-starter-test` alone no + longer pulls in MockMvc's autoconfiguration; that needs `spring-boot-starter-webmvc-test`. + +## License + +MIT -- see [LICENSE](../LICENSE). diff --git a/custom-validation/docs/01-jakarta-namespace-and-bean-validation-3-1.md b/custom-validation/docs/01-jakarta-namespace-and-bean-validation-3-1.md new file mode 100644 index 0000000..6a40a77 --- /dev/null +++ b/custom-validation/docs/01-jakarta-namespace-and-bean-validation-3-1.md @@ -0,0 +1,78 @@ +# 1. The jakarta.validation namespace, and what Bean Validation 3.1 actually changed + +[README](../README.md) | Next: [Record validation](02-record-validation.md) + +Source: [`ContactForm.java`](../src/main/java/com/ankurm/customvalidation/dto/ContactForm.java), +[`SpamMessageCheck.java`](../src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java). +Transcripts: [`docs/output/00-jakarta-validation-api-manifest.txt`](output/00-jakarta-validation-api-manifest.txt), +[`docs/output/00b-hibernate-validator-manifest.txt`](output/00b-hibernate-validator-manifest.txt). + +## The defect this module replaces + +The original version of the companion article this module backs used +`javax.validation.constraints.NotBlank`, `javax.validation.Constraint`, and so on throughout -- +the pre-Jakarta-EE-9 namespace. That package was already wrong for any Spring Boot 3+ application +by the time the article was first published: Spring Boot 3.0 moved its entire dependency tree from +`javax.*` to `jakarta.*` in December 2022, and `javax.validation.*` classes are simply not on the +classpath of a `spring-boot-starter-validation` 3.x or 4.x application. Every class in this module +uses `jakarta.validation.*` instead. If you have a codebase still on `javax.validation`, it did not +survive the Boot 2 to 3 upgrade and needs the same mechanical rename this module already reflects. + +## What Spring Boot 4.1.1 actually pins, checked directly + +Rather than trust a blog's version claim (including an older draft of this one), the fact was +checked the way this repository always checks it: build a throwaway project against +`spring-boot-starter-parent:4.1.1` and run `mvn dependency:tree`. + +``` +[INFO] +- org.springframework.boot:spring-boot-starter-validation:jar:4.1.1:compile +[INFO] | \- org.springframework.boot:spring-boot-validation:jar:4.1.1:compile +[INFO] | \- org.hibernate.validator:hibernate-validator:jar:9.1.3.Final:compile +[INFO] | +- jakarta.validation:jakarta.validation-api:jar:3.1.1:compile +``` + +Then confirmed a second, independent way: open the actual jars this application loads at runtime +and read their own manifests, rather than trusting the dependency tree's coordinates alone. + +``` +$ mvn test # ClasspathVersionTest +``` + +
Class: jakarta.validation.Validation
+Jar file: jakarta.validation-api-3.1.1.jar
+Bundle-SymbolicName: jakarta.validation.jakarta.validation-api
+Bundle-Version: 3.1.1
+Implementation-Version: null
+ +([`docs/output/00-jakarta-validation-api-manifest.txt`](output/00-jakarta-validation-api-manifest.txt)) + +
Class: org.hibernate.validator.internal.engine.ValidatorFactoryImpl
+Jar file: hibernate-validator-9.1.3.Final.jar
+Implementation-Title: hibernate-validator
+Implementation-Version: 9.1.3.Final
+ +([`docs/output/00b-hibernate-validator-manifest.txt`](output/00b-hibernate-validator-manifest.txt)) + +Spring Boot 4.1.1 pins **Hibernate Validator 9.1.3.Final**, the reference implementation of +**Jakarta Validation 3.1** -- the spec revision released in 2024 as part of Jakarta EE 11. + +## What is actually new in 3.1 (not a rename of 3.0) + +Three real changes, not a version-number bump for its own sake: + +- **The specification itself was renamed** from "Jakarta Bean Validation" to "Jakarta Validation." + The namespace (`jakarta.validation.*`) and the annotations you already know + (`@NotBlank`, `@Size`, `@Valid`, `@Constraint`) are unchanged -- this is a spec-title change, not + an API break. +- **The minimum required Java version moved to 17.** Not a concern for anything already running + JDK 21 or 25, but it is the reason Hibernate Validator 9.x cannot be backported to run on + Java 11. +- **Record validation is now explicitly specified**, closing a real gap in 3.0 where the spec was + silent on how a constraint on a record component should behave. That is substantial enough to + earn its own chapter -- see below. + +## Going deeper + +- [Jakarta Validation news and release history](https://beanvalidation.org/news/) (rel="nofollow") +- [Hibernate Validator reference guide](https://docs.hibernate.org/stable/validator/reference/en-US/html_single/) (rel="nofollow") +- Next: [Record validation](02-record-validation.md) diff --git a/custom-validation/docs/02-record-validation.md b/custom-validation/docs/02-record-validation.md new file mode 100644 index 0000000..8f0cfd3 --- /dev/null +++ b/custom-validation/docs/02-record-validation.md @@ -0,0 +1,95 @@ +# 2. Record validation, field-level and class-level + +[Prev: The jakarta.validation namespace](01-jakarta-namespace-and-bean-validation-3-1.md) | [README](../README.md) | Next: [What the defaults do not do](03-what-the-defaults-do-not-do.md) + +Source: [`ContactFormRecord.java`](../src/main/java/com/ankurm/customvalidation/dto/ContactFormRecord.java), +[`EventBookingRecord.java`](../src/main/java/com/ankurm/customvalidation/dto/EventBookingRecord.java), +[`DateRangeValid.java`](../src/main/java/com/ankurm/customvalidation/validator/DateRangeValid.java). +Test: [`ValidationScenariosTest.java`](../src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java). +Transcripts: [`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt), +[`docs/output/05-record-based-bad-date-range.txt`](output/05-record-based-bad-date-range.txt), +[`docs/output/06-record-based-good-date-range.txt`](output/06-record-based-good-date-range.txt). + +## Field-level: a constraint on a record component + +`ContactFormRecord` carries the same three constraints as the class-based `ContactForm` -- +`@NotBlank`, `@Email`, `@SpamMessageCheck` -- placed directly on record components instead of +fields: + +```java +public record ContactFormRecord( + @NotBlank(message = "Email cannot be empty!") + @Email(message = "Please provide a valid email address.") + String email, + + @NotBlank(message = "Message cannot be empty!") + @Size(min = 10, message = "Message must be at least 10 characters long.") + @SpamMessageCheck + String message +) { +} +``` + +This only compiles because `@SpamMessageCheck`'s `@Target` includes `RECORD_COMPONENT` (see +[`SpamMessageCheck.java`](../src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java)). +Forget that element type on a custom constraint and the annotation still compiles fine on a class +field -- it simply cannot be placed on a record component at all, a compile error rather than a +silent no-op. Built-in constraints like `@NotBlank` already declare `RECORD_COMPONENT`, which is +why they worked on a hand-rolled record before this module ever touched the topic; a homemade +constraint needs the same declaration deliberately added. + +Rejection behaves identically to the class-based version at the validation layer -- same +constraint, same message: + +``` +POST /contact-record +{"email":"spammy@email.com","message":"This is spam."} +``` + +[`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt) shows +the one thing that is *not* identical: the HTTP response body. See +[the next chapter](03-what-the-defaults-do-not-do.md) for why. + +## Class-level (cross-field): a constraint on the record's type + +`EventBookingRecord` carries `@DateRangeValid` on the record's type declaration -- the same +position a class-level constraint occupies on an ordinary class: + +```java +@DateRangeValid +public record EventBookingRecord( + @NotNull(message = "startDate is required") LocalDate startDate, + @NotNull(message = "endDate is required") LocalDate endDate +) { +} +``` + +The one code change a cross-field validator needs to support both shapes is in the validator +itself, not the annotation -- [`DateRangeValidator`](../src/main/java/com/ankurm/customvalidation/validator/DateRangeValidator.java) +dispatches on the runtime type and reads accessor methods that differ by naming convention only: + +```java +if (value instanceof EventBooking booking) { + return booking.getEndDate().isAfter(booking.getStartDate()); +} +if (value instanceof EventBookingRecord booking) { + return booking.endDate().isAfter(booking.startDate()); // record accessors, no "get" prefix +} +``` + +Both shapes reject the same bad input the same way: + +``` +POST /booking-record +{"startDate":"2026-05-10","endDate":"2026-05-01"} +``` + +HTTP 400 -- [`docs/output/05-record-based-bad-date-range.txt`](output/05-record-based-bad-date-range.txt) -- +and a correctly-ordered pair is accepted: +[`docs/output/06-record-based-good-date-range.txt`](output/06-record-based-good-date-range.txt). + +## Going deeper + +- Records themselves: [Java Records (JEP 395)](https://openjdk.org/jeps/395) (rel="nofollow") +- Prev: [The jakarta.validation namespace](01-jakarta-namespace-and-bean-validation-3-1.md) +- Next: [What the defaults do not do](03-what-the-defaults-do-not-do.md) diff --git a/custom-validation/docs/03-what-the-defaults-do-not-do.md b/custom-validation/docs/03-what-the-defaults-do-not-do.md new file mode 100644 index 0000000..c9e24b2 --- /dev/null +++ b/custom-validation/docs/03-what-the-defaults-do-not-do.md @@ -0,0 +1,77 @@ +# 3. What the defaults do not do: record validation failures with no error body + +[Prev: Record validation](02-record-validation.md) | [README](../README.md) + +Source: [`ContactController.java`](../src/main/java/com/ankurm/customvalidation/web/ContactController.java). +Test: [`ValidationScenariosTest.java`](../src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java), +[`ProblemDetailsEnabledTest.java`](../src/test/java/com/ankurm/customvalidation/ProblemDetailsEnabledTest.java). +Transcripts: [`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt), +[`docs/output/03b-record-based-spam-rejected-accept-json.txt`](output/03b-record-based-spam-rejected-accept-json.txt), +[`docs/output/07-problemdetails-enabled-record-rejected.txt`](output/07-problemdetails-enabled-record-rejected.txt). + +## The surprise this chapter exists to document + +`ContactController#submitContactForm` (the class-based endpoint) declares a `BindingResult` +parameter immediately after `@Valid ContactForm`. That is what lets it inspect +`bindingResult.getFieldErrors()` and hand back a `{"message": "..."}` body of its own construction. +A record parameter has nowhere convenient to put an equivalent `BindingResult` in this codebase's +controller signatures, so `submitContactFormRecord` has none -- and a failing constraint on the +record path throws `MethodArgumentNotValidException` instead of populating a result object. + +The assumption going in was that Spring's default handling of that exception would still produce +*some* readable body. It does not, by default: + +``` +$ curl -s -i -X POST localhost:8080/contact-record \ + -H 'Content-Type: application/json' \ + -d '{"email":"spammy@email.com","message":"This is spam."}' + +HTTP status: 400 +Body: '' (empty!) +``` + +([`docs/output/03-record-based-spam-rejected.txt`](output/03-record-based-spam-rejected.txt)) + +
An Accept: application/json header does not change this. It was tested +directly rather than assumed -- see +docs/output/03b-record-based-spam-rejected-accept-json.txt. +The empty body is not a content-negotiation problem; nothing is being negotiated because nothing is +being written.
+ +## The property that changes this, and what it still does not give you + +`spring.mvc.problemdetails.enabled=true` (a property that already existed in Spring Boot 3, not +new in 4.1) turns on RFC 9457 `ProblemDetail` responses for framework-thrown MVC exceptions, +`MethodArgumentNotValidException` included: + +``` +HTTP status: 400 +Content-Type: application/problem+json +Body: {"detail":"Invalid request content.","instance":"/contact-record","status":400,"title":"Bad Request"} +``` + +([`docs/output/07-problemdetails-enabled-record-rejected.txt`](output/07-problemdetails-enabled-record-rejected.txt)) + +Progress -- there is now a body, and a real HTTP status-coded RFC 9457 document -- but read it +closely: no mention of "spam", no field name, no constraint message. Boot's default mapping fills +in only the generic fields (`title`, `status`, a fixed `detail` string). The per-field detail the +class-based endpoint hand-rolls from `bindingResult.getFieldErrors()` is not reproduced +automatically; getting it back needs a custom `@ExceptionHandler` (or a +`ResponseEntityExceptionHandler` override of `handleMethodArgumentNotValid`) that reads the +exception's `BindingResult` -- the exception itself still carries one, even though the controller +signature does not expose it -- and writes the field errors into the `ProblemDetail`'s own +`properties` map. + +
Turning on spring.mvc.problemdetails.enabled is not, by itself, a +drop-in replacement for the BindingResult-based error reporting pattern most +Spring tutorials (including the earlier version of this one) teach. It replaces an empty body with +a standardised envelope; it does not replace the work of putting your validation messages inside +that envelope.
+ +## Going deeper + +- [Spring Framework `ProblemDetail` reference](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html) (rel="nofollow") +- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457) (rel="nofollow") +- See also this repo's [`problem-details/`](../problem-details) module, built for a different + ankurm.com article specifically about `ProblemDetail` and global exception handling in depth. +- Prev: [Record validation](02-record-validation.md) diff --git a/custom-validation/docs/output/00-jakarta-validation-api-manifest.txt b/custom-validation/docs/output/00-jakarta-validation-api-manifest.txt new file mode 100644 index 0000000..71925de --- /dev/null +++ b/custom-validation/docs/output/00-jakarta-validation-api-manifest.txt @@ -0,0 +1,5 @@ +Class: jakarta.validation.Validation +Jar file: jakarta.validation-api-3.1.1.jar +Bundle-SymbolicName: jakarta.validation.jakarta.validation-api +Bundle-Version: 3.1.1 +Implementation-Version: null diff --git a/custom-validation/docs/output/00b-hibernate-validator-manifest.txt b/custom-validation/docs/output/00b-hibernate-validator-manifest.txt new file mode 100644 index 0000000..690574b --- /dev/null +++ b/custom-validation/docs/output/00b-hibernate-validator-manifest.txt @@ -0,0 +1,4 @@ +Class: org.hibernate.validator.internal.engine.ValidatorFactoryImpl +Jar file: hibernate-validator-9.1.3.Final.jar +Implementation-Title: hibernate-validator +Implementation-Version: 9.1.3.Final diff --git a/custom-validation/docs/output/01-class-based-spam-rejected.txt b/custom-validation/docs/output/01-class-based-spam-rejected.txt new file mode 100644 index 0000000..62bd6e8 --- /dev/null +++ b/custom-validation/docs/output/01-class-based-spam-rejected.txt @@ -0,0 +1,6 @@ +$ curl -s -X POST localhost:8080/contact \ + -H 'Content-Type: application/json' \ + -d '{"email":"spammy@email.com","message":"This is spam."}' + +HTTP status: 400 +Body: {"message":"Message contains 'spam' and must be at least 50 characters long."} diff --git a/custom-validation/docs/output/02-class-based-valid-accepted.txt b/custom-validation/docs/output/02-class-based-valid-accepted.txt new file mode 100644 index 0000000..f70c838 --- /dev/null +++ b/custom-validation/docs/output/02-class-based-valid-accepted.txt @@ -0,0 +1,6 @@ +$ curl -s -X POST localhost:8080/contact \ + -H 'Content-Type: application/json' \ + -d '{"email":"user@example.com","message":"This is a legitimate message about an issue I'm facing."}' + +HTTP status: 200 +Body: Contact form submitted successfully! diff --git a/custom-validation/docs/output/03-record-based-spam-rejected.txt b/custom-validation/docs/output/03-record-based-spam-rejected.txt new file mode 100644 index 0000000..d953328 --- /dev/null +++ b/custom-validation/docs/output/03-record-based-spam-rejected.txt @@ -0,0 +1,12 @@ +$ curl -s -i -X POST localhost:8080/contact-record \ + -H 'Content-Type: application/json' \ + -d '{"email":"spammy@email.com","message":"This is spam."}' + +HTTP status: 400 +Body: '' (empty!) + +# The record parameter has no BindingResult to collect field errors into, unlike +# ContactController#submitContactForm's class-based, BindingResult-carrying signature. +# A failing constraint throws MethodArgumentNotValidException instead, and Boot 4.1's +# default handling for it returns an EMPTY body when the request has no Accept header +# asking for a structured error. See the next transcript for what changes with one. diff --git a/custom-validation/docs/output/03b-record-based-spam-rejected-accept-json.txt b/custom-validation/docs/output/03b-record-based-spam-rejected-accept-json.txt new file mode 100644 index 0000000..be7b459 --- /dev/null +++ b/custom-validation/docs/output/03b-record-based-spam-rejected-accept-json.txt @@ -0,0 +1,12 @@ +$ curl -s -X POST localhost:8080/contact-record \ + -H 'Content-Type: application/json' -H 'Accept: application/json' \ + -d '{"email":"spammy@email.com","message":"This is spam."}' + +HTTP status: 400 +Content-Type: null +Body: '' (still empty!) + +# An Accept header alone changes nothing -- the body is still empty. What actually turns +# on a structured error body is the spring.mvc.problemdetails.enabled property, which is +# off by default and unrelated to content negotiation. See +# docs/output/07-problemdetails-enabled-record-rejected.txt for the same request with it on. diff --git a/custom-validation/docs/output/04-class-based-bad-date-range.txt b/custom-validation/docs/output/04-class-based-bad-date-range.txt new file mode 100644 index 0000000..56f8632 --- /dev/null +++ b/custom-validation/docs/output/04-class-based-bad-date-range.txt @@ -0,0 +1,6 @@ +$ curl -s -X POST localhost:8080/booking \ + -H 'Content-Type: application/json' \ + -d '{"startDate":"2026-05-10","endDate":"2026-05-01"}' + +HTTP status: 400 +Body: diff --git a/custom-validation/docs/output/05-record-based-bad-date-range.txt b/custom-validation/docs/output/05-record-based-bad-date-range.txt new file mode 100644 index 0000000..f893e1e --- /dev/null +++ b/custom-validation/docs/output/05-record-based-bad-date-range.txt @@ -0,0 +1,10 @@ +$ curl -s -X POST localhost:8080/booking-record \ + -H 'Content-Type: application/json' \ + -d '{"startDate":"2026-05-10","endDate":"2026-05-01"}' + +HTTP status: 400 +Body: + +# Class-level @DateRangeValid, placed on the record's type declaration exactly as it would +# be on a class, is honoured the same way. The validator reads booking.startDate() / +# booking.endDate() (accessor methods) instead of getStartDate()/getEndDate(). diff --git a/custom-validation/docs/output/06-record-based-good-date-range.txt b/custom-validation/docs/output/06-record-based-good-date-range.txt new file mode 100644 index 0000000..70d623e --- /dev/null +++ b/custom-validation/docs/output/06-record-based-good-date-range.txt @@ -0,0 +1,6 @@ +$ curl -s -X POST localhost:8080/booking-record \ + -H 'Content-Type: application/json' \ + -d '{"startDate":"2026-05-01","endDate":"2026-05-10"}' + +HTTP status: 200 +Body: Booking accepted: 2026-05-01 -> 2026-05-10 diff --git a/custom-validation/docs/output/07-problemdetails-enabled-record-rejected.txt b/custom-validation/docs/output/07-problemdetails-enabled-record-rejected.txt new file mode 100644 index 0000000..072ef4e --- /dev/null +++ b/custom-validation/docs/output/07-problemdetails-enabled-record-rejected.txt @@ -0,0 +1,18 @@ +# application.yaml: spring.mvc.problemdetails.enabled: true (opt-in; unrelated to Bean +# Validation 3.1 itself -- this property already existed in Boot 3) + +$ curl -s -X POST localhost:8080/contact-record \ + -H 'Content-Type: application/json' \ + -d '{"email":"spammy@email.com","message":"This is spam."}' + +HTTP status: 400 +Content-Type: application/problem+json +Body: {"detail":"Invalid request content.","instance":"/contact-record","status":400,"title":"Bad Request"} + +# Progress over the empty body, but notice what is MISSING: no mention of "spam", no field +# name. Boot's default MethodArgumentNotValidException -> ProblemDetail mapping fills in +# only the generic RFC 9457 fields (title, status, detail="Invalid request content."). +# Per-field messages -- what the class-based /contact endpoint hand-rolls from +# bindingResult.getFieldErrors() -- need a custom @ExceptionHandler that does the same +# thing into the ProblemDetail's own "properties" map. Turning the property on is not, +# by itself, a drop-in replacement for BindingResult-based error reporting. diff --git a/custom-validation/pom.xml b/custom-validation/pom.xml new file mode 100644 index 0000000..95ff48b --- /dev/null +++ b/custom-validation/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + custom-validation + 1.0.0 + custom-validation + Custom Bean Validation constraints on Spring Boot 4.1 / Jakarta Validation 3.1 + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/custom-validation/scripts/run-all.sh b/custom-validation/scripts/run-all.sh new file mode 100644 index 0000000..530e92c --- /dev/null +++ b/custom-validation/scripts/run-all.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Regenerate every transcript under docs/output/. The test suite IS the transcript generator -- +# every number and body quoted in the article is written by an assertion, not typed by hand. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -DskipTests package +mvn -q test +echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/" diff --git a/custom-validation/scripts/run.sh b/custom-validation/scripts/run.sh new file mode 100644 index 0000000..f9550df --- /dev/null +++ b/custom-validation/scripts/run.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +nohup java -jar target/custom-validation-1.0.0.jar > /tmp/custom-validation.log 2>&1 & +echo $! > /tmp/custom-validation.pid +for i in $(seq 1 30); do + if curl -s -o /dev/null http://localhost:8080/actuator 2>/dev/null || curl -s -o /dev/null -w '' http://localhost:8080/contact 2>/dev/null; then + break + fi + sleep 1 +done +echo "Started on :8080 (pid $(cat /tmp/custom-validation.pid))" diff --git a/custom-validation/scripts/stop.sh b/custom-validation/scripts/stop.sh new file mode 100644 index 0000000..ae1c29f --- /dev/null +++ b/custom-validation/scripts/stop.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ -f /tmp/custom-validation.pid ]; then + kill "$(cat /tmp/custom-validation.pid)" 2>/dev/null || true + rm -f /tmp/custom-validation.pid +fi diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/CustomValidationApplication.java b/custom-validation/src/main/java/com/ankurm/customvalidation/CustomValidationApplication.java new file mode 100644 index 0000000..3ecd51a --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/CustomValidationApplication.java @@ -0,0 +1,11 @@ +package com.ankurm.customvalidation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CustomValidationApplication { + public static void main(String[] args) { + SpringApplication.run(CustomValidationApplication.class, args); + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/dto/ContactForm.java b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/ContactForm.java new file mode 100644 index 0000000..06c9c61 --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/ContactForm.java @@ -0,0 +1,39 @@ +package com.ankurm.customvalidation.dto; + +import com.ankurm.customvalidation.validator.SpamMessageCheck; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Class-based DTO, field-level validation. Note the package: {@code jakarta.validation.constraints}, + * not {@code javax.validation.constraints} — the namespace this module replaces. See + * docs/01-jakarta-namespace-and-bean-validation-3-1.md. + */ +public class ContactForm { + + @NotBlank(message = "Email cannot be empty!") + @Email(message = "Please provide a valid email address.") + private String email; + + @NotBlank(message = "Message cannot be empty!") + @Size(min = 10, message = "Message must be at least 10 characters long.") + @SpamMessageCheck + private String message; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/dto/ContactFormRecord.java b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/ContactFormRecord.java new file mode 100644 index 0000000..c13efe7 --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/ContactFormRecord.java @@ -0,0 +1,30 @@ +package com.ankurm.customvalidation.dto; + +import com.ankurm.customvalidation.validator.SpamMessageCheck; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Record equivalent of {@link ContactForm}. Bean Validation 3.1 (Jakarta Validation 3.1, the spec + * version Spring Boot 4.1.1 pins via Hibernate Validator 9.1.3.Final — confirmed with + * {@code mvn dependency:tree}, not read off a blog) is the first spec revision to explicitly + * clarify how record components are validated: a constraint annotation placed directly on a + * record component is treated exactly like a constraint on a field of the same name for + * validation purposes, including by {@code @Valid} cascading and Spring MVC's automatic + * {@code @RequestBody} validation. Compare this class to {@link ContactForm} line for line — the + * constraints are identical, only the carrier shape changed. See + * docs/02-record-validation.md. + */ +public record ContactFormRecord( + + @NotBlank(message = "Email cannot be empty!") + @Email(message = "Please provide a valid email address.") + String email, + + @NotBlank(message = "Message cannot be empty!") + @Size(min = 10, message = "Message must be at least 10 characters long.") + @SpamMessageCheck + String message +) { +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/dto/EventBooking.java b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/EventBooking.java new file mode 100644 index 0000000..6826ef2 --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/EventBooking.java @@ -0,0 +1,32 @@ +package com.ankurm.customvalidation.dto; + +import com.ankurm.customvalidation.validator.DateRangeValid; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +@DateRangeValid +public class EventBooking { + + @NotNull(message = "startDate is required") + private LocalDate startDate; + + @NotNull(message = "endDate is required") + private LocalDate endDate; + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/dto/EventBookingRecord.java b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/EventBookingRecord.java new file mode 100644 index 0000000..5197872 --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/dto/EventBookingRecord.java @@ -0,0 +1,19 @@ +package com.ankurm.customvalidation.dto; + +import com.ankurm.customvalidation.validator.DateRangeValid; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +/** + * Record equivalent of {@link EventBooking}. A class-level (cross-field) constraint on a record + * is placed on the record's type declaration, exactly as it would be on a class — the + * {@code isValid} method receives the whole record instance and reads its accessor methods + * ({@code startDate()}, not {@code getStartDate()}). See docs/02-record-validation.md. + */ +@DateRangeValid +public record EventBookingRecord( + @NotNull(message = "startDate is required") LocalDate startDate, + @NotNull(message = "endDate is required") LocalDate endDate +) { +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/validator/DateRangeValid.java b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/DateRangeValid.java new file mode 100644 index 0000000..5c33ffa --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/DateRangeValid.java @@ -0,0 +1,30 @@ +package com.ankurm.customvalidation.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.RECORD_COMPONENT; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Class-level (cross-field) custom constraint. Also targets {@code RECORD_COMPONENT}'s sibling, + * {@code TYPE}, which is what lets it be placed on a record's type declaration directly — + * see {@link com.ankurm.customvalidation.dto.EventBookingRecord}. + */ +@Documented +@Constraint(validatedBy = DateRangeValidator.class) +@Target({TYPE, RECORD_COMPONENT}) +@Retention(RUNTIME) +public @interface DateRangeValid { + + String message() default "endDate must be after startDate"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/validator/DateRangeValidator.java b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/DateRangeValidator.java new file mode 100644 index 0000000..b8294bb --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/DateRangeValidator.java @@ -0,0 +1,35 @@ +package com.ankurm.customvalidation.validator; + +import com.ankurm.customvalidation.dto.EventBooking; +import com.ankurm.customvalidation.dto.EventBookingRecord; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validates either the class-based {@link EventBooking} or the record-based + * {@link EventBookingRecord}, dispatching on the actual runtime type. A single + * {@code ConstraintValidator} is the simplest way to share one + * cross-field rule across both shapes without duplicating the comparison logic. + */ +public class DateRangeValidator implements ConstraintValidator { + + @Override + public boolean isValid(Object value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + if (value instanceof EventBooking booking) { + if (booking.getStartDate() == null || booking.getEndDate() == null) { + return true; // let @NotNull handle nulls + } + return booking.getEndDate().isAfter(booking.getStartDate()); + } + if (value instanceof EventBookingRecord booking) { + if (booking.startDate() == null || booking.endDate() == null) { + return true; + } + return booking.endDate().isAfter(booking.startDate()); + } + return true; + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java new file mode 100644 index 0000000..1c83768 --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/SpamMessageCheck.java @@ -0,0 +1,35 @@ +package com.ankurm.customvalidation.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.RECORD_COMPONENT; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Field-level custom constraint, ported to the {@code jakarta.validation} namespace. + * See docs/01-jakarta-namespace-and-bean-validation-3-1.md. + * + *

{@code RECORD_COMPONENT} is required for this annotation to be usable directly on a Java + * record component (see {@link com.ankurm.customvalidation.dto.ContactFormRecord}) — without it, + * the annotation compiles but is silently never validated, because a record component is not a + * field and Bean Validation only walks constraint targets it is declared to apply to.

+ */ +@Documented +@Constraint(validatedBy = SpamMessageValidator.class) +@Target({FIELD, PARAMETER, RECORD_COMPONENT}) +@Retention(RUNTIME) +public @interface SpamMessageCheck { + + String message() default "Message contains 'spam' and must be at least 50 characters long."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/validator/SpamMessageValidator.java b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/SpamMessageValidator.java new file mode 100644 index 0000000..a10c5fa --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/validator/SpamMessageValidator.java @@ -0,0 +1,18 @@ +package com.ankurm.customvalidation.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class SpamMessageValidator implements ConstraintValidator { + + @Override + public boolean isValid(String message, ConstraintValidatorContext context) { + if (message == null || message.trim().isEmpty()) { + return true; // let @NotBlank handle empty/null messages + } + if (message.toLowerCase().contains("spam")) { + return message.length() >= 50; + } + return true; + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/web/ContactController.java b/custom-validation/src/main/java/com/ankurm/customvalidation/web/ContactController.java new file mode 100644 index 0000000..9ac9775 --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/web/ContactController.java @@ -0,0 +1,42 @@ +package com.ankurm.customvalidation.web; + +import com.ankurm.customvalidation.dto.ContactForm; +import com.ankurm.customvalidation.dto.ContactFormRecord; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +public class ContactController { + + @PostMapping("/contact") + public ResponseEntity submitContactForm(@Valid @RequestBody ContactForm contactForm, + BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + Map errors = bindingResult.getFieldErrors().stream() + .collect(Collectors.toMap( + fieldError -> fieldError.getField(), + fieldError -> fieldError.getDefaultMessage())); + return ResponseEntity.badRequest().body(errors); + } + return ResponseEntity.ok("Contact form submitted successfully!"); + } + + /** + * Identical rules, record carrier. {@code @Valid @RequestBody} triggers Bean Validation on a + * record parameter the same way it does on a class — the automatic MVC argument-resolver + * path does not care which shape the target is. Errors surface as a + * {@code MethodArgumentNotValidException} the same way, since a record parameter has no + * {@code BindingResult} to append (there is no bean instance for Spring to bind into first). + */ + @PostMapping("/contact-record") + public ResponseEntity submitContactFormRecord(@Valid @RequestBody ContactFormRecord contactForm) { + return ResponseEntity.ok("Contact form submitted successfully!"); + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/web/DiagnosticController.java b/custom-validation/src/main/java/com/ankurm/customvalidation/web/DiagnosticController.java new file mode 100644 index 0000000..23fdf2d --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/web/DiagnosticController.java @@ -0,0 +1,28 @@ +package com.ankurm.customvalidation.web; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Prints the real thing instead of asserting the remembered thing: which Bean Validation spec + * version and which provider are actually on the classpath and wired up, at runtime, in this + * application. Delete before shipping — see the root README. + */ +@RestController +public class DiagnosticController { + + @GetMapping("/diagnostic/validation-provider") + public String validationProvider() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + Validator validator = factory.getValidator(); + Package specPackage = jakarta.validation.Validation.class.getPackage(); + return "jakarta.validation spec version (Implementation-Version of jakarta.validation-api): " + + specPackage.getSpecificationVersion() + + "\nValidator implementation: " + validator.getClass().getName() + + "\nValidator provider package: " + validator.getClass().getPackage().getImplementationTitle() + + " " + validator.getClass().getPackage().getImplementationVersion(); + } +} diff --git a/custom-validation/src/main/java/com/ankurm/customvalidation/web/EventBookingController.java b/custom-validation/src/main/java/com/ankurm/customvalidation/web/EventBookingController.java new file mode 100644 index 0000000..356766b --- /dev/null +++ b/custom-validation/src/main/java/com/ankurm/customvalidation/web/EventBookingController.java @@ -0,0 +1,23 @@ +package com.ankurm.customvalidation.web; + +import com.ankurm.customvalidation.dto.EventBooking; +import com.ankurm.customvalidation.dto.EventBookingRecord; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class EventBookingController { + + @PostMapping("/booking") + public ResponseEntity book(@Valid @RequestBody EventBooking booking) { + return ResponseEntity.ok("Booking accepted: " + booking.getStartDate() + " -> " + booking.getEndDate()); + } + + @PostMapping("/booking-record") + public ResponseEntity bookRecord(@Valid @RequestBody EventBookingRecord booking) { + return ResponseEntity.ok("Booking accepted: " + booking.startDate() + " -> " + booking.endDate()); + } +} diff --git a/custom-validation/src/test/java/com/ankurm/customvalidation/ClasspathVersionTest.java b/custom-validation/src/test/java/com/ankurm/customvalidation/ClasspathVersionTest.java new file mode 100644 index 0000000..74bb32f --- /dev/null +++ b/custom-validation/src/test/java/com/ankurm/customvalidation/ClasspathVersionTest.java @@ -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"); + } +} diff --git a/custom-validation/src/test/java/com/ankurm/customvalidation/ProblemDetailsEnabledTest.java b/custom-validation/src/test/java/com/ankurm/customvalidation/ProblemDetailsEnabledTest.java new file mode 100644 index 0000000..25068a2 --- /dev/null +++ b/custom-validation/src/test/java/com/ankurm/customvalidation/ProblemDetailsEnabledTest.java @@ -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\":\"spammy@email.com\",\"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\":\"spammy@email.com\",\"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"); + } +} diff --git a/custom-validation/src/test/java/com/ankurm/customvalidation/Transcript.java b/custom-validation/src/test/java/com/ankurm/customvalidation/Transcript.java new file mode 100644 index 0000000..d798d74 --- /dev/null +++ b/custom-validation/src/test/java/com/ankurm/customvalidation/Transcript.java @@ -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); + } + } +} diff --git a/custom-validation/src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java b/custom-validation/src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java new file mode 100644 index 0000000..24e8ad1 --- /dev/null +++ b/custom-validation/src/test/java/com/ankurm/customvalidation/ValidationScenariosTest.java @@ -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\":\"spammy@email.com\",\"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\":\"spammy@email.com\",\"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\":\"user@example.com\",\"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\":\"user@example.com\",\"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\":\"spammy@email.com\",\"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\":\"spammy@email.com\",\"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\":\"spammy@email.com\",\"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\":\"spammy@email.com\",\"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"); + } +} diff --git a/etag-caching/README.md b/etag-caching/README.md new file mode 100644 index 0000000..7539703 --- /dev/null +++ b/etag-caching/README.md @@ -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). diff --git a/etag-caching/docs/01-starter-web-renamed.md b/etag-caching/docs/01-starter-web-renamed.md new file mode 100644 index 0000000..e7b08e5 --- /dev/null +++ b/etag-caching/docs/01-starter-web-renamed.md @@ -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 +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) +``` + +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) diff --git a/etag-caching/docs/02-etags-unchanged-verified.md b/etag-caching/docs/02-etags-unchanged-verified.md new file mode 100644 index 0000000..4e7e81a --- /dev/null +++ b/etag-caching/docs/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) diff --git a/etag-caching/docs/03-deep-vs-shallow-cache.md b/etag-caching/docs/03-deep-vs-shallow-cache.md new file mode 100644 index 0000000..6b0cc52 --- /dev/null +++ b/etag-caching/docs/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. + +
This module builds the ETag by hand from a version counter to keep the example +self-contained. A JPA entity's own @Version 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.
+ +## 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) diff --git a/etag-caching/docs/output/01-first-get-returns-etag.txt b/etag-caching/docs/output/01-first-get-returns-etag.txt new file mode 100644 index 0000000..98e0559 --- /dev/null +++ b/etag-caching/docs/output/01-first-get-returns-etag.txt @@ -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} diff --git a/etag-caching/docs/output/02-conditional-get-304.txt b/etag-caching/docs/output/02-conditional-get-304.txt new file mode 100644 index 0000000..6e41ee4 --- /dev/null +++ b/etag-caching/docs/output/02-conditional-get-304.txt @@ -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. diff --git a/etag-caching/docs/output/03-conditional-put-412.txt b/etag-caching/docs/output/03-conditional-put-412.txt new file mode 100644 index 0000000..f7504da --- /dev/null +++ b/etag-caching/docs/output/03-conditional-put-412.txt @@ -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"} diff --git a/etag-caching/docs/output/04-conditional-put-success.txt b/etag-caching/docs/output/04-conditional-put-success.txt new file mode 100644 index 0000000..932b78f --- /dev/null +++ b/etag-caching/docs/output/04-conditional-put-success.txt @@ -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} diff --git a/etag-caching/docs/output/05-shallow-etag-header-filter.txt b/etag-caching/docs/output/05-shallow-etag-header-filter.txt new file mode 100644 index 0000000..29d6932 --- /dev/null +++ b/etag-caching/docs/output/05-shallow-etag-header-filter.txt @@ -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) diff --git a/etag-caching/pom.xml b/etag-caching/pom.xml new file mode 100644 index 0000000..ab1d1a2 --- /dev/null +++ b/etag-caching/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + etag-caching + 1.0.0 + etag-caching + ETag cache control (ShallowEtagHeaderFilter, checkNotModified, If-Match) on Spring Boot 4.1 + + + 25 + + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/etag-caching/scripts/run-all.sh b/etag-caching/scripts/run-all.sh new file mode 100644 index 0000000..3f4c360 --- /dev/null +++ b/etag-caching/scripts/run-all.sh @@ -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/" diff --git a/etag-caching/scripts/run.sh b/etag-caching/scripts/run.sh new file mode 100644 index 0000000..267c189 --- /dev/null +++ b/etag-caching/scripts/run.sh @@ -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))" diff --git a/etag-caching/scripts/stop.sh b/etag-caching/scripts/stop.sh new file mode 100644 index 0000000..e2a01ac --- /dev/null +++ b/etag-caching/scripts/stop.sh @@ -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 diff --git a/etag-caching/src/main/java/com/ankurm/etagcaching/EtagCachingApplication.java b/etag-caching/src/main/java/com/ankurm/etagcaching/EtagCachingApplication.java new file mode 100644 index 0000000..64459f3 --- /dev/null +++ b/etag-caching/src/main/java/com/ankurm/etagcaching/EtagCachingApplication.java @@ -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); + } +} diff --git a/etag-caching/src/main/java/com/ankurm/etagcaching/config/WebConfig.java b/etag-caching/src/main/java/com/ankurm/etagcaching/config/WebConfig.java new file mode 100644 index 0000000..de978dc --- /dev/null +++ b/etag-caching/src/main/java/com/ankurm/etagcaching/config/WebConfig.java @@ -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 shallowEtagHeaderFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); + registration.addUrlPatterns("/api/echo/*"); + return registration; + } +} diff --git a/etag-caching/src/main/java/com/ankurm/etagcaching/service/Product.java b/etag-caching/src/main/java/com/ankurm/etagcaching/service/Product.java new file mode 100644 index 0000000..74ae494 --- /dev/null +++ b/etag-caching/src/main/java/com/ankurm/etagcaching/service/Product.java @@ -0,0 +1,4 @@ +package com.ankurm.etagcaching.service; + +public record Product(long id, String name, int price, int version) { +} diff --git a/etag-caching/src/main/java/com/ankurm/etagcaching/service/ProductService.java b/etag-caching/src/main/java/com/ankurm/etagcaching/service/ProductService.java new file mode 100644 index 0000000..39ae29a --- /dev/null +++ b/etag-caching/src/main/java/com/ankurm/etagcaching/service/ProductService.java @@ -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 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()); + } + } +} diff --git a/etag-caching/src/main/java/com/ankurm/etagcaching/web/EchoController.java b/etag-caching/src/main/java/com/ankurm/etagcaching/web/EchoController.java new file mode 100644 index 0000000..20e0759 --- /dev/null +++ b/etag-caching/src/main/java/com/ankurm/etagcaching/web/EchoController.java @@ -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; + } +} diff --git a/etag-caching/src/main/java/com/ankurm/etagcaching/web/ProductController.java b/etag-caching/src/main/java/com/ankurm/etagcaching/web/ProductController.java new file mode 100644 index 0000000..22ee6a0 --- /dev/null +++ b/etag-caching/src/main/java/com/ankurm/etagcaching/web/ProductController.java @@ -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 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) { + } +} diff --git a/etag-caching/src/test/java/com/ankurm/etagcaching/EtagScenariosTest.java b/etag-caching/src/test/java/com/ankurm/etagcaching/EtagScenariosTest.java new file mode 100644 index 0000000..6ff2d73 --- /dev/null +++ b/etag-caching/src/test/java/com/ankurm/etagcaching/EtagScenariosTest.java @@ -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"); + } +} diff --git a/etag-caching/src/test/java/com/ankurm/etagcaching/Transcript.java b/etag-caching/src/test/java/com/ankurm/etagcaching/Transcript.java new file mode 100644 index 0000000..9b2915f --- /dev/null +++ b/etag-caching/src/test/java/com/ankurm/etagcaching/Transcript.java @@ -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); + } + } +} diff --git a/restclient-basic-auth/README.md b/restclient-basic-auth/README.md new file mode 100644 index 0000000..0b71094 --- /dev/null +++ b/restclient-basic-auth/README.md @@ -0,0 +1,61 @@ +# restclient-basic-auth + +Companion module for [**Spring Boot RestTemplate with Basic Auth: A Modern Guide**](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) +on ankurm.com, rewritten around **RestClient** -- Spring Boot 4's recommended synchronous HTTP +client, now that `RestTemplate` is out of the recommended path. Cross-linked with the deeper +[RestTemplate to RestClient Migration Guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/). + +`mvn test` starts a real embedded Tomcat with a real Spring Security filter chain on a random port +and makes real HTTP calls against it -- every transcript in [`docs/output/`](docs/output) is a +genuine request/response pair, not a mocked one. + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| Spring Security | managed by Boot 4.1.1 | +| 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 -u admin:password123 http://localhost:8080/api/hello +mvn test # 4 tests, regenerates docs/output/ +``` + +## Endpoints and beans + +| | Shows | +|---|---| +| `GET /api/hello` (server side) | Spring Security `httpBasic()`, unchanged from the original article | +| `restClientWithDefaultHeaders` bean | `RestClient.Builder.defaultHeaders(h -> h.setBasicAuth(...))` | +| `restClientWithInterceptor` bean | `BasicAuthenticationInterceptor`, ported unchanged from RestTemplate | + +## Documentation + +1. [{noop} passwords: still work, still deprecated, no runtime warning](docs/01-password-encoding.md) +2. [RestClient with Basic Auth, two ways](docs/02-restclient-basic-auth-patterns.md) +3. [The Boot 4 starter split, and the exchange() trap](docs/03-starter-split-and-exchange-trap.md) + +## Findings worth the trip + +- **`{noop}` plaintext passwords still work on Boot 4.1 and emit no runtime warning at all** -- + checked directly by running an app with one and reading the full startup and auth log. The + `@Deprecated` annotation on `NoOpPasswordEncoder` is a compile-time signal only. +- **`spring-boot-starter-webmvc` alone does not include HTTP client auto-configuration.** + `spring-boot-starter-restclient` is its own module in Boot 4 and must be declared explicitly, or + the `RestClient.Builder` bean this module depends on is not there. +- **`BasicAuthenticationInterceptor` needs zero changes to move from `RestTemplate` to + `RestClient`** -- both accept the same `ClientHttpRequestInterceptor` interface. +- **`RestClient.retrieve()` throws on 4xx/5xx by default, same as `RestTemplate`** -- it is + `RestClient.exchange()` specifically that disables that default, a trap covered in depth in the + [migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/). + +## License + +MIT -- see [LICENSE](../LICENSE). diff --git a/restclient-basic-auth/docs/01-password-encoding.md b/restclient-basic-auth/docs/01-password-encoding.md new file mode 100644 index 0000000..dc0c7b6 --- /dev/null +++ b/restclient-basic-auth/docs/01-password-encoding.md @@ -0,0 +1,30 @@ +# 1. {noop} passwords: still work, still deprecated, no runtime warning + +[README](../README.md) | Next: [RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md) + +Source: [`AppSecurityConfig.java`](../src/main/java/com/ankurm/restclientbasicauth/config/AppSecurityConfig.java). + +## What was checked, and why + +The original article used `User.builder().password("{noop}password123")` with the comment "for +demonstration purposes only." Before repeating that pattern in a rewrite, it seemed worth checking +whether Boot 4.1 actually does anything different with it now -- log a deprecation warning, refuse +to start, anything. + +It does not. A throwaway application built with exactly that `{noop}` password, run standalone with +`java -jar`, produces no warning in the full startup log, and a subsequent Basic-Auth request that +successfully authenticates against it produces no warning either. `NoOpPasswordEncoder` is +`@Deprecated` in Spring Security's own source and has been for years, but that annotation is a +compile-time signal to whoever writes the code, not a runtime one -- nothing tells an operator +watching logs in production that a demo shortcut is still live. + +That is precisely the failure mode worth naming: a comment reading "for demonstration purposes +only" is not enforced by anything at runtime. This module uses +`PasswordEncoderFactories.createDelegatingPasswordEncoder()` (Spring Security's own recommended +default, currently BCrypt) instead, so the encoded value in the user store is not silently +reversible plaintext even in a demo. + +## Going deeper + +- [Spring Security password storage reference](https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html) (rel="nofollow") +- Next: [RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md) diff --git a/restclient-basic-auth/docs/02-restclient-basic-auth-patterns.md b/restclient-basic-auth/docs/02-restclient-basic-auth-patterns.md new file mode 100644 index 0000000..4bf5f88 --- /dev/null +++ b/restclient-basic-auth/docs/02-restclient-basic-auth-patterns.md @@ -0,0 +1,69 @@ +# 2. RestClient with Basic Auth, two ways + +[Prev: {noop} passwords](01-password-encoding.md) | [README](../README.md) | Next: [The starter split, and the exchange() trap](03-starter-split-and-exchange-trap.md) + +Source: [`RestClientConfig.java`](../src/main/java/com/ankurm/restclientbasicauth/client/RestClientConfig.java), +[`ApiClient.java`](../src/main/java/com/ankurm/restclientbasicauth/client/ApiClient.java). +Test: [`RestClientBasicAuthTest.java`](../src/test/java/com/ankurm/restclientbasicauth/RestClientBasicAuthTest.java). +Transcripts: [`docs/output/01-restclient-basic-auth-default-headers.txt`](output/01-restclient-basic-auth-default-headers.txt), +[`docs/output/02-restclient-basic-auth-interceptor.txt`](output/02-restclient-basic-auth-interceptor.txt). + +The original article's two `RestTemplate` patterns -- `RestTemplateBuilder.basicAuthentication(...)` +and a hand-added `BasicAuthenticationInterceptor` -- both have a direct `RestClient` equivalent. +Both are built on the **auto-configured `RestClient.Builder`** injected as a constructor parameter, +never `RestClient.create()`, which carries none of Boot's message converters, observability, or +customizer beans -- the same rule the +[RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) +covers in more general depth. + +## Idiomatic on RestClient: `defaultHeaders` + `setBasicAuth` + +```java +@Bean +public RestClient restClientWithDefaultHeaders(RestClient.Builder builder) { + return builder + .defaultHeaders(headers -> headers.setBasicAuth("admin", "password123")) + .build(); +} +``` + +``` +Response: Hello, you have accessed a secured endpoint! +``` + +([`docs/output/01-restclient-basic-auth-default-headers.txt`](output/01-restclient-basic-auth-default-headers.txt)) +-- a real HTTP call against a real embedded Tomcat with a real Spring Security filter chain, not a +mock. + +## Ported unchanged: `BasicAuthenticationInterceptor` + +```java +@Bean +public RestClient restClientWithInterceptor(RestClient.Builder builder) { + return builder + .requestInterceptor(new BasicAuthenticationInterceptor("admin", "password123")) + .build(); +} +``` + +`BasicAuthenticationInterceptor` implements `ClientHttpRequestInterceptor` -- the exact same +interface both `RestTemplate.getInterceptors()` and `RestClient.Builder.requestInterceptor(...)` +accept, so this class needs no changes at all to move from one client to the other. Same result: +[`docs/output/02-restclient-basic-auth-interceptor.txt`](output/02-restclient-basic-auth-interceptor.txt). + +Prefer `defaultHeaders` when the credentials are fixed at bean-creation time; keep the interceptor +form when credentials must be resolved per request (a token fetched from a vault, say) -- +an interceptor runs on every call, a `defaultHeaders` value is captured once. + +## The negative cases, checked too + +`docs/output/03-restclient-no-credentials-401.txt` and `04-restclient-wrong-password-401.txt` +confirm what actually happens on the failure path: `retrieve()` throws +`HttpClientErrorException.Unauthorized` on a 401, exactly like `RestTemplate` did -- this is the +default `retrieve()` behaviour, not the `exchange()` behaviour covered in the next chapter. + +## Going deeper + +- [`RestClient` Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/RestClient.html) (rel="nofollow") +- Prev: [{noop} passwords](01-password-encoding.md) +- Next: [The starter split, and the exchange() trap](03-starter-split-and-exchange-trap.md) diff --git a/restclient-basic-auth/docs/03-starter-split-and-exchange-trap.md b/restclient-basic-auth/docs/03-starter-split-and-exchange-trap.md new file mode 100644 index 0000000..469de61 --- /dev/null +++ b/restclient-basic-auth/docs/03-starter-split-and-exchange-trap.md @@ -0,0 +1,40 @@ +# 3. The Boot 4 starter split, and the exchange() trap + +[Prev: RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md) | [README](../README.md) + +Source: [`pom.xml`](../pom.xml). + +## `spring-boot-starter-restclient` is not optional in Boot 4 + +The original article's `spring-boot-starter-web` dependency was, on Boot 3, sufficient to get an +auto-configured `RestTemplateBuilder` bean for free. On Boot 4.1, HTTP client support moved into +its own starter, confirmed the same way every version fact in this repository is confirmed -- +build a throwaway project and read the real dependency tree: + +``` +$ mvn dependency:tree # against spring-boot-starter-parent:4.1.1 + spring-boot-starter-webmvc only +``` + +`spring-boot-starter-webmvc` alone does **not** pull in `spring-boot-restclient`. Leave +`spring-boot-starter-restclient` off this module's `pom.xml` and the auto-configured +`RestClient.Builder` this chapter's code depends on is simply not there -- a `NoSuchBeanDefinitionException` +at startup, not a subtle behavioural difference. This module declares it explicitly. + +## The trap this module deliberately avoids + +`RestClient` has its own `exchange()` method, and it means something different from +`RestTemplate.exchange()`: **RestClient's `exchange()` disables the default status handlers**, so a +4xx or 5xx response is silently returned to you instead of thrown. Every example in this module +uses `retrieve()` for exactly that reason -- `retrieve()` keeps the throw-on-4xx/5xx behaviour this +Basic Auth demo relies on (see [`docs/output/03-restclient-no-credentials-401.txt`](output/03-restclient-no-credentials-401.txt)). +A team that mechanically renames `restTemplate.exchange(...)` call sites to +`restClient.exchange(...)` during a migration ships code that stops noticing failed requests. The +[RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) +covers this trap, the full method-mapping table, and the three behavioural differences that matter +in more depth than a Basic Auth-focused rewrite has room for. + +## Going deeper + +- [RestTemplate to RestClient Migration Guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) -- the exchange() trap, timeouts, and the full mapping table +- [Spring Boot 4 HTTP Service Clients (@HttpExchange)](https://ankurm.com/spring-boot-4-http-service-clients/) -- the declarative layer built on top of RestClient +- Prev: [RestClient with Basic Auth, two ways](02-restclient-basic-auth-patterns.md) diff --git a/restclient-basic-auth/docs/output/01-restclient-basic-auth-default-headers.txt b/restclient-basic-auth/docs/output/01-restclient-basic-auth-default-headers.txt new file mode 100644 index 0000000..3fd2d7d --- /dev/null +++ b/restclient-basic-auth/docs/output/01-restclient-basic-auth-default-headers.txt @@ -0,0 +1,5 @@ +// RestClient.Builder builder = ...; +// RestClient client = builder.defaultHeaders(h -> h.setBasicAuth("admin", "password123")).build(); +// client.get().uri(baseUrl + "/api/hello").retrieve().body(String.class); + +Response: Hello, you have accessed a secured endpoint! diff --git a/restclient-basic-auth/docs/output/02-restclient-basic-auth-interceptor.txt b/restclient-basic-auth/docs/output/02-restclient-basic-auth-interceptor.txt new file mode 100644 index 0000000..773611a --- /dev/null +++ b/restclient-basic-auth/docs/output/02-restclient-basic-auth-interceptor.txt @@ -0,0 +1,6 @@ +// RestClient.Builder builder = ...; +// RestClient client = builder.requestInterceptor( +// new BasicAuthenticationInterceptor("admin", "password123")).build(); +// client.get().uri(baseUrl + "/api/hello").retrieve().body(String.class); + +Response: Hello, you have accessed a secured endpoint! diff --git a/restclient-basic-auth/docs/output/03-restclient-no-credentials-401.txt b/restclient-basic-auth/docs/output/03-restclient-no-credentials-401.txt new file mode 100644 index 0000000..e71a8f4 --- /dev/null +++ b/restclient-basic-auth/docs/output/03-restclient-no-credentials-401.txt @@ -0,0 +1,8 @@ +// RestClient client = builder.build(); // no basic auth +// client.get().uri(baseUrl + "/api/hello").retrieve().body(String.class); + +Thrown: org.springframework.web.client.HttpClientErrorException$Unauthorized +Status: 401 UNAUTHORIZED + +# retrieve() throws HttpClientErrorException on 4xx by default -- same default as +# RestTemplate, unlike RestClient's own exchange() method, which disables that default. diff --git a/restclient-basic-auth/docs/output/04-restclient-wrong-password-401.txt b/restclient-basic-auth/docs/output/04-restclient-wrong-password-401.txt new file mode 100644 index 0000000..c9ef235 --- /dev/null +++ b/restclient-basic-auth/docs/output/04-restclient-wrong-password-401.txt @@ -0,0 +1,2 @@ +Thrown: org.springframework.web.client.HttpClientErrorException$Unauthorized +Status: 401 UNAUTHORIZED diff --git a/restclient-basic-auth/pom.xml b/restclient-basic-auth/pom.xml new file mode 100644 index 0000000..3f123a6 --- /dev/null +++ b/restclient-basic-auth/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + restclient-basic-auth + 1.0.0 + restclient-basic-auth + Consuming a Basic-Auth-secured REST API with RestClient on Spring Boot 4.1 (RestTemplate is out of the recommended path) + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/restclient-basic-auth/scripts/run-all.sh b/restclient-basic-auth/scripts/run-all.sh new file mode 100644 index 0000000..3f4c360 --- /dev/null +++ b/restclient-basic-auth/scripts/run-all.sh @@ -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/" diff --git a/restclient-basic-auth/scripts/run.sh b/restclient-basic-auth/scripts/run.sh new file mode 100644 index 0000000..2e87fca --- /dev/null +++ b/restclient-basic-auth/scripts/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +nohup java -jar target/restclient-basic-auth-1.0.0.jar > /tmp/restclient-basic-auth.log 2>&1 & +echo $! > /tmp/restclient-basic-auth.pid +sleep 3 +echo "Started on :8080 (pid $(cat /tmp/restclient-basic-auth.pid))" diff --git a/restclient-basic-auth/scripts/stop.sh b/restclient-basic-auth/scripts/stop.sh new file mode 100644 index 0000000..9d8427e --- /dev/null +++ b/restclient-basic-auth/scripts/stop.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ -f /tmp/restclient-basic-auth.pid ]; then + kill "$(cat /tmp/restclient-basic-auth.pid)" 2>/dev/null || true + rm -f /tmp/restclient-basic-auth.pid +fi diff --git a/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/RestclientBasicAuthApplication.java b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/RestclientBasicAuthApplication.java new file mode 100644 index 0000000..a1542b3 --- /dev/null +++ b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/RestclientBasicAuthApplication.java @@ -0,0 +1,11 @@ +package com.ankurm.restclientbasicauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RestclientBasicAuthApplication { + public static void main(String[] args) { + SpringApplication.run(RestclientBasicAuthApplication.class, args); + } +} diff --git a/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/client/ApiClient.java b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/client/ApiClient.java new file mode 100644 index 0000000..5a2a14a --- /dev/null +++ b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/client/ApiClient.java @@ -0,0 +1,32 @@ +package com.ankurm.restclientbasicauth.client; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +public class ApiClient { + + private final RestClient viaDefaultHeaders; + private final RestClient viaInterceptor; + + public ApiClient(@Qualifier("basicAuthViaDefaultHeaders") RestClient viaDefaultHeaders, + @Qualifier("basicAuthViaInterceptor") RestClient viaInterceptor) { + this.viaDefaultHeaders = viaDefaultHeaders; + this.viaInterceptor = viaInterceptor; + } + + public String callSecuredEndpointViaDefaultHeaders(String baseUrl) { + return viaDefaultHeaders.get() + .uri(baseUrl + "/api/hello") + .retrieve() + .body(String.class); + } + + public String callSecuredEndpointViaInterceptor(String baseUrl) { + return viaInterceptor.get() + .uri(baseUrl + "/api/hello") + .retrieve() + .body(String.class); + } +} diff --git a/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/client/RestClientConfig.java b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/client/RestClientConfig.java new file mode 100644 index 0000000..bb1a414 --- /dev/null +++ b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/client/RestClientConfig.java @@ -0,0 +1,42 @@ +package com.ankurm.restclientbasicauth.client; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.support.BasicAuthenticationInterceptor; +import org.springframework.web.client.RestClient; + +/** + * Two ways to add HTTP Basic credentials to every request an injected {@link RestClient} makes -- + * both built on the auto-configured {@link RestClient.Builder}, never {@code RestClient.create()} + * (that bypasses Boot's message converters, observability, and any customizer beans; see + * ankurm.com's RestTemplate + * to RestClient migration guide, which covers this and the exchange() trap in depth). + */ +@Configuration +public class RestClientConfig { + + /** Idiomatic on RestClient specifically: {@code HttpHeaders.setBasicAuth(...)} via defaultHeaders(). */ + @Bean + @Qualifier("basicAuthViaDefaultHeaders") + public RestClient restClientWithDefaultHeaders(RestClient.Builder builder) { + return builder + .defaultHeaders(headers -> headers.setBasicAuth("admin", "password123")) + .build(); + } + + /** + * The RestTemplate-era pattern, ported unchanged: {@link BasicAuthenticationInterceptor} + * implements {@code ClientHttpRequestInterceptor}, the same interface both RestTemplate and + * RestClient accept, so it plugs into {@code RestClient.Builder.requestInterceptor(...)} with + * no adaptation needed. Useful if credentials need to be resolved dynamically per request + * (a token that rotates, say) rather than fixed at bean-creation time. + */ + @Bean + @Qualifier("basicAuthViaInterceptor") + public RestClient restClientWithInterceptor(RestClient.Builder builder) { + return builder + .requestInterceptor(new BasicAuthenticationInterceptor("admin", "password123")) + .build(); + } +} diff --git a/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/config/AppSecurityConfig.java b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/config/AppSecurityConfig.java new file mode 100644 index 0000000..6a29b53 --- /dev/null +++ b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/config/AppSecurityConfig.java @@ -0,0 +1,57 @@ +package com.ankurm.restclientbasicauth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class AppSecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // stateless REST API, no browser form submissions + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll()) + .httpBasic(Customizer.withDefaults()); + + return http.build(); + } + + /** + * {@code {noop}} plaintext passwords (as the original article used) still work unchanged on + * Boot 4.1: {@link org.springframework.security.crypto.password.NoOpPasswordEncoder} is + * {@code @Deprecated} in source but not removed, and -- checked directly by running this + * application with one and inspecting the full startup and authentication log output -- it + * emits no runtime warning of any kind, at startup or on a successful login. This module uses + * a real {@link PasswordEncoder} anyway, not because the old code would warn you, but because + * "for demonstration purposes only" comments have a documented habit of reaching production + * unchanged. See docs/01-password-encoding.md. + */ + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { + UserDetails user = User.builder() + .username("admin") + .password(passwordEncoder.encode("password123")) + .roles("USER", "ADMIN") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/web/SecuredController.java b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/web/SecuredController.java new file mode 100644 index 0000000..a6f5154 --- /dev/null +++ b/restclient-basic-auth/src/main/java/com/ankurm/restclientbasicauth/web/SecuredController.java @@ -0,0 +1,13 @@ +package com.ankurm.restclientbasicauth.web; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SecuredController { + + @GetMapping("/api/hello") + public String getSecuredGreeting() { + return "Hello, you have accessed a secured endpoint!"; + } +} diff --git a/restclient-basic-auth/src/test/java/com/ankurm/restclientbasicauth/RestClientBasicAuthTest.java b/restclient-basic-auth/src/test/java/com/ankurm/restclientbasicauth/RestClientBasicAuthTest.java new file mode 100644 index 0000000..d4f710f --- /dev/null +++ b/restclient-basic-auth/src/test/java/com/ankurm/restclientbasicauth/RestClientBasicAuthTest.java @@ -0,0 +1,107 @@ +package com.ankurm.restclientbasicauth; + +import com.ankurm.restclientbasicauth.client.ApiClient; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A real embedded Tomcat on a random port, a real Spring Security filter chain, and a real + * RestClient making real HTTP calls over loopback -- no MockMvc here, because the point is to + * prove the client actually authenticates over the wire, the same way the original article's + * CommandLineRunner did against RestTemplate. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RestClientBasicAuthTest { + + @LocalServerPort + int port; + + @Autowired + ApiClient apiClient; + + @Autowired + RestClient.Builder builder; + + private String baseUrl() { + return "http://localhost:" + port; + } + + @Test + void defaultHeadersBasicAuthSucceeds() { + String response = apiClient.callSecuredEndpointViaDefaultHeaders(baseUrl()); + assertThat(response).isEqualTo("Hello, you have accessed a secured endpoint!"); + + Transcript.write("01-restclient-basic-auth-default-headers.txt", + "// RestClient.Builder builder = ...;\n" + + "// RestClient client = builder.defaultHeaders(h -> h.setBasicAuth(\"admin\", \"password123\")).build();\n" + + "// client.get().uri(baseUrl + \"/api/hello\").retrieve().body(String.class);\n\n" + + "Response: " + response + "\n"); + } + + @Test + void interceptorBasicAuthSucceeds() { + String response = apiClient.callSecuredEndpointViaInterceptor(baseUrl()); + assertThat(response).isEqualTo("Hello, you have accessed a secured endpoint!"); + + Transcript.write("02-restclient-basic-auth-interceptor.txt", + "// RestClient.Builder builder = ...;\n" + + "// RestClient client = builder.requestInterceptor(\n" + + "// new BasicAuthenticationInterceptor(\"admin\", \"password123\")).build();\n" + + "// client.get().uri(baseUrl + \"/api/hello\").retrieve().body(String.class);\n\n" + + "Response: " + response + "\n"); + } + + @Test + void noCredentialsGets401() { + RestClient noAuthClient = builder.build(); // the auto-configured builder, no basic auth added + + HttpClientErrorException ex = catchHttpClientErrorException(() -> + noAuthClient.get().uri(baseUrl() + "/api/hello").retrieve().body(String.class)); + + assertThat(ex.getStatusCode()).isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED); + + Transcript.write("03-restclient-no-credentials-401.txt", + "// RestClient client = builder.build(); // no basic auth\n" + + "// client.get().uri(baseUrl + \"/api/hello\").retrieve().body(String.class);\n\n" + + "Thrown: " + ex.getClass().getName() + "\n" + + "Status: " + ex.getStatusCode() + "\n" + + "\n# retrieve() throws HttpClientErrorException on 4xx by default -- same default as\n" + + "# RestTemplate, unlike RestClient's own exchange() method, which disables that default.\n"); + } + + @Test + void wrongPasswordAlsoGets401() { + RestClient wrongPasswordClient = builder + .defaultHeaders(h -> h.setBasicAuth("admin", "not-the-password")) + .build(); + + HttpClientErrorException ex = catchHttpClientErrorException(() -> + wrongPasswordClient.get().uri(baseUrl() + "/api/hello").retrieve().body(String.class)); + + assertThat(ex.getStatusCode()).isEqualTo(org.springframework.http.HttpStatus.UNAUTHORIZED); + + Transcript.write("04-restclient-wrong-password-401.txt", + "Thrown: " + ex.getClass().getName() + "\n" + + "Status: " + ex.getStatusCode() + "\n"); + } + + private interface ThrowingRunnable { + void run(); + } + + private HttpClientErrorException catchHttpClientErrorException(ThrowingRunnable runnable) { + try { + runnable.run(); + } catch (HttpClientErrorException e) { + return e; + } + throw new AssertionError("Expected HttpClientErrorException but none was thrown"); + } +} diff --git a/restclient-basic-auth/src/test/java/com/ankurm/restclientbasicauth/Transcript.java b/restclient-basic-auth/src/test/java/com/ankurm/restclientbasicauth/Transcript.java new file mode 100644 index 0000000..15fcdef --- /dev/null +++ b/restclient-basic-auth/src/test/java/com/ankurm/restclientbasicauth/Transcript.java @@ -0,0 +1,21 @@ +package com.ankurm.restclientbasicauth; + +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); + } + } +}