Course topics

By WebNest Studio

Spring Boot Tutorial

Calling REST APIs with RestClient

Modern applications constantly call other services: payment gateways, email providers, weather APIs, and your own microservices. Spring's RestClient, introduced in Spring Framework 6.1, is the recommended synchronous HTTP client for Spring MVC applications. It has a fluent API similar to WebClient but blocking, which pairs perfectly with virtual threads.

This lesson covers creating RestClient instances with Spring Boot's auto-configured builder, GET/POST/PUT/DELETE requests, path and query parameters, headers and authentication, reading responses and status codes, error handling, timeouts, logging interceptors, SSRF protection, and testing with MockRestServiceServer.

RestClient vs RestTemplate vs WebClient

RestTemplate is the classic client; it still works but is in maintenance mode and its many overloaded methods are awkward. WebClient is the reactive, non-blocking client for WebFlux applications. RestClient is the modern choice for everything else. It shares message converters and interceptors with the rest of Spring MVC and can be created from an existing RestTemplate configuration during migration.

The Auto-Configured Builder

In Spring Boot 4, HTTP client support has its own starter: add spring-boot-starter-restclient (the web starter alone no longer brings it). Spring Boot then provides a prototype RestClient.Builder bean already configured with the application's JSON mapper, HTTP client settings and any RestClientCustomizer beans. Inject the builder, set a base URL and default headers, and build one RestClient per remote service. Global timeouts and redirects are configured with spring.http.clients.* properties.

Making Requests

The chain is: method (get(), post()...) → uri(...) with templates and variables → optional headers, contentType, body → retrieve() → body(Type.class), toEntity(Type.class) (with status and headers) or toBodilessEntity(). For generic types such as lists use ParameterizedTypeReference. exchange(...) gives full manual control over the response.

Error Handling

By default retrieve() throws HttpClientErrorException for 4xx and HttpServerErrorException for 5xx responses. Use onStatus(predicate, handler) to translate specific statuses into your own exceptions — for example 404 into Optional.empty() or a ProductNotFoundException. Network failures and timeouts throw ResourceAccessException. Combine with retries (see the resilience lesson) for transient failures.

Timeouts and Security

Always set connect and read timeouts; without them, a hung remote service can block your threads indefinitely. When URLs come from users (webhooks, link previews), protect against SSRF with Spring Boot 4.1's InetAddressFilter, which blocks requests to internal network addresses.

Examples

A client for an external API built from the auto-configured builder

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-restclient</artifactId>   <!-- new in Spring Boot 4 -->
</dependency>

# application.yml
spring:
  http:
    clients:
      connect-timeout: 2s
      read-timeout: 5s
github:
  base-url: https://api.github.com
  token: ${GITHUB_TOKEN}

public record GithubRepo(String name, @JsonProperty("stargazers_count") int stars, String language) {}

@Component
public class GithubClient {

    private final RestClient client;

    public GithubClient(RestClient.Builder builder,
                        @Value("${github.base-url}") String baseUrl,
                        @Value("${github.token}") String token) {
        this.client = builder
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github+json")
            .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .build();
    }

    public List<GithubRepo> reposOf(String org) {
        return client.get()
            .uri(uri -> uri.path("/orgs/{org}/repos")
                .queryParam("sort", "updated")
                .queryParam("per_page", 5)
                .build(org))
            .retrieve()
            .body(new ParameterizedTypeReference<>() {});
    }
}
Output
githubClient.reposOf("spring-projects")
[GithubRepo[name=spring-boot, stars=77000, language=Java],
 GithubRepo[name=spring-framework, stars=58000, language=Java], ...]

POST, PUT, DELETE and reading status and headers

Java
public record CreatePaymentRequest(long amountPaise, String currency, String orderRef) {}
public record Payment(String id, String status) {}

public Payment createPayment(CreatePaymentRequest req, String idempotencyKey) {
    ResponseEntity<Payment> response = client.post()
        .uri("/v1/payments")
        .contentType(MediaType.APPLICATION_JSON)
        .header("Idempotency-Key", idempotencyKey)
        .body(req)
        .retrieve()
        .toEntity(Payment.class);

    log.info("status={} location={}", response.getStatusCode(), response.getHeaders().getLocation());
    return response.getBody();
}

public void updateMetadata(String paymentId, Map<String, String> metadata) {
    client.put().uri("/v1/payments/{id}/metadata", paymentId)
        .body(metadata)
        .retrieve()
        .toBodilessEntity();
}

public void cancel(String paymentId) {
    client.delete().uri("/v1/payments/{id}", paymentId).retrieve().toBodilessEntity();
}
Output
status=201 CREATED location=https://pay.example.com/v1/payments/pay_91x
Payment[id=pay_91x, status=CREATED]

Translating error statuses into domain results

Java
public Optional<Product> findProduct(long id) {
    try {
        return Optional.ofNullable(client.get()
            .uri("/products/{id}", id)
            .retrieve()
            .onStatus(status -> status.value() == 404, (request, response) -> {
                throw new ProductNotFoundException(id);
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new CatalogUnavailableException("Catalog returned " + response.getStatusCode());
            })
            .body(Product.class));
    } catch (ProductNotFoundException e) {
        return Optional.empty();
    } catch (ResourceAccessException e) {       // connection refused, timeout
        throw new CatalogUnavailableException("Catalog unreachable", e);
    }
}
Output
findProduct(1)   -> Optional[Product[id=1, name=Hoodie]]
findProduct(999) -> Optional.empty
(catalog down)   -> CatalogUnavailableException: Catalog unreachable

A logging interceptor applied to all RestClients

Java
@Bean
RestClientCustomizer loggingCustomizer() {
    return builder -> builder.requestInterceptor((request, body, execution) -> {
        long start = System.nanoTime();
        ClientHttpResponse response = execution.execute(request, body);
        log.info("HTTP {} {} -> {} in {} ms", request.getMethod(), request.getURI(),
            response.getStatusCode().value(), (System.nanoTime() - start) / 1_000_000);
        return response;
    });
}
Output
INFO  HTTP GET https://api.github.com/orgs/spring-projects/repos?sort=updated&per_page=5 -> 200 in 312 ms

Testing a client with MockRestServiceServer

Java
// test dependency: spring-boot-starter-restclient-test
@RestClientTest(GithubClient.class)
@TestPropertySource(properties = {"github.base-url=https://api.github.com", "github.token=test"})
class GithubClientTest {

    @Autowired GithubClient client;
    @Autowired MockRestServiceServer server;

    @Test
    void parsesRepositories() {
        server.expect(requestTo("https://api.github.com/orgs/webnest/repos?sort=updated&per_page=5"))
              .andExpect(header("Authorization", "Bearer test"))
              .andRespond(withSuccess("""
                  [{"name":"shop","stargazers_count":12,"language":"Java"}]
                  """, MediaType.APPLICATION_JSON));

        assertThat(client.reposOf("webnest"))
            .containsExactly(new GithubRepo("shop", 12, "Java"));
    }
}
Output
GithubClientTest > parsesRepositories() PASSED (no real network call)

Common Mistakes

  • Expecting RestClient.Builder to be injectable in Spring Boot 4 with only spring-boot-starter-webmvc; add spring-boot-starter-restclient.
  • Creating RestClient.create() everywhere instead of using the auto-configured builder, losing shared JSON settings and customizers.
  • Not setting connect and read timeouts, so a slow partner API exhausts your request threads.
  • Building URLs by string concatenation with user input instead of URI templates, breaking encoding and enabling injection.
  • Catching every exception and returning null, hiding outages from monitoring.
  • Calling URLs supplied by users without SSRF protection.

Key Points to Remember

  • RestClient is the modern synchronous HTTP client for Spring MVC applications.
  • Inject the auto-configured RestClient.Builder and configure one client per remote service.
  • retrieve().body(), toEntity() and toBodilessEntity() cover most response needs.
  • Use onStatus to map HTTP errors to domain exceptions; set timeouts via spring.http.clients.*.
  • Test clients with @RestClientTest and MockRestServiceServer.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.