Add rest-client-test module: @RestClientTest on Boot 4.1, Jackson 3 by default, and the RestClient-vs-RestTemplate root-URI matching trap (post #49)

This commit is contained in:
Claude
2026-10-08 16:40:51 +00:00
parent 4370ac7fdf
commit b9388053c4
23 changed files with 704 additions and 0 deletions
@@ -0,0 +1,32 @@
package com.ankurm.restclienttest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Production wiring: a single {@code RestClient} pointed at the HR API's base URL, plus the
* declarative HTTP interface client built on top of it.
*/
@Configuration
public class AppConfig {
@Bean
public RestClient employeeRestClient(RestClient.Builder builder) {
return builder.baseUrl("http://hr-api.example.com").build();
}
/**
* {@link EmployeeHttpInterface} is a plain annotated interface, not a class
* {@code @RestClientTest} can scan or instantiate directly - it has to be created here,
* from the same {@code RestClient}, via {@link HttpServiceProxyFactory}.
*/
@Bean
public EmployeeHttpInterface employeeHttpInterface(RestClient employeeRestClient) {
RestClientAdapter adapter = RestClientAdapter.create(employeeRestClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(EmployeeHttpInterface.class);
}
}
@@ -0,0 +1,7 @@
package com.ankurm.restclienttest;
/**
* Immutable DTO for the employee data returned by the (simulated) external HR system's API.
*/
public record Employee(Integer id, String firstName, String lastName, String email) {
}
@@ -0,0 +1,12 @@
package com.ankurm.restclienttest;
/**
* A collaborator {@code EmployeeClient} depends on but that has nothing to do with REST clients
* itself. {@code @RestClientTest}'s minimal context never creates a bean for this on its own -
* it exists specifically so the test class can demonstrate replacing it with
* {@code @MockitoBean}, now that {@code @MockBean} is gone in Spring Boot 4.
*/
public interface EmployeeAuditLogger {
void logFetch(Integer employeeId);
}
@@ -0,0 +1,20 @@
package com.ankurm.restclienttest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* The real implementation, wired in production but never present in the {@code @RestClientTest}
* slice - see {@code EmployeeAuditLogger}.
*/
@Component
public class EmployeeAuditLoggerImpl implements EmployeeAuditLogger {
private static final Logger log = LoggerFactory.getLogger(EmployeeAuditLoggerImpl.class);
@Override
public void logFetch(Integer employeeId) {
log.info("Fetched employee {}", employeeId);
}
}
@@ -0,0 +1,35 @@
package com.ankurm.restclienttest;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
/**
* Fetches employee data from the external HR system using Spring's fluent {@code RestClient} -
* the recommended approach for new client code. Every successful fetch is audited through
* {@link EmployeeAuditLogger}, a plain collaborator bean unrelated to REST at all.
*
* <p>The client builds itself from an injected {@code RestClient.Builder}, the same shape as
* {@link EmployeeTemplateClient}'s {@code RestTemplateBuilder} constructor - this is what lets
* {@code @RestClientTest} wire a mocked client straight in, with no separate {@code RestClient}
* bean required (see {@link EmployeeHttpInterface} for the one case that does need one).</p>
*/
@Service
public class EmployeeClient {
private final RestClient restClient;
private final EmployeeAuditLogger auditLogger;
public EmployeeClient(RestClient.Builder restClientBuilder, EmployeeAuditLogger auditLogger) {
this.restClient = restClientBuilder.baseUrl("http://hr-api.example.com").build();
this.auditLogger = auditLogger;
}
public Employee getEmployeeById(Integer id) {
Employee employee = restClient.get()
.uri("/employees/{id}", id)
.retrieve()
.body(Employee.class);
auditLogger.logFetch(id);
return employee;
}
}
@@ -0,0 +1,15 @@
package com.ankurm.restclienttest;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
/**
* A declarative HTTP interface client (Spring Framework 6+): no implementation here at all,
* just the contract. {@link AppConfig} turns this into a real bean at runtime via
* {@code HttpServiceProxyFactory}, bound to the same {@code RestClient} as {@link EmployeeClient}.
*/
public interface EmployeeHttpInterface {
@GetExchange("/employees/{id}")
Employee getEmployeeById(@PathVariable Integer id);
}
@@ -0,0 +1,24 @@
package com.ankurm.restclienttest;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
/**
* The same job as {@link EmployeeClient}, built on the older {@code RestTemplate} instead of
* {@code RestClient}. Still fully supported in Spring Boot 4.1, and {@code @RestClientTest}
* handles it identically - see the post for why.
*/
@Service
public class EmployeeTemplateClient {
private final RestTemplate restTemplate;
public EmployeeTemplateClient(RestTemplateBuilder builder) {
this.restTemplate = builder.rootUri("http://hr-api.example.com").build();
}
public Employee getEmployeeById(Integer id) {
return restTemplate.getForObject("/employees/{id}", Employee.class, id);
}
}
@@ -0,0 +1,18 @@
package com.ankurm.restclienttest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* No real {@code main} is ever run in this module - the tests only need a
* {@code @SpringBootApplication} class somewhere above them in the package tree so that
* {@code @RestClientTest} (and Spring Boot's test slices in general) can find a configuration to
* bootstrap from.
*/
@SpringBootApplication
public class RestClientTestApplication {
public static void main(String[] args) {
SpringApplication.run(RestClientTestApplication.class, args);
}
}