← Back to list

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…

Sıddık Açıl · 2024-03-11 18:56 · 17 claps · 6.4 min read paywalled
#spring #spring-boot #spring-framework #aop
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3

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:

[embed]Support declarative HTTP clients · Issue #31337 · spring-projects/spring-boot Spring Framework 6.0 introduces declarative HTTP clients. We should add some auto-configuration in Boot which supplies…github.com

There is also a third party starter that provides this out of the box:

[embed]GitHub - DanielLiu1123/httpexchange-spring-boot-starter: The missing starter for Spring 6.x… The missing starter for Spring 6.x declarative HTTP client. Eliminate boilerplate code, server base implementation code…github.com

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 HttpServiceProxyFactory for a given interface, I will call it HttpExchangeClient.
  • To create new HttpServiceProxyFactory, let’s create a HttpExchangeClientFactory.
  • 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:

[embed]ClassPathScanningCandidateComponentProvider doesn't respect AnnotationTypeFilter [SPR-12802] ·… Diego Plentz opened SPR-12802 and commented ClassPathScanningCandidateComponentProvider doesn't find interfaces when…github.com

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

[embed]REST Clients The Spring Framework provides the following choices for making calls to REST endpoints: RestClient - synchronous client…docs.spring.io

[embed]Declarative REST Clients with Spring Framework 6 A spring-native declarative HTTP client for all the lovers of OpenFeign including WebClient supportmedium.com

[embed]Support declarative HTTP clients · Issue #31337 · spring-projects/spring-boot Spring Framework 6.0 introduces declarative HTTP clients. We should add some auto-configuration in Boot which supplies…github.com

[embed]GitHub - DanielLiu1123/httpexchange-spring-boot-starter: The missing starter for Spring 6.x… The missing starter for Spring 6.x declarative HTTP client. Eliminate boilerplate code, server base implementation code…github.com

[embed]GitHub - joshlong/declarative-client-registration: it would be dope if we could just automatically… it would be dope if we could just automatically turn interfaces with the correct annotations into declarative clients…github.com


메타데이터
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