Android
Retrofit
OkHttp

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

2018年11月18日にリリースされたRetrofit 2.5.0から、OkHttpのInterceptorに Invocation というタグが渡されるようになり、Interceptorにおいて、呼び出されたRetrofitメソッドの情報を取得できるようになりました。

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

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


解決したい課題

私がアプリを開発しているサービスでは、バックエンドAPIの大半が、API呼び出しの形式にJSON:APIを採択しています。JSON:APIではリクエスト・レスポンスのJSONの形式が定められており、API成功時には以下のような形式でJSONが返る決まりです。クライアントからAPIにデータを送信するときも、同様の形式の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呼び出しで利用するようにしました。


JsonApiResponse.kt

class JsonApiResponse<T>(

val data: Data<T>
) {
class Data<U>(
val type: String,
val id: String,
val attributes: U?
)
}

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

{

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

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


User.kt

class User(

val name: String,
val mailAddress: String
)

Retrofitのインターフェースを次のように定義しています (結果の取得にKotlin Coroutine Adapterを使ってます)。


Api.kt

interface Api {

@GET("/users/{userId}")
fun getUser(
@Path("userId") userId: String
): Deferred<Response<JsonApiResponse<User>>>
}

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


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 を使う前の解決方法

問題は、AcceptContent-Type ヘッダの付与です。

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

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


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 を利用することで1、次のように、Interceptorで Content-Type ヘッダーを上書きすることができます。


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が使っている形式を判断して、ヘッダーを上書きしたりしなかったりする必要がありました。


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> が含まれる

    → HTTPレスポンスがJSON:API形式

    → HTTPリクエストの Accept ヘッダをJSON:APIのメディアタイプにする必要がある

  • メソッドの引数のうち、@Body アノテーションがついている引数の型が JsonApiRequest<T>

    → HTTPリクエストがJSON:API形式

    → HTTPリクエストの Content-Type ヘッダをJSON:APIのメディアタイプにする必要がある

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


Invocation の取得方法

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


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 と、メソッドに渡された実引数のリストが取得できます。今回は、戻り値や引数の型がわかれば十分なので、前者の、Method のみを使います。


戻り値の型の判断方法

型パラメーターも含んだメソッドの戻り値の型は Method.getGenericReturnType() で取得できます。Retrofitメソッドの戻り値の型は、利用するAdapterによって異なっているので、判断には少し工夫が必要です。

Adapter
戻り値の型の例

使わない
Call<JsonApiResponse<User>>>

Kotlin Coroutines Adapter

Deferred<Response<JsonApiResponse<User>>>
Deferred<JsonApiResponse<User>>>

RxJava2 Adapter

Single<Response<JsonApiResponse<User>>>
Single<JsonApiResponse<User>>>

どのパターンであるとしても、型パラメーターを展開していったときに JsonApiResponse が現れるかどうか、を見れば判断ができそうです。ジェネリクス型は ParameterizedType で表現されるので、これは、次のように実装できます。


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 自体もジェネリクス型なので上記のような実装になっていますが、非ジェネリクス型で判断をする場合は、次のように少し条件分岐の仕方が変わります。

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() で、引数のアノテーションは Method.getParameterAnnotations() で取得できます。@Body アノテーションを持つ引数を探して、その引数の型を検査することで、Body の型が判断できます。


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を修正しなくてよいので、保守性は良くなっています。


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 で追加された Invocation を使うと、OkHttpのInterceptorにおいて、呼び出されたRetrofitメソッドの情報が取得できます


    • 例えば、メソッドの戻り値の型や、@Body アノテーションが付与された引数の型に応じて、Interceptorの挙動を変えることができます







  1. ContentTypeOverridingRequestBody はRetrofitの内部クラスで外からは使えないため、このクラスのソースコードをコピーしてもってくる必要があります