com4dc’s blog

Javaプログラマーのはずだけど運用してます

ClientHttpRequestInterceptor で RestTemplate のリクエスト処理に割り込む

やりたいこと

RestTemplate をつかう際に共通処理をリクエスト前に潜り込ませたい。例

  • ローカルキャッシュに持ってる認証情報を Authorizationヘッダ にセットする
  • 認証情報がなければ取りに行ってローカルキャッシュに保存しつつ Authorizationヘッダにセットする

ClientHttprequestInterceptor インタフェース

RestTemplate には Interceptor を複数設定できるようになってるので、このインタフェースを実装すれば良い。

docs.spring.io

Mock でちゃんと Intercept されるか確認してみる

動作環境は以下

  • openjdk 11.0.6 2020-01-14 LTS
  • macOS Mojave 10.14.6
@ExtendWith(MockitoExtension.class)
public class DummyClientTest {

    @Mock
    TestClientInterceptor interceptor;

    @DisplayName("Interceptor内で処理が行われることを確認")
    @Test
    public void inject_interceptor_success() throws IOException {

        String success = "success";
        var mockResponse = new MockClientHttpResponse(success.getBytes(Charset.defaultCharset()), HttpStatus.OK);
        when(interceptor.intercept(any(), any(), any())).thenReturn(mockResponse);

        var restTemplate = new RestTemplateBuilder().additionalInterceptors(interceptor).build();

        var value = new LinkedMultiValueMap<String, String>();
        value.add("main", "request");

        var requestEntity = RequestEntity
            .post(URI.create("https://example.com/dummy"))
            .body(value);

        var actual = restTemplate.exchange(requestEntity, String.class);
        assertNotNull(actual);
        assertEquals("success", actual.getBody());
    }
}

RestTemplate に何も設定しなければ https://example.com に謎の情報をPostしてしまうので当然エラーで弾かれる。今回は Interceptor 実装を RestTemplate に設定してるのでそちらに処理が委譲されてるはず。 このUnitTestは通る。

23:38:31.434 [Test worker] DEBUG org.springframework.web.client.RestTemplate - HTTP POST https://example.com/dummy
23:38:31.448 [Test worker] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/cbor, application/*+json, */*]
23:38:31.453 [Test worker] DEBUG org.springframework.web.client.RestTemplate - Writing [{main=[request]}] with org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
23:38:31.472 [Test worker] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
23:38:31.474 [Test worker] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/octet-stream"

ちなみにInterceptor を設定しない場合

23:26:16.094 [Test worker] DEBUG org.apache.http.impl.conn.PoolingHttpClientConnectionManager - Connection [id: 0][route: {s}->https://example.com:443] can be kept alive indefinitely
23:26:16.094 [Test worker] DEBUG org.apache.http.impl.conn.DefaultManagedHttpClientConnection - http-outgoing-0: set socket timeout to 0
23:26:16.095 [Test worker] DEBUG org.apache.http.impl.conn.PoolingHttpClientConnectionManager - Connection released: [id: 0][route: {s}->https://example.com:443][total kept alive: 1; route allocated: 1 of 5; total allocated: 1 of 10]

404 Not Found: [<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
         "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w... (445 bytes)]
org.springframework.web.client.HttpClientErrorException$NotFound: 404 Not Found: [<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
         "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w... (445 bytes)]
....

めっちゃエラーで怒られる。ごめんなさい。一応ちゃんとテストで404ナノを確認

    @DisplayName("存在しないURIにアクセスし404が返却される")
    @Test
    public void not_inject_interceptor_failed_notfound() {
        var restTemplate = new RestTemplateBuilder().build();
        var requestEntity = RequestEntity
            .get(URI.create("http://dummy.restapiexample.com/api/v1/dummy")).build();

        var actual = assertThrows(HttpClientErrorException.class, () -> restTemplate.exchange(requestEntity, String.class));
        assertNotNull(actual);
        assertEquals(HttpStatus.NOT_FOUND, actual.getStatusCode());
    }

実装

雑な実装とイメージ。

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
        throws IOException {
    
    // どこか保存したところからATを復元する
    var accessToken = repository.findToken().orElseGet(() -> {
       var token = getNewAccessToken();
       if (token != null) { repository.saveToken(token); }
       return token;
    );
    
    if (accessToken == null) {
        log.warn("can not get AccessToken.");
        throw new TestClientException("can not get AccessToken");
    }
    
    // Bearerトークンとして付与
    request.getHeaders().add(HttpHeaders.AUTHORIZATION, "bearer " + accessToken.getAccessToken());   
    
    var response = execution.execute(request, body);
    var statusCode = response.getStatusCode();
    
    if (statusCode.equals(HttpStatus.UNAUTHORIZED)) {
        // ATが無効. リトライは一回でいいか
        log.info("Expired AccessToken. Try call TokenEndpoint.");
        
        var token = getNewAccessToken();
        
        if (token == null) {
            log.warn("can not get AccessToken.");
            throw new TestClientException("can not get AccessToken");
        }
        // どこか共有するとこにATを保存する
        repository.saveToken(token);
        
        var renewToken = token.getAccessToken();
        request.getHeaders().add(HttpHeaders.AUTHORIZATION, "bearer " + renewToken);
        response = execution.execute(request, body);
    }
    return response;
}

トークン取得、キャッシュ保存、リクエストが何箇所か重複してるのできれいではない気がするんだけど、 とりあえずやりたいことはできた。