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:
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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!";
|
||||
}
|
||||
}
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user