読者です 読者をやめる 読者になる 読者になる

GeekFactory

int128.hatenablog.com

Swagger CodegenでPHPクライアントを生成する

Swagger Codegenが生成するPHPクライアントを使う機会があったので、使い方を記事に残しておきます。

Swagger Codegenが生成するファイル

Swagger CodegenPHPテンプレートを指定すると、以下のファイル群が生成されます。

  • SwaggerClient-php/
    • autoload.php
    • composer.json
    • lib/
      • Api/
        • APIクライアントのクラス群(例: PetApi.php
      • Model/
        • モデルクラス群(例: Pet.php

Petstoreの例が参考になります。 swagger-codegen/samples/client/petstore/php at master · swagger-api/swagger-codegen · GitHub から参照できます。

APIクライアントの使い方

APIクライアントのインスタンスを生成し、メソッドを実行します。

require_once(__DIR__ . '/SwaggerClient-php/autoload.php');

// APIクライアントの生成
$pet_api = new Swagger\Client\Api\PetApi();

// APIの実行
$the_pet = $pet_api->getPetById(1);

この例ではID=1のPetモデルを取得しています。getPetById メソッドはAPIクライアントクラス(PetApi.php)で定義されています。

    /**
     * Operation getPetById
     *
     * Find pet by ID
     *
     * @param int $pet_id ID of pet to return (required)
     * @throws \Swagger\Client\ApiException on non-2xx response
     * @return \Swagger\Client\Model\Pet
     */
    public function getPetById($pet_id)

また、getPetById メソッドが返すモデルはモデルクラス(Pet.php)で定義されています。

/**
 * Pet Class Doc Comment
 *
 * @category    Class
 * @package     Swagger\Client
 * @author      Swagger Codegen team
 * @link        https://github.com/swagger-api/swagger-codegen
 */
class Pet implements ArrayAccess

モデルクラスはArrayAccessを実装しているため、配列としてアクセスできるように設計されています。また、GetterとSetterでもアクセスできるようになっています。

$the_pet->getName();
$the_pet['name'];

$the_pet->setName('foo');
$the_pet['name'] = 'foo';

例外処理

APIが200番台以外のステータスコードを返した場合は例外が発生します。ステータスコードやレスポンスは例外オブジェクトから取得できます。

try {
    $the_pet = $pet_api->getPetById(1);
} catch (Swagger\Client\ApiException $e) {
    echo 'Caught exception: ', $e->getMessage(), "\n";
    echo 'HTTP response headers: ', $e->getResponseHeaders(), "\n";
    echo 'HTTP response body: ', $e->getResponseBody(), "\n";
    echo 'HTTP status code: ', $e->getCode(), "\n";
}

APIクライアントの設定とカスタマイズ

APIクライアントの getConfig メソッドでヘッダやOAuthなどを設定できるようになっています。

$pet_api->getApiClient()->getConfig();

詳しくは swagger-codegen/Configuration.php at master · swagger-api/swagger-codegen · GitHub が参考になります。

今のところ、APIクライアントにはインターセプタの仕組みは用意されていないようです。共通処理を入れたい場合は ApiClient クラスを継承した独自クラスを定義し、APIクライアントのコンストラクタに渡すとよいでしょう。

class MyApiClient extends Swagger\Client\ApiClient
{
    public function callApi($resourcePath, $method, $queryParams, $postData, $headerParams, $responseType = null, $endpointPath = null)
    {
        // TODO: 事前処理
        $response = parent::callApi($resourcePath, $method, $queryParams, $postData, $headerParams, $responseType, $endpointPath);
        // TODO: 事後処理
        return $response;
    }
}
$pet_api = new Swagger\Client\Api\PetApi(new MyApiClient());

依存関係の管理

composer.jsonとautoload.phpが生成されるので、Composerで依存関係を管理することも可能です。ここで力尽きたので詳細は割愛します。

Spring Security OAuth2のリトライを@Retryableで書く

Spring Security OAuth2のアクセストークン取得で接続失敗に対してリトライを行いたい場合、Spring Retryを使うと簡単に実現できます。

やりたいこと

  • Spring BootのアプリでOAuth 2.0クライアントを利用する。
  • Spring Security OAuth2でアクセストークンを取得する。( OAuth2RestTemplateFeignRequestInterceptor は内部でSpring Security OAuth2を利用している)
  • OAuthクライアントの接続失敗でリトライしたい。

実現方法

Spring Security OAuth2の AccessTokenProvider にはインターセプタを設定するメソッドが用意されており、アクセストークン取得のリクエストを投げる前後で任意の処理を入れることが可能です。 インターセプタのメソッドにSpring Retryの @Retryable アノテーションを付けることで、簡単にリトライを実現できます。

@EnableRetry
@Configuration
class OAuth2Configuration {
  @Autowired
  RetryableAccessTokenRequestInterceptor accessTokenRequestInterceptor

  @Bean
  AccessTokenProvider accessTokenProvider() {
    def accessTokenProviders = [
      new AuthorizationCodeAccessTokenProvider(),
      new ImplicitAccessTokenProvider(),
      new ResourceOwnerPasswordAccessTokenProvider(),
      new ClientCredentialsAccessTokenProvider()
    ]
    // インターセプタを設定する
    accessTokenProviders*.interceptors = [accessTokenRequestInterceptor()]
    new AccessTokenProviderChain(accessTokenProviders)
  }
}
@Component
class RetryableAccessTokenRequestInterceptor implements ClientHttpRequestInterceptor {
  // 接続失敗に対するリトライを宣言する
  @Retryable(value = {ConnectException.class}, backoff = @Backoff(delay = 500))
  @Override
  ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
    execution.execute(request, body)
  }
}

あらかじめ、依存関係にSpring Retryを追加しておく必要があります。

実行結果

リトライの様子を確認するため、ログレベルを設定しておきます。

logging.level:
  # リトライのログ
  org.springframework.retry: DEBUG
  # Spring Security OAuth2の通信ログ
  org.apache.http.wire: DEBUG

アクセストークン取得を実行すると、以下のようなログが出力されます。

o.s.retry.support.RetryTemplate          : Retry: count=0
org.apache.http.wire                     : http-outgoing-1 << "[read] I/O error: Connection reset"
o.s.retry.support.RetryTemplate          : Checking for rethrow: count=1
o.s.retry.support.RetryTemplate          : Retry: count=1
o.s.retry.support.RetryTemplate          : Checking for rethrow: count=2
o.s.retry.support.RetryTemplate          : Retry: count=2
o.s.retry.support.RetryTemplate          : Checking for rethrow: count=3
o.s.retry.support.RetryTemplate          : Retry failed last attempt: count=3

まとめ

インターセプタはアクセストークン取得のリクエストを微修正する*1ことが本来の用途ですが、リトライの用途にも使えます。

*1:RFCに準拠していないサービスに対応させるためのハックとか

Spring BootアプリのテストをSpockで書く(続編)

以前にSpring BootアプリケーションのテストをSpockで書く方法を紹介しましたが、この方法ではテストの所要時間が長くなる問題がありました。本稿では他の方法を紹介します。

int128.hatenablog.com

具体的には、インナークラスの @TestConfiguration でMockを定義するとSpecificationクラスごとにApplication Contextが再生成されてしまうため、スローテストの原因になる問題がありました。

    // Specificationのインナークラス
    @TestConfiguration
    static class MockConfig {
        final detachedMockFactory = new DetachedMockFactory()

        @Bean
        ExternalApiClient externalApiClient() {
            detachedMockFactory.Mock(ExternalApiClient)
        }
    }

特定のテストケースだけBeanをMockに置換したい場合はこの方法が必要ですが、多くの場合は必要ないはずです。その前にプロダクトコードの設計を見直す方がよいでしょう。

コンポーネントテスト

コンポーネントテストのレベルでは、コンストラクタでMockを注入する方法で十分です。

// プロダクトコード
@Component
class BarService {
    final ExternalApiClient client

    BarService(ExternalApiClient client) {
        this.client = client
        assert client
    }
}
// テストコード
@SpringBootTest(webEnvironment = NONE)
class BarServiceSpec extends Specification {
    @Subject BarService service

    ExternalApiClient client = Mock()

    def setup() {
        service = new BarService(client)
    }
}

Mockではなく本物のBeanが必要な場合は、Specificationクラスに @Autowired なプロパティを追加してBeanを取得します。

@SpringBootTest(webEnvironment = NONE)
class BarServiceSpec extends Specification {
    @Subject BarService service

    ExternalApiClient client = Mock()  // Mock Bean

    @Autowired HelloProvider provider  // 本物のBean

    def setup() {
        service = new BarService(client, provider)
    }
}

E2Eテスト

冒頭で述べた方法ではSpecificationごとにMockを定義していましたが、全テストケースで共通のMockを定義するとApplication Contextが再利用されるので所要時間が大幅に短くなります。

// Mock定義
@Configuration
class IntegrationTestConfiguration {
    private final detachedMockFactory = new DetachedMockFactory()

    @Bean
    ExternalApiClient externalApiClient() {
        detachedMockFactory.Mock(ExternalApiClient)
    }
}
// テストコード
@SpringBootTest(webEnvironment = RANDOM_PORT)
class BarControllerSpec extends Specification {
    @Autowired TestRestTemplate restTemplate

    @Autowired ExternalApiClient client
}

@Primary を付けるなどの工夫をすれば、この方法とSpecificationごとにMockを定義する方法を組み合わせて利用できると思います。(未検証…)

まとめ

これまでに以下の方法を紹介しました。

  • SpecificationクラスごとにMockを定義する方法
    • Specificationクラスごとに本物とMockを使い分けられる。
    • 個別にApplication Contextが生成されるので、スローテストの原因になる。
  • コンストラクタでMockを注入する方法
    • Application Contextが再利用されるので、テストの所要時間が短い。
    • テストコードでコンストラクタ呼び出しを記述するのが面倒。
  • 全テストケースで共通のMockを定義する方法
    • Application Contextが再利用されるので、テストの所要時間が短い。
    • Mock定義が共通なので融通が利かない。

参考までにGitHubにサンプルプロジェクトを置いています。

github.com