Add custom-validation, etag-caching, restclient-basic-auth: Boot 4.1 API pass

Three companion modules verifying and rewriting the Boot 4.1.1 / Framework
7.0.9 story for three older articles: the javax->jakarta.validation namespace
fix plus Jakarta Validation 3.1 record-validation clarification, ETag/
conditional-request APIs re-verified unchanged plus the starter rename, and
RestTemplate Basic Auth rebuilt on RestClient with the exchange() trap called
out. 19 real passing tests generate every transcript quoted from the three
companion articles.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
Claude
2026-09-19 10:17:09 +00:00
parent 03bdf7ee87
commit e4b5636f7c
75 changed files with 2374 additions and 0 deletions
+61
View File
@@ -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).
@@ -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)
@@ -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)
@@ -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)
@@ -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!
@@ -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!
@@ -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.
@@ -0,0 +1,2 @@
Thrown: org.springframework.web.client.HttpClientErrorException$Unauthorized
Status: 401 UNAUTHORIZED
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>restclient-basic-auth</artifactId>
<version>1.0.0</version>
<name>restclient-basic-auth</name>
<description>Consuming a Basic-Auth-secured REST API with RestClient on Spring Boot 4.1 (RestTemplate is out of the recommended path)</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Boot 4 split HTTP client support out of spring-boot-starter-web into its own module;
declare it explicitly or the auto-configured RestClient.Builder bean this module relies
on simply is not there. Confirmed with mvn dependency:tree against a bare
spring-boot-starter-parent:4.1.1 project. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -DskipTests package
mvn -q test
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
nohup java -jar target/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))"
+6
View File
@@ -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
@@ -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);
}
}
@@ -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);
}
}
@@ -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 <a href="https://ankurm.com/resttemplate-to-restclient-migration-guide/">RestTemplate
* to RestClient migration guide</a>, 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();
}
}
@@ -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);
}
}
@@ -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!";
}
}
@@ -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");
}
}
@@ -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);
}
}
}