Declarative REST Clients in Spring Framework 6
One of the best additions in the new version of Spring (and Spring Boot of course) is the ability to define remote REST APIs as declarative…
Declarative REST Clients in Spring Framework 6
One of the best additions in the new version of Spring (and Spring Boot of course) is the ability to define remote REST APIs as declarative interfaces. This idea strongly derives from OpenFeign, which has allowed us to create a REST client interface that is defined in a declarative fashion. As with OpenFeign, the idea mainly depends on the ability to use the Method Proxying pattern. Let’s exemplify this new addition to see it in action, by first creating a run of the mill controller and changing it to adopt to the new style.
HTTP Interfaces with HttpExchange
Let’s assume we have a small microservice that helps us manage our “business partners” for our imaginary machine parts company. It will enable us to
- Manage our Partners through a CRUD Interface
- Manage the machine parts from our partners through a CRUD Interface that connects to another “parts” microservice underneath
A basic structure for service that accomplishes this can be found below. It is not vital for understanding the subject matter for this writeup, which are HttpExchange and HttpServiceProxyFactory; however, exemplifying the scenario and where these building blocks of code fit makes everything come together.
.
├── Application.java
├── client
│ └── PartsClient.java
├── controller
│ ├── PartnerController.java
│ └── PartnerPartsController.java
├── dto
│ ├── PartDTO.java
│ └── PartnerDTO.java
├── entity
│ └── Partner.java
├── exception
│ ├── PartNotFoundException.java
│ └── PartnerNotFoundException.java
├── repository
│ └── PartnerRepository.java
└── service
├── PartnerPartsService.java
├── PartnerService.java
└── impl
├── DefaultPartnerPartsService.java
└── DefaultPartnerService.java
What is interesting for the purpose of this write-up is the definition of the client for the “parts” microservice, namely the PartsClient
@HttpExchange("/api/v1/parts")
public interface PartsClient {
@GetExchange List<PartDTO> getAllParts();
@GetExchange("/partner/{partnerId}") List<PartDTO> getPartsByPartner(@PathVariable UUID partnerId);
@GetExchange("/{partId}") PartDTO getPart(@PathVariable UUID partId);
@PostExchange PartDTO createPart(@RequestBody PartDTO partDTO);
@PutExchange("/{partId}") PartDTO updatePart(
@PathVariable UUID partId, @RequestBody PartDTO partDTO);
@DeleteExchange("/{partId}") void deletePart(@PathVariable UUID partId);
}
But, this is just an interface definition. How do we actually use it?
HttpServiceProxyFactory
The new version also introduced a mechanism to generate the client for the interface, the HttpServiceProxyFactory. Here is how you would use the proxy factory to create a client for your interface definition along with a small configuration class for our RestClient:
@ConfigurationProperties(prefix = "client")
public record ClientConfiguration(String url) {}
and
@Configuration
public class PartsClientProxyConfig {
@Bean
public PartsClient partsClient(ClientConfiguration clientConfiguration) {
RestClient restClient = RestClient.builder().baseUrl(clientConfiguration.url()).build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(PartsClient.class);
}
}
Populate the configuration with the following entry. (assuming the parts microservice is running on port 8081):
client.url=http://localhost:8081
We can now use the PartsClient:
@Service
public class DefaultPartnerPartsService implements PartnerPartsService {
@Autowired
PartsClient partsClient;
@Override
public List<PartDTO> getAllParts(UUID partnerId) {
return partsClient.getPartsByPartner(partnerId);
}
}
Automating HttpServiceProxyFactory Definitions
If you have used OpenFeign, you would remember that you did not have to do all of the above, because it was provided out of the box already. We can add a similar mechanism that automates the creation of HttpServiceProxyFactory so that you do not have to go through for this setup for each upstream service. Such an addition to Spring Framework is already in the works. Its progress can be tracked here:
There is also a third party starter that provides this out of the box:
Or we can simply use BeanDefinitionRegistryPostProcessor for this purpose to roll out our annotation. I will lay out how one might achieve this kind of behavior. We need a
- A new annotation to mark for automatic instantion of
HttpServiceProxyFactoryfor a given interface, I will call itHttpExchangeClient. - To create new
HttpServiceProxyFactory, let’s create aHttpExchangeClientFactory. - To be able to scan the type definitions, we need a component scanner:
HttpExchangeClientScanner. - We also need a class that implements the
BeanDefinitionRegistryPostProcessor, which glues everything together and adds the resulting bean definition to the Bean Registry.
I have created a new subpackage in the project and named it spring and moved the ClientConfiguration there. We do not need the ConfigurationProperties annotation anymore.
public record ClientConfiguration(String url) {}
Let’s create the annotation that will be used to annotate the HttpExchange based clients.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface HttpExchangeClient {
@AliasFor("name")
String value() default "";
String name() default "";
}
We can, then, use this to annotate the PartsClient:
@HttpExchangeClient("parts-client")
@HttpExchange("/api/v1/parts")
public interface PartsClient {
// ...
}
This alone, of course, does not do anything as it is just an annotation. We have to first create a scanner that will pick up anything that is marked with this annotation. To achieve scanning Spring Framework offers ClassPathScanningCandidateComponentProvider. Let’s try using that on its own:
ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider();
scanner.addIncludeFilter(new AnnotationTypeFilter(HttpExchangeClient.class));
assert scanner.findCandidateComponents("").size() == 1;
This assertion will fail as the component scan will only return one entry, but why? This behavior can be tracked to the issue here:
public class InterfaceAwareClassPathScanningCandidateComponentProvider
extends ClassPathScanningCandidateComponentProvider {
public InterfaceAwareClassPathScanningCandidateComponentProvider() {
super();
}
public InterfaceAwareClassPathScanningCandidateComponentProvider(boolean useDefaultFilters) {
super(useDefaultFilters);
}
public InterfaceAwareClassPathScanningCandidateComponentProvider(
boolean useDefaultFilters, Environment environment) {
super(useDefaultFilters, environment);
}
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
return super.isCandidateComponent(beanDefinition) || beanDefinition.getMetadata().isAbstract();
}
}
Switching the above test case to:
InterfaceAwareClassPathScanningCandidateComponentProvider scanner = new InterfaceAwareClassPathScanningCandidateComponentProvider();
scanner.addIncludeFilter(new AnnotationTypeFilter(HttpExchangeClient.class));
assert scanner.findCandidateComponents("").size() == 1;
will work now.
We can make this its own little scanner class and call it HttpExchangeClientScanner:
public class HttpExchangeClientScanner
extends InterfaceAwareClassPathScanningCandidateComponentProvider {
public HttpExchangeClientScanner() {
super();
configure();
}
public HttpExchangeClientScanner(boolean useDefaultFilters) {
super(useDefaultFilters);
configure();
}
public HttpExchangeClientScanner(boolean useDefaultFilters, Environment environment) {
super(useDefaultFilters, environment);
configure();
}
private void configure() {
this.addIncludeFilter(new AnnotationTypeFilter(HttpExchangeClient.class));
}
}
The final piece of the puzzle is the HttpExchangeClientRegistrar:
@Component
public class HttpExchangeClientRegistrar
implements BeanDefinitionRegistryPostProcessor, EnvironmentAware {
Environment environment;
HttpExchangeClientFactory clientFactory = new HttpExchangeClientFactory();
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry)
throws BeansException {
if (registry instanceof ListableBeanFactory) {
ClassPathScanningCandidateComponentProvider scanner = new HttpExchangeClientScanner(false);
scanner.findCandidateComponents("").stream()
.filter(ScannedGenericBeanDefinition.class::isInstance)
.map(ScannedGenericBeanDefinition.class::cast)
.forEach(
(beanDefinition) -> {
var clientClassName = beanDefinition.getBeanClassName();
var metadata = beanDefinition.getMetadata();
var name =
(String)
metadata
.getAnnotationAttributes(HttpExchangeClient.class.getName())
.get("name");
try {
var clazz = Class.forName(clientClassName);
var bd =
BeanDefinitionBuilder.rootBeanDefinition(
ResolvableType.forClass(clazz),
() -> clientFactory.createClient(clazz, name, environment))
.getBeanDefinition();
registry.registerBeanDefinition(clientClassName, bd);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
});
} else {
throw new IllegalStateException("BeanRegistry is not a ListableBeanFactory");
}
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
This uses the scanner we created to scan everything to find BeanDefinitions and extract the name and metadata of PartsClient. From the metadata we can get fetch the HttpExchangeClient annotation and get its name property (parts-client). HttpExchangeClientFactorycreates the client implementation and and we register an instance of our bean under the name we have extracted previously.
Keep in mind that the above code block will not work with Spring AOT, as it needs a little bit more configuration.
Testing
First let’s get Testcontainers setup out of the way:
@TestConfiguration(proxyBeanMethods = false)
public class TestApplicationConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"));
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry, PostgreSQLContainer<?> container) {
registry.add("spring.datasource.url", container::getJdbcUrl);
registry.add("spring.datasource.username", container::getUsername);
registry.add("spring.datasource.password", container::getPassword);
}
}
Then we can use one of the preferred mocking methods to mock our parts microservice.
MockWebServer
You can simply use the MockWebServer offered by OkHttp and test the whole thing in a rather simple manner. Add the following dependency:
testImplementation("com.squareup.okhttp3:mockwebserver:4.9.2")
Let’s setup MockWebServer:
@TestComponent
public class MockWebServerConfiguration {
@Bean
public MockWebServer partsMockWebServer(ClientConfiguration clientConfiguration) throws IOException {
var mockWebServer = new MockWebServer();
mockWebServer.start(8081);
mockWebServer.url("/api/v1/parts");
return mockWebServer;
}
}
Then, we can utilize this
@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Import({TestSpringWebInterfacesProxyApplication.class})
public class PartnerPartsControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private PartnerRepository partnerRepository;
@Autowired
private MockWebServer mockWebServer;
@Test
public void getPartnerParts_whenValidInput_thenReturns200() throws Exception {
// Arrange
Partner partner = Partner.of( "Test Partner", "test@example.com");
partnerRepository.save(partner);
partnerRepository.flush();
mockWebServer.enqueue(new MockResponse()
.setBody(objectMapper.writeValueAsString(
List.of(new PartDTO(UUID.randomUUID(), "name", "description"))
))
.addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
);
// Act & Assert
mockMvc.perform(get("/api/v1/partners/" + partner.getId() + "/parts")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
}
WireMock
If you are looking for a more full fledged for your upstream testing, WireMock might be a better option for you. Add the Spring Cloud Contract WireMock dependency:
testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock:4.1.0")
Change the test to use WireMock instead of MockWebServer
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Import({TestApplicationConfiguration.class, MockWebServerConfiguration.class})
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 0)
@AutoConfigureMockMvc
public class PartnerPartsControllerIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private PartnerRepository partnerRepository;
@Test
public void getPartnerParts_whenValidInput_thenReturns200() throws Exception {
// Arrange
Partner partner = Partner.of("Test Partner", "test@example.com");
partnerRepository.save(partner);
partnerRepository.flush();
stubFor(
WireMock.get(urlPathEqualTo("/api/v1/parts/partner/" + partner.getId()))
.willReturn(
aResponse()
.withStatus(200)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(
objectMapper.writeValueAsString(
List.of(new PartDTO(UUID.randomUUID(), "name", "description"))))));
// Act & Assert
mockMvc
.perform(
get("/api/v1/partners/" + partner.getId() + "/parts")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
}
How Does All of These Work underneath?
When createClient is called MethodIntrospector is used to create an exhaustive list of the methods. These are then filtered if they are annotated with HttpExchange annotation. The method information is then passed over to create a MethodInterceptor, the bulk of the magic happens inside the HttpMethodInterceptor, which makes the invocations for individual HttpServiceMethods. This uses the HttpExchangeAdapter instance we passed (in our case RestClientAdapter) to make actual requests, and a list of pre-made HttpArgumentResolvers to resolve the arguments being passed on to actually populate the related fields in the underlying client.
References
메타데이터
- post_id
- 9cf34550b327
- slug
- declarative-rest-clients-in-spring-framework-6-9cf34550b327
- url
- https://medium.com/@sddkal/declarative-rest-clients-in-spring-framework-6-9cf34550b327
- canonical_url
- https://medium.com/@sddkal/declarative-rest-clients-in-spring-framework-6-9cf34550b327
- author_url
- https://medium.com/@sddkal
- status
- ok
- fetched_at
- 2026-06-28 04:42:08