Invocationを使ってRetrofitメソッドの戻り値・引数の型に応じてHTTPヘッダの値を変える

2018年11月18日にリリースされた[Retrofit 2.5.0](https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-250-2018-11-18)から、OkHttpのInterceptorに `Invocation` というタグが渡されるようになり、Interceptorにおいて、呼び出されたRetrofitメソッドの情報を取得できるようになりました。

今回は、実際に使ってみて便利だった例として、メソッドの戻り値や `@Body` 引数の型に応じてHTTPヘッダの値を変える方法を紹介します。なお、ここではKotlinでの実装例を紹介していますが、Javaでも同じ手法が利用可能です。

以下では、私のケースの背景を順を追って説明していますが、`Invocation` を使ったコード例は最後の[Invocationを使う解決方法](#invocation-を使う解決方法)に書いてありますので、お急ぎの方はそちらにジャンプしてください。

# 解決したい課題

私がアプリを開発しているサービスでは、バックエンドAPIの大半が、API呼び出しの形式に[JSON:API](https://jsonapi.org/)を採択しています。JSON:APIではリクエスト・レスポンスのJSONの形式が定められており、API成功時には以下のような形式でJSONが返る決まりです。クライアントからAPIにデータを送信するときも、同様の形式のJSONをリクエストボディに格納します。

```json
{
  "data": {
    "type": "articles",
    "id": "1",
    "attributes": {
      // ... this article's attributes
    }
  }
}
```

また、JSON:APIは `application/vnd.api+json` というメディアタイプを持っており、API呼び出し時に、以下のHTTPヘッダをリクエストに付与しなければなりません。

- `Accept: application/vnd.api+json`
- `Content-Type: application/vnd.api+json` (POSTメソッドなどでデータを送信する場合のみ)

Retrofitを使っている場合に、このAPI呼び出しをどう実装するか、というのが今回の課題です。

# リクエスト・レスポンスのJSON形式の定義

JSON:APIのAPI呼び出しでは、上記のように、リクエスト・レスポンスJSONのトップレベル構造は決まっており、APIごとに異なるのは `attributes` プロパティ配下だけです。全てのJSONデータクラスの定義に、この共通部分をいちいち含めるのは無駄なので、我々のアプリでは、以下のようにトップレベルの構造をジェネリクス型で定義して、各JSON:API呼び出しで利用するようにしました。

```kotlin:JsonApiResponse.kt
class JsonApiResponse<T>(
    val data: Data<T>
) {
    class Data<U>(
        val type: String,
        val id: String,
        val attributes: U?
    )
}
```

例えば、以下のような形式でユーザー情報が返るAPIがあった場合、

```json
{
  "data": {
    "type": "articles",
    "id": "123",
    "attributes": {
      "name": "Emily Rosales",
      "mailAddress": "emily@example.com",
    }
  }
}
```

`attributes` の内容を定義するデータクラスを用意して、

```kotlin:User.kt
class User(
    val name: String,
    val mailAddress: String
)
```

Retrofitのインターフェースを次のように定義しています (結果の取得に[Kotlin Coroutine Adapter](https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter)を使ってます)。

```kotlin:Api.kt
interface Api {
    @GET("/users/{userId}")
    fun getUser(
        @Path("userId") userId: String
    ): Deferred<Response<JsonApiResponse<User>>>
}
```

POST/PUTなどのメソッドでデータを送信するケースも同様で、JSON:APIリクエストの共通部分を定義する `JsonApiRequest<T>`というジェネリクス型を用意して、`@Body` アノテーションを付与するパラメーターにこのデータ型を利用しています。

```kotlin:Api.kt
interface Api {
	// ...

    @PUT("/users/{userId}")
    fun updateUser(
        @Path("userId") userId: String,
        @Body user: JsonApiRequest<User>
    ): Deferred<Response<JsonApiResponse<User>>>
}
```

ここまでは、特に問題ありません。

# Accept/Content-Type ヘッダの付与

## `Invocation` を使う前の解決方法

問題は、`Accept` と `Content-Type` ヘッダの付与です。

最初に述べたように、JSON:APIを呼び出すときには、`application/vnd.api+json` を値として、`Accept` や `Content-Type` ヘッダをリクエストに付与しなければなりません。

Retrofitには、リクエストヘッダーを指定するための `@Headers` アノテーションがあり、これを使えば任意のHTTPヘッダーの値を指定することができます。ただ、我々のアプリでは、呼び出す大半のAPIがJSON:APIなので、それらの全てに `@Headers` を付与するのが少し面倒でした。

```kotlin:Api.kt
interface Api {
	// ...

    @Headers(
        "Accept: application/vnd.api+json",
        "Content-Type: application/vnd.api+json"
    )
    @PUT("/users/{userId}")
    fun updateUser(
        @Path("userId") userId: String,
        @Body user: JsonApiRequest<User>
    ): Deferred<Response<JsonApiResponse<User>>>
}
```

そのため、我々は `@Headers` アノテーションは使わず、OkHttpのInterceptorを使ってこれらのヘッダを追加することにしました。OkHttpではContent Typeの情報は `RequestBody` が保持しているため、`Request.Builder.addHeader()` では上書きができないのですが、Retrofitが内部で使っている [`ContentTypeOverridingRequestBody`](https://github.com/square/retrofit/blob/parent-2.5.0/retrofit/src/main/java/retrofit2/RequestBuilder.java#L253-L273) を利用することで[^1]、次のように、Interceptorで `Content-Type` ヘッダーを上書きすることができます。

```kotlin:JsonApiHeaderInterceptor.kt
class JsonApiHeaderInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val requestBuilder = request.newBuilder()

        // Accept header
        requestBuilder.addHeader("Accept", MEDIA_TYPE)

        // Content-Type header
        val requestBody = request.body()
        if (requestBody != null) {
            requestBuilder.method(
                request.method(),
                ContentTypeOverridingRequestBody(requestBody, MediaType.get(MEDIA_TYPE))
            )
        }

        return chain.proceed(requestBuilder.build())
    }

	companion object {
        private const val MEDIA_TYPE = "application/vnd.api+json"
    }
}
```

さて、これで問題は解決しているように見えるのですが、Interceptorを使う場合の課題として、APIごとに異なる挙動にするのが面倒、という点があります。上記のInterceptorも、利用する全てのAPIがJSON:API形式なら問題がないのですが、一部に非JSON:APIなAPIも混じっていたりしたので、実際は、URLのパスからAPIが使っている形式を判断して、ヘッダーを上書きしたりしなかったりする必要がありました。

```kotlin:JsonApiHeaderInterceptor
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val pathSegments = request.url().pathSegments()
        val requestBuilder = request.newBuilder()

        if (path.startsWith("/aaa") ||
            (path.startsWith("/bbb") && !path.contains("ccc-dd"))
        ) {
            // ...
        }

        return chain.proceed(requestBuilder.build())
    }
```

呼び出すAPIが増えるたびに、この条件文を更新する必要があり、ツラいです。今、振り返って考えると、`@Headers` アノテーションを使うほうがましでした。

## `Invocation` を使う解決方法

このようにツラい状況でしたが、`Invocation` を使うことで、Interceptorにおいて、呼び出そうとしているAPIがJSON:APIなのか否かをもっとスマートに判断することができるようになりました。

先に述べたように、我々のアプリではJSON:APIのリクエスト・レスポンスのJSON構造を表すジェネリクス型 `JsonApiRequest<T>`, `JsonApiResponse<T>` を用意しており、JSON:APIを呼び出すメソッドの定義では必ずそれらを使うようにしています。そのため、メソッドの戻り値や引数の型がわかれば、

- メソッドの戻り値の型に `JsonApiResponse<T>` が含まれる<br />
  → HTTPレスポンスがJSON:API形式<br />
  → HTTPリクエストの `Accept` ヘッダをJSON:APIのメディアタイプにする必要がある
- メソッドの引数のうち、`@Body` アノテーションがついている引数の型が `JsonApiRequest<T>`<br />
  → HTTPリクエストがJSON:API形式<br />
  → HTTPリクエストの `Content-Type` ヘッダをJSON:APIのメディアタイプにする必要がある

というように、各ヘッダを付与するべきかどうかが判断できます。

### `Invocation` の取得方法

`Invocation` は、OkHttpの `Request` にタグとして付与されており、`tag()` メソッドで取得できます。

```kotlin:JsonApiHeaderInterceptor.kt
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val invocation = request.tag(Invocation::class.java)
        if (invocation != null) {
            val method = invocation.method()
            // ...
        }
```

`Invocation` からは [`java.lang.reflect.Method`](https://developer.android.com/reference/java/lang/reflect/Method) と、メソッドに渡された実引数のリストが取得できます。今回は、戻り値や引数の型がわかれば十分なので、前者の、`Method` のみを使います。

### 戻り値の型の判断方法

型パラメーターも含んだメソッドの戻り値の型は [`Method.getGenericReturnType()`](https://developer.android.com/reference/java/lang/reflect/Method#getGenericReturnType()) で取得できます。Retrofitメソッドの戻り値の型は、利用するAdapterによって異なっているので、判断には少し工夫が必要です。

| Adapter                   | 戻り値の型の例                                               |
| ------------------------- | ------------------------------------------------------------ |
| 使わない                  | `Call<JsonApiResponse<User>>>` |
| Kotlin Coroutines Adapter | `Deferred<Response<JsonApiResponse<User>>>`<br />`Deferred<JsonApiResponse<User>>>` |
| RxJava2 Adapter           | `Single<Response<JsonApiResponse<User>>>`<br />`Single<JsonApiResponse<User>>>` |

どのパターンであるとしても、型パラメーターを展開していったときに `JsonApiResponse` が現れるかどうか、を見れば判断ができそうです。ジェネリクス型は [`ParameterizedType`](https://developer.android.com/reference/java/lang/reflect/ParameterizedType) で表現されるので、これは、次のように実装できます。

```kotlin:JsonApiHeaderInterceptor.kt
private val Method.hasJsonApiResponse: Boolean
    get() {
        var target = genericReturnType
        while (true) {
            if (target is ParameterizedType) {
                if (target.rawType == JsonApiResponse::class.java) {
                    return true
                }
                target = target.actualTypeArguments[0]
            } else {
                return false
            }
        }
    }
```

なお、今回は、判断対象となる `JsonApiResponse` 自体もジェネリクス型なので上記のような実装になっていますが、非ジェネリクス型で判断をする場合は、次のように少し条件分岐の仕方が変わります。

```kotlin
private val Method.hasUserResponse: Boolean
    get() {
        var target = genericReturnType
        while (true) {
            if (target == User::class.java) {
                return true
            } else if (target is ParameterizedType) {
                target = target.actualTypeArguments[0]
            } else {
                return false
            }
        }
    }
```

### `@Body` 引数の型の判断方法

メソッドの引数の型は [`Method.getParameterTypes()`](https://developer.android.com/reference/java/lang/reflect/Method#getParameterTypes()) で、引数のアノテーションは [`Method.getParameterAnnotations()`](https://developer.android.com/reference/java/lang/reflect/Method.getParameterAnnotations()) で取得できます。`@Body` アノテーションを持つ引数を探して、その引数の型を検査することで、`Body` の型が判断できます。

```kotlin:JsonApiHeaderInterceptor.kt
private val Method.hasJsonApiRequest: Boolean
    get() {
        val bodyType = parameterTypes.zip(parameterAnnotations)
            .firstOrNull { (_, annotations) ->
                annotations.any { it is Body }
            }?.first
        return bodyType == JsonApiRequest::class.java
    }
```

### つなげると

`Invocation` を使うInterceptorの実装は、次のようになります。`Invocation` を使う前よりも長くなっていますが、将来、新しいAPIの定義が追加されてもInterceptorを修正しなくてよいので、保守性は良くなっています。

```kotlin:JsonApiHeaderInterceptor.kt
class JsonApiHeaderInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val requestBuilder = request.newBuilder()
        val invocation = request.tag(Invocation::class.java)

        if (invocation != null) {
            val method = invocation.method()

            // Accept header
            if (method.hasJsonApiResponse) {
                requestBuilder.addHeader("Accept", MEDIA_TYPE)
            }

            // Content-Type header
            if (method.hasJsonApiRequest) {
                val requestBody = request.body()
                if (requestBody != null) {
                    requestBuilder.method(
                        request.method(),
                        ContentTypeOverridingRequestBody(requestBody, MediaType.get(MEDIA_TYPE))
                    )
                }
            }
        }

        return chain.proceed(requestBuilder.build())
    }

    private val Method.hasJsonApiResponse: Boolean
        get() {
            var target = genericReturnType
            while (true) {
                if (target is ParameterizedType) {
                    if (target.rawType == JsonApiResponse::class.java) {
                        return true
                    }
                    target = target.actualTypeArguments[0]
                } else {
                    return false
                }
            }
        }

    private val Method.hasJsonApiRequest: Boolean
        get() {
            val bodyType = parameterTypes.zip(parameterAnnotations)
                .firstOrNull { (_, annotations) ->
                    annotations.any { it is Body }
                }?.first
            return bodyType == JsonApiRequest::class.java
        }

    companion object {
        private const val MEDIA_TYPE = "application/vnd.api+json"
    }
}
```

# まとめ

- [Retrofit 2.5.0](https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-250-2018-11-18) で追加された `Invocation` を使うと、OkHttpのInterceptorにおいて、呼び出されたRetrofitメソッドの情報が取得できます
  - 例えば、メソッドの戻り値の型や、`@Body` アノテーションが付与された引数の型に応じて、Interceptorの挙動を変えることができます

[^1]: [`ContentTypeOverridingRequestBody`](https://github.com/square/retrofit/blob/parent-2.5.0/retrofit/src/main/java/retrofit2/RequestBuilder.java#L253-L273) はRetrofitの内部クラスで外からは使えないため、このクラスのソースコードをコピーしてもってくる必要があります