HTTP Interface Clients

Declare a remote API as a Java interface with @HttpExchange and let Spring generate the client, grouped and configured with @ImportHttpServices.

Syntax@HttpExchange(url = "/base") interface RemoteApi { @GetExchange("/{id}") Dto get(@PathVariable String id); } @ImportHttpServices(group = "remote", types = RemoteApi.class) spring.http.serviceclient.remote.base-url=https://remote.example.com

A hand-written RestClient class repeats the same pattern for every endpoint: method, URI, retrieve, convert. An HTTP interface removes that boilerplate. You describe the remote API as a Java interface with annotations from org.springframework.web.service.annotation, and Spring creates a proxy that makes the calls through a RestClient.

@HttpExchange(url = "/products", accept = "application/json")
interface CatalogApi {

    @GetExchange("/{sku}")
    Product product(@PathVariable String sku);

    @PostExchange
    ResponseEntity<Void> create(@RequestBody NewProduct product);
}

@GetExchange, @PostExchange, @PutExchange, @PatchExchange and @DeleteExchange mirror the server-side mapping annotations, and the parameters use the same @PathVariable, @RequestParam, @RequestHeader and @RequestBody you know from controllers. The return type decides what you get: a DTO for the body, ResponseEntity<T> for status and headers, void when you only care that it succeeded.

Registering clients

Spring Framework 7 added a registry for these proxies. Put @ImportHttpServices on a configuration class, name a group and list the interfaces. Each group gets its own RestClient, and Spring Boot binds spring.http.serviceclient.<group>.* properties to it: base-url, timeouts, default headers. The interfaces then become beans you inject like any other.

Group by remote service, not by interface. Three interfaces that all talk to the billing system belong in one billing group so they share one base URL, one set of timeouts and one authentication setup.

When to use which

Interfaces shine when the remote API has many endpoints with plain request and response shapes. A direct RestClient is easier when a call needs unusual handling, such as streaming a large body or reading an error response in a special way. You can mix them: the interface for the common calls, and a RestClientHttpServiceGroupConfigurer bean to add interceptors or status handlers to a group's client.

Error behaviour is the same as RestClient: a 4xx or 5xx response throws HttpClientErrorException or HttpServerErrorException. Do not let those escape from the interface into your controllers. Wrap the interface in a small service that translates failures into your own exceptions, or configure a status handler for the group.

A common mistake is forgetting the base URL for a group; calls then go to a relative URL and fail at runtime, not at startup. Keep the property next to the group name and cover each client with at least one test.

Example

Example · java
import java.math.BigDecimal;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
import org.springframework.web.service.registry.ImportHttpServices;

record Product(String sku, String name, BigDecimal price, int stock) {
}

record StockAdjustment(String sku, int delta, String reason) {
}

@HttpExchange(url = "/products", accept = "application/json")
interface CatalogApi {

    @GetExchange("/{sku}")
    Product product(@PathVariable String sku);

    @GetExchange
    List<Product> search(@RequestParam("q") String term, @RequestParam("limit") int limit);

    @PostExchange("/stock-adjustments")
    ResponseEntity<Void> adjustStock(@RequestBody StockAdjustment adjustment);
}

@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "catalog", types = CatalogApi.class)
class HttpServicesConfig {
    // base URL and timeouts come from spring.http.serviceclient.catalog.*
}

@Service
public class StockService {

    private final CatalogApi catalog;

    StockService(CatalogApi catalog) {
        this.catalog = catalog;   // a generated proxy backed by the group's RestClient
    }

    public boolean reserve(String sku, int quantity) {
        Product product = catalog.product(sku);
        if (product.stock() < quantity) {
            return false;
        }
        catalog.adjustStock(new StockAdjustment(sku, -quantity, "order reservation"));
        return true;
    }
}

When to use it

  • Wrap a partner's twenty-endpoint REST API as one annotated interface instead of twenty hand-written RestClient methods.
  • Point the catalogue client at a local stub in development and at the internal service in production by changing only spring.http.serviceclient.catalog.base-url.
  • Give the billing group a longer read timeout and its own API key without touching the other HTTP clients.

More examples

Configuring a group with properties

Each key under <code>spring.http.serviceclient</code> is a group name from <code>@ImportHttpServices</code>. Settings you leave out fall back to the global <code>spring.http.clients.*</code> values, so you only override what differs, such as the slower billing service here. Taking the URL from an environment variable keeps the same jar deployable to every environment.

Example · yaml
spring:
  http:
    serviceclient:
      catalog:
        base-url: https://catalog.internal.example.com
        connect-timeout: 1s
        read-timeout: 3s
        default-header:
          X-Client-Name: orders-service
      billing:
        base-url: ${BILLING_URL}
        read-timeout: 10s

Adding authentication to one group

A <code>RestClientHttpServiceGroupConfigurer</code> receives every group's <code>RestClient.Builder</code> before the proxies are created. <code>filterByName</code> limits the change to one group, so the billing key is never sent to the catalogue service. The key itself comes from configuration, which in production should be an environment variable or a secret store, never a literal.

Example · java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;

@Configuration(proxyBeanMethods = false)
public class BillingClientConfig {

    @Bean
    RestClientHttpServiceGroupConfigurer billingAuth(@Value("${app.billing.api-key}") String apiKey) {
        return groups -> groups.filterByName("billing")
                .forEachClient((group, builder) -> builder
                        .defaultHeader("Authorization", "Bearer " + apiKey));
    }
}

Creating a proxy by hand

This is the lower-level route the registry automates. It is useful when one client needs a <code>RestClient</code> you build yourself, for example with its own request factory. Because the builder is injected, Boot's converters, timeouts and observation still apply. For ordinary cases prefer <code>@ImportHttpServices</code>, which needs no factory code at all.

Example · java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

record Coordinates(double latitude, double longitude) {
}

interface GeocoderApi {

    @GetExchange("/geocode")
    Coordinates lookup(@RequestParam("postcode") String postcode);
}

@Configuration(proxyBeanMethods = false)
public class GeocoderConfig {

    @Bean
    GeocoderApi geocoderApi(RestClient.Builder builder, @Value("${app.geocoder.base-url}") String baseUrl) {
        RestClient rest = builder.baseUrl(baseUrl).build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(rest))
                .build();
        return factory.createClient(GeocoderApi.class);
    }
}

Discussion

  • Be the first to comment on this lesson.