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呼び出しで利用するようにしました。
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
の内容を定義するデータクラスを用意して、
class User(
val name: String,
val mailAddress: String
)
Retrofitのインターフェースを次のように定義しています (結果の取得にKotlin Coroutine Adapterを使ってます)。
interface Api {
@GET("/users/{userId}")
fun getUser(
@Path("userId") userId: String
): Deferred<Response<JsonApiResponse<User>>>
}
POST/PUTなどのメソッドでデータを送信するケースも同様で、JSON:APIリクエストの共通部分を定義する JsonApiRequest<T>
というジェネリクス型を用意して、@Body
アノテーションを付与するパラメーターにこのデータ型を利用しています。
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
を付与するのが少し面倒でした。
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
ヘッダーを上書きすることができます。
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が使っている形式を判断して、ヘッダーを上書きしたりしなかったりする必要がありました。
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()
メソッドで取得できます。
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
で表現されるので、これは、次のように実装できます。
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
の型が判断できます。
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を修正しなくてよいので、保守性は良くなっています。
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の挙動を変えることができます
- 例えば、メソッドの戻り値の型や、
-
ContentTypeOverridingRequestBody
はRetrofitの内部クラスで外からは使えないため、このクラスのソースコードをコピーしてもってくる必要があります ↩