RestClient(Spring Boot 3.2+)で外部APIを呼び出す

1. 概要

Spring Framework 6.1(Spring Boot 3.2と同梱)から、SpringにRestClientが追加されました。これはHTTPクライアント(ブロッキング)で、WebClientのようなフルエントAPIを使いますが、Reactorは不要です。このガイドでは、RestClientの作成、GET/POSTの呼び出し、エラーハンドリング、タイムアウト設定、インターセプタの追加、テストの作成方法をご紹介します。読み終わったら、新規プロジェクトでRestTemplateをRestClientに置き換えることができます。

2. インストール

RestClientはspring-webモジュール内にあります。Starter webにはこのモジュールと、JSONを変換するためのJacksonが既に含まれています:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

このガイド全体を通じて、https://api.shop.exampleにある架空の注文APIを呼び出します。データをrecordで宣言します:

public record Order(Long id, String customerName, BigDecimal total, String status) {}

public record CreateOrderRequest(String customerName, BigDecimal total) {}

ベースURLはapplication.propertiesに設定されます:

shop.api.base-url=https://api.shop.example

3. RestClientの作成

3.1. 最速の方法

RestClient restClient = RestClient.create("https://api.shop.example");

これはスクリプトの試行や実行に適しています。Spring Bootアプリケーションでは、Bootが提供するbuilderを使用する必要があります。

3.2. Spring BootのRestClient.Builderを使用する

Spring Bootは自動的にRestClient.Builder beanを構成します。このbuilderにはアプリケーションのHttpMessageConverterが既に付属しており、例えばカスタマイズしたObjectMapperなどがあります。また、Micrometerを通じたobservabilityもサポートしています。

@Component
public class OrderClient {

    private final RestClient restClient;

    public OrderClient(RestClient.Builder builder,
                       @Value("${shop.api.base-url}") String baseUrl) {
        this.restClient = builder
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

説明:

  • baseUrl(...):その後の呼び出しには、/orders/42などの相対パスを渡すだけで済みます。
  • defaultHeader(...):このヘッダはすべてのリクエストに付加されます。
  • build():RestClientインスタンスを作成します。このインスタンスはスレッドセーフなので、一度作成して再利用できます。

注意: RestClient.Builder beanのスコープはprototypeです。注入するたびに、新しいbuilderを受け取ります。したがって、OrderClientでの設定は他のクライアントに影響しません。すべてのbuilderに共通設定を適用するには、RestClientCustomizer beanを宣言してください。

4. GET リクエストの送信

4.1. bodyをオブジェクトとして取得する

public Order getOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .retrieve()
            .body(Order.class);
}
  • get():HTTPメソッドを選択します。
  • uri("/orders/{id}", id):変数{id}は自動的に置き換えられてエンコードされます。結果はGET https://api.shop.example/orders/42になります。
  • retrieve():リクエストを送信してレスポンスを読む準備をします。
  • body(Order.class):JacksonはJSONをOrderレコードに変換します。

4.2. ステータスとヘッダを一緒に取得する

ステータスコードまたはヘッダが必要な場合は、toEntityを使用します:

ResponseEntity<Order> response = restClient.get()
        .uri("/orders/{id}", 42)
        .retrieve()
        .toEntity(Order.class);

System.out.println(response.getStatusCode());
System.out.println(response.getHeaders().getContentType());
System.out.println(response.getBody());
200 OK
application/json
Order[id=42, customerName=Lan, total=250000, status=PAID]

4.3. リストとクエリパラメータを取得する

List<Order>のようなジェネリック型は実行時に消去されます。したがって、ParameterizedTypeReferenceが必要です:

public List<Order> findOrders(String status, int page) {
    return restClient.get()
            .uri(uriBuilder -> uriBuilder
                    .path("/orders")
                    .queryParam("status", status)
                    .queryParam("page", page)
                    .build())
            .retrieve()
            .body(new ParameterizedTypeReference<List<Order>>() {});
}

findOrders("PAID", 1)呼び出しはGET /orders?status=PAID&page=1リクエストを作成します。uriBuilderを使用する場合でもベースURLは保持されます。

5. POST、PUT、DELETE リクエストの送信

public Order createOrder(CreateOrderRequest request) {
    return restClient.post()
            .uri("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(Order.class);
}

public void cancelOrder(Long id) {
    restClient.delete()
            .uri("/orders/{id}", id)
            .retrieve()
            .toBodilessEntity();
}
  • body(request):オブジェクトは宣言したcontentTypeのおかげでJSONにシリアル化されます。
  • toBodilessEntity():bodyを読む必要がない場合に使用します。このメソッドはリクエストをトリガーしてエラーを処理します。

put()とpatch()はpost()と同じように使用します。

注意: retrieve()を呼び出した後、body(...)、toEntity(...)、またはtoBodilessEntity()を呼び出さないと、リクエストは送信されません。これはDELETE呼び出しでよくある間違いです。

6. エラーハンドリング

6.1. デフォルト動作

デフォルトでは、retrieve()はレスポンスのステータスが失敗の場合、例外をスローします:

  • 4xx:HttpClientErrorExceptionをスローします、例えばHttpClientErrorException.NotFound。
  • 5xx:HttpServerErrorExceptionをスローします。

接続エラーまたはタイムアウトはResourceAccessExceptionをスローします。

6.2. onStatusでカスタマイズする

通常、HTTPエラーをビジネスロジック例外に変換したい場合があります:

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("注文 " + id + " が見つかりません");
    }
}
public Order getOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .retrieve()
            .onStatus(status -> status.value() == 404, (request, response) -> {
                throw new OrderNotFoundException(id);
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new IllegalStateException(
                        "Shop APIエラー:" + response.getStatusCode());
            })
            .body(Order.class);
}

ハンドラは宣言された順序でチェックされます。ステータスに一致する最初のハンドラが実行されます。ハンドラがない場合は、セクション6.1のデフォルト動作が適用されます。

6.3. exchangeで完全に制御する

エラーbodyを読む必要がある場合、またはステータスに基づいて分岐する場合は、exchangeを使用します:

public Optional<Order> findOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .exchange((request, response) -> {
                if (response.getStatusCode().value() == 404) {
                    return Optional.empty();
                }
                if (response.getStatusCode().isError()) {
                    throw new IllegalStateException("エラー " + response.getStatusCode());
                }
                return Optional.ofNullable(
                        response.bodyTo(Order.class));
            });
}

注意: exchange内では、デフォルトのステータスハンドラは適用されません。ステータスを自分でチェックする必要があります。忘れると、エラーbodyがOrderにパースされて、フィールドがnullになる可能性があります。

7. タイムアウトと基盤となるHTTPクライアント

デフォルトでは、RestClientはclasspath上にあるHTTPライブラリを次の順序で選択します:Apache HttpClient 5、Jetty、Reactor Netty、JDKのjava.net.http.HttpClient、その後SimpleClientHttpRequestFactory。常にタイムアウトを設定する必要があります。そうしないと、遅いAPIがスレッドを長時間占有する可能性があります。

@Configuration
public class ShopClientConfig {

    @Bean
    public RestClientCustomizer timeoutCustomizer() {
        return builder -> {
            var factory = new SimpleClientHttpRequestFactory();
            factory.setConnectTimeout(Duration.ofSeconds(2));
            factory.setReadTimeout(Duration.ofSeconds(5));
            builder.requestFactory(factory);
        };
    }
}

RestClientCustomizerはSpring Bootが作成するすべてのRestClient.Builderに適用されます。特定のクライアントだけにタイムアウトを設定したい場合は、そのクライアントのコンストラクタ内でbuilder.requestFactory(factory)を呼び出します。

遅いAPIの場合、リクエストは5秒後に失敗します:

org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out

Spring Boot 3.4以降、プロパティspring.http.client.connect-timeoutとspring.http.client.read-timeoutでタイムアウトを宣言できます。

8. インターセプタ:トークンの付加、ログ記録

ClientHttpRequestInterceptorを使うと、すべてのリクエストに介入できます。例えば、次のコードはAPIキーを付加して、応答時間をログに記録します:

@Slf4j
public class ApiKeyInterceptor implements ClientHttpRequestInterceptor {

    private final String apiKey;

    public ApiKeyInterceptor(String apiKey) {
        this.apiKey = apiKey;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        request.getHeaders().set("X-Api-Key", apiKey);
        long start = System.currentTimeMillis();
        ClientHttpResponse response = execution.execute(request, body);
        log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getURI(),
                response.getStatusCode(), System.currentTimeMillis() - start);
        return response;
    }
}

builderでクライアントを構築するときにインターセプタを登録します:

this.restClient = builder
        .baseUrl(baseUrl)
        .requestInterceptor(new ApiKeyInterceptor(apiKey))
        .build();
INFO  ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)

注意: ログを記録するためにinterceptor内でresponse.getBody()を読まないでください。ストリームは一度だけ読むことができます。読む必要がある場合は、request factoryをBufferingClientHttpRequestFactoryでラップしてください。

9. HTTPインターフェース:インターフェースでクライアントを宣言する

個々の呼び出しを手動で書く代わりに、インターフェースでAPIを記述できます。SpringはRestClientに基づいて実装を生成します:

public interface OrderApi {

    @GetExchange("/orders/{id}")
    Order getOrder(@PathVariable Long id);

    @PostExchange("/orders")
    Order createOrder(@RequestBody CreateOrderRequest request);
}
@Configuration
public class OrderApiConfig {

    @Bean
    public OrderApi orderApi(RestClient.Builder builder,
                             @Value("${shop.api.base-url}") String baseUrl) {
        RestClient restClient = builder.baseUrl(baseUrl).build();
        return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build()
                .createClient(OrderApi.class);
    }
}

OrderApiをinjectしてorderApi.getOrder(42L)を呼び出すだけです。このアプローチはAPIが多くのエンドポイントを持つ場合に便利です。タイムアウト、インターセプタ、ステータスハンドラは、基盤となるRestClientで構成されたままです。

10. @RestClientTestでテストを作成する

Spring Boot 3.2以降、@RestClientTestはRestClientをサポートします。このannotationはHTTPクライアント関連のbeanだけを初期化します。また、MockRestServiceServerをRestClient.Builderに接続するため、実際のリクエストが外部に送信されることはありません。

セクション6.2のonStatusを持つgetOrderバージョンでOrderClientをテストします:

@RestClientTest(components = OrderClient.class,
        properties = "shop.api.base-url=https://api.shop.example")
class OrderClientTest {

    @Autowired
    private OrderClient orderClient;

    @Autowired
    private MockRestServiceServer server;

    @Test
    void givenExistingOrder_whenGetOrder_thenReturnsOrder() {
        server.expect(requestTo("https://api.shop.example/orders/42"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess("""
                        {"id":42,"customerName":"Lan","total":250000,"status":"PAID"}
                        """, MediaType.APPLICATION_JSON));

        Order order = orderClient.getOrder(42L);

        assertThat(order.id()).isEqualTo(42L);
        assertThat(order.customerName()).isEqualTo("Lan");
        assertThat(order.total()).isEqualByComparingTo("250000");
        server.verify();
    }

    @Test
    void givenMissingOrder_whenGetOrder_thenThrowsOrderNotFound() {
        server.expect(requestTo("https://api.shop.example/orders/99"))
                .andRespond(withStatus(HttpStatus.NOT_FOUND));

        assertThatThrownBy(() -> orderClient.getOrder(99L))
                .isInstanceOf(OrderNotFoundException.class)
                .hasMessage("注文 99 が見つかりません");
    }
}

必要な静的インポートは、org.springframework.test.web.client.matchとorg.springframework.test.web.client.responseパッケージのMockRestRequestMatchersとMockRestResponseCreatorsにあります。

  • server.expect(...):テストが期待するリクエストを宣言します。
  • andRespond(...):そのリクエストに対してモックレスポンスを返します。
  • server.verify():すべての宣言されたリクエストが実際に送信されたことを確認します。

注意: MockRestServiceServerはSpringが注入するRestClient.Builderから作成されたクライアントでのみ機能します。クラス内でRestClient.create(...)を呼び出すと、テストは実際のリクエストをネットワークに送信します。

11. RestClientを使用しない場合

  • WebFlux(リアクティブ)アプリケーション: RestClientはスレッドをブロックします。リアクティブパイプラインで呼び出すと、イベントループが塞がります。WebClientを使用してください。
  • 大規模データのストリーミングまたはノンブロッキングServer-Sent Events: WebClientがより適しています。
  • RestTemplateで問題なく動作する既存コード: 書き直す必要はありません。RestTemplateは依然サポートされており、新機能は受け取りません。RestClient.create(restTemplate)を使用して段階的に移行し、既存の設定を保持できます。

アプリケーションがJava 21で仮想スレッド(spring.threads.virtual.enabled=true)で実行されている場合、RestClientのブロッキングモデルはスケーラビリティ上の欠点がほぼありません。コードは依然として単純でデバッグしやすいです。

12. 結論

このガイドでは、フルエントで読みやすいAPIで外部APIを呼び出すためにRestClientを使用しました。Spring Bootのbuilderからクライアントを作成し、GET/POSTを送信し、onStatusまたはexchangeでHTTPエラーをビジネス例外に変換し、タイムアウトを設定し、インターセプタを追加し、HTTPインターフェースでクライアントを宣言するという手順を説明しました。最後に、実際のネットワーク呼び出しなしで@RestClientTestとMockRestServiceServerでテストを作成しました。

servlet型の3.2以降のSpring Bootプロジェクトの場合、RestClientはRestTemplateの代わりにデフォルトの選択肢である必要があります。2つのことを忘れないでください:常にタイムアウトを設定し、retrieve()チェーンは常にレスポンス読み取りメソッドで終了させてください。

spring-bootrestclienthttprest-apitesting