本文へスキップ

JVM (Apollo Kotlin)

Apollo Kotlinを使ってDelivery Trackerを使う例です。

Step 1~4 の初期設定が完了すると、以下のようなAPIを簡単に使用することができます。

demo.kt
val response = apolloClient.query(
GetTrackLastEventQuery(
carrierId = "kr.cjlogistics",
trackingNumber = "1234567890",
)
).execute()

val notFoundError = response.findDeliveryTrackerError(code="NOT_FOUND")
if (notFoundError != null) {
println("NotFound : ${notFoundError.message}")
} else {
println(response.data?.track?.lastEvent?.status?.code)
}
注意事項

下記のコードは依存性を最小化するため、コードが長いです。 使用しているHTTP ClientとJSON Parserライブラリを使用すると、コードを簡素化することができます。

ステップ1. プロジェクト設定

ステップ1.1. プラグインの適用

プロジェクトの build.gradle.kts ファイルの plugins にApollo Gradle Plugin(com.apollographql.apollo3)を追加します。

build.gradle.kts
plugins {
// ...
id("com.apollographql.apollo3") version "3.8.2"
}

Apollo Pluginにはプロジェクトをビルドする時、クエリからモデルを生成するコンパイラが含まれています。

ステップ1.2. Apollo Gradleプラグインの設定

Apollo Plugin を設定し、Codegen になる位置などを指定します。 プロジェクトの build.gradle.kts ファイルに下記の内容を追加します。

build.gradle.kts
import java.net.URL
import java.util.Base64
import java.io.IOException

// ...

apollo {
service("DeliveryTracker") {
packageName.set("com.demoproject.deliverytracker.api")
schemaFile.set(File("src/main/graphql/schema.graphqls"))
introspection {
endpointUrl.set("https://apis.tracker.delivery/graphql")
headers.put("Authorization", providers.provider {
var accessToken = providers.gradleProperty("deliveryTracker.introspection.accessToken").orNull
if (accessToken != null) {
return@provider "Bearer $accessToken"
}

val clientId = providers.gradleProperty("deliveryTracker.introspection.clientId").orNull
val clientSecret = providers.gradleProperty("deliveryTracker.introspection.clientSecret").orNull

if (clientId == null || clientSecret == null) {
throw InvalidUserDataException("プロパティ 'deliveryTracker.introspection.accessToken'プロパティが存在するか、'deliveryTracker.introspection.clientId'と'deliveryTracker.introspection.clientSecret'プロパティの両方が存在する必要があります。")
}

val connection = URL("https://auth.tracker.delivery/oauth2/token").openConnection() as java.net.HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray()))
val body = "grant_type=client_credentials".toByteArray()
connection.setRequestProperty("Content-Length", body.size.toString())
connection.outputStream.use { stream ->
stream.write(body, 0, body.size)
}

if (connection.responseCode >= 400) {
val response = connection.errorStream.bufferedReader().use { it.readText() }
throw IOException("Failed to authenticate with Delivery Tracker. Server responded with error: $response")
}

val response = connection.inputStream.bufferedReader().use { it.readText() }
val matchResult = """"access_token"\s*:\s*"(.+?)"""".toRegex().find(response)

accessToken = matchResult?.groups?.get(1)?.value
if (accessToken == null) {
throw IOException("Authentication failed: Unable to parse the access token from the response. Response received: $response")
}
"Bearer $accessToken"
})
}
}
}

ステップ1.3. 依存関係の追加

build.gradle.kts
dependencies {
// ...
implementation("com.apollographql.apollo3:apollo-runtime:3.8.2")
}

ステップ1.4. グラドルプロパティの設定

Schema Download のためには認証情報の設定が必要です。

プロジェクトディレクトリのgradle.propertiesなどを修正して必要なGradle Propertiesを注入する必要があります。

Gradle Propertiesの設定方法についてもっと詳しく知りたい場合はこちらを参照してください。

gradle.properties
# ...
deliveryTracker.introspection.introspection.clientId=[YOUR_CLIENT_ID]
deliveryTracker.introspection.clientSecret=[YOUR_CLIENT_SECRET]...

clientId, clientSecret について知りたい場合はこちらを参照してください。

ステップ1.4. Reload Gradleプロジェクト

最後に変更内容を反映するためGradle build scriptをreloadします。

この時、schemaファイルがなくてエラーが発生した場合、一時的なschemaファイルを生成します。

echo "type Query { dummy: Boolean }" > src/main/graphql/schema.graphqls

ステップ2. スキーマのダウンロード

GradleのdownloadDeliveryTrackerApolloSchemaFromIntrospectionタスクを実行してSchemaをダウンロードします。

./gradlew :downloadDeliveryTrackerApolloSchemaFromIntroduction

ステップ3. 最初のクエリを書く

Codegenに必要なGraphQL Queryを作成します。 以下の例は、運送状の最後のイベントを取得するAPIです。

src/main/graphql/GetTrackLastEvent.graphql
query GetTrackLastEvent($carrierId: ID!, $trackingNumber: String!) {
track(carrierId: $carrierId, trackingNumber: $trackingNumber) {
lastEvent {
time
status {
code
}
}
}
}

GradleのgenerateDeliveryTrackerApolloSources Taskを実行して上で作ったAPI(Query)をCodeGenします。

./gradlew :generateDeliveryTrackerApolloSources

ステップ4. 最初のクエリを実行する

以下の例のようにAPIを呼び出してDelivery Trackerを使用することができます。

ステップ4.1. API Clientの作成とRequestロジック作成

src/main/kotlin/com/deomporject/main.kt
package com.demoproject

import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.network.http.DefaultHttpEngine
import com.demoproject.deliverytracker.api.DeliveryTrackerAuthorizationInterceptor
import com.demoproject.deliverytracker.api.GetTrackLastEventQuery
import com.demoproject.deliverytracker.api.findDeliveryTrackerError

suspend fun main() {
val apolloClient = ApolloClient.Builder()
.serverUrl("https://apis.tracker.delivery/graphql")
.httpEngine(DefaultHttpEngine(timeoutMillis = 20_000))
.addHttpInterceptor(
DeliveryTrackerAuthorizationInterceptor(
clientId = "[YOUR_CLIENT_ID]",
clientSecret = "[YOUR_CLIENT_SECRET]"
)
)
.build()

val response = apolloClient.query(
GetTrackLastEventQuery(
carrierId = "kr.cjlogistics",
trackingNumber = "1234567890",
)
).execute()

val notFoundError = response.findDeliveryTrackerError(code="NOT_FOUND")
if (notFoundError != null) {
println("NotFound : ${notFoundError.message}")
} else {
println(response.data?.track?.lastEvent?.status?.code)
}
}

ステップ4.2. Authorization処理コード作成

src/main/kotlin/com/deomporject/deliverytracker/api/DeliveryTrackerAuthorizationInterceptor.kt
package com.demoproject.deliverytracker.api

import com.apollographql.apollo3.api.http.HttpRequest
import com.apollographql.apollo3.api.http.HttpResponse
import com.apollographql.apollo3.api.json.BufferedSourceJsonReader
import com.apollographql.apollo3.api.json.JsonReader
import com.apollographql.apollo3.network.http.HttpInterceptor
import com.apollographql.apollo3.network.http.HttpInterceptorChain
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.*
import okio.Buffer
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

class DeliveryTrackerAuthorizationInterceptor(
clientId: String,
clientSecret: String,
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build(),
) : HttpInterceptor {
private val credentials = Credentials.basic(clientId, clientSecret)
private val requestBody = FormBody.Builder()
.add("grant_type", "client_credentials")
.build()
private val mutex = Mutex()
private var accessToken: String? = null

private suspend fun fetchNewAccessToken():String? = suspendCoroutine { continuation ->
val call = okHttpClient.newCall(
Request.Builder()
.url("https://auth.tracker.delivery/oauth2/token")
.addHeader("Authorization", credentials)
.post(requestBody)
.build()
)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
continuation.resume(null)
}

override fun onResponse(call: Call, response: Response) {
val body = response.body
if (body == null) {
continuation.resume(null)
return
}

val jsonReader = BufferedSourceJsonReader(Buffer().write(body.bytes()))
jsonReader.beginObject()
while (jsonReader.hasNext()) {
if (jsonReader.nextName() != "access_token") {
jsonReader.skipValue()
continue
}
continuation.resume(jsonReader.nextString())
return
}
jsonReader.endObject()
continuation.resume(null)
}
})
}

/**
* errors[*].extensions.code == "UNAUTHENTICATED" が存在するか確認します。
*
* 依存性を最小化するためBufferedSourceJsonReaderを使用しています。
*/
private fun hasUnauthenticatedError(buffer: Buffer):Boolean {
val jsonReader = BufferedSourceJsonReader(buffer)
jsonReader.beginObject()
while (jsonReader.hasNext()) {
if (jsonReader.nextName() != "errors") {
jsonReader.skipValue()
continue
}
if (jsonReader.peek() == JsonReader.Token.NULL) {
jsonReader.nextNull()
return false
}
jsonReader.beginArray()
while (jsonReader.hasNext()) {
jsonReader.beginObject()
while (jsonReader.hasNext()) {
val name = jsonReader.nextName()
if (name != "extensions" && jsonReader.peek() != JsonReader.Token.BEGIN_OBJECT) {
jsonReader.skipValue()
continue
}
jsonReader.beginObject()
while (jsonReader.hasNext()) {
val extensionName = jsonReader.nextName()
if (extensionName != "code") {
jsonReader.skipValue()
continue
}
if (jsonReader.peek() != JsonReader.Token.STRING) {
jsonReader.skipValue()
continue
}
if (jsonReader.nextString() == "UNAUTHENTICATED") {
return true
}
}
jsonReader.endObject()
}
jsonReader.endObject()
}
jsonReader.endArray()
}
return false
}

private suspend fun getAccessToken(forceFetchNewAccessToken: Boolean = false) = mutex.withLock {
if (accessToken == null || forceFetchNewAccessToken) {
accessToken = fetchNewAccessToken()
}
accessToken
}

override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain):HttpResponse {
var token = getAccessToken()

val response = if (token != null) {
chain.proceed(request.newBuilder().addHeader("Authorization", "Bearer $token").build())
} else {
chain.proceed(request)
}
val body = response.body?.readByteArray() ?: return response

if (hasUnauthenticatedError(Buffer().write(body))){
token = getAccessToken(forceFetchNewAccessToken = true)
if (token != null) {
return chain.proceed(request.newBuilder().addHeader("Authorization", "Bearer $token").build())
}
}

return HttpResponse.Builder(
statusCode=response.statusCode,
)
.headers(response.headers)
.body(Buffer().write(body))
.build()
}
}

ステップ4.2. Error処理ユーティリティの作成 (Optional)

src/main/kotlin/com/deomporject/deliverytracker/api/DeliveryTrackerApolloExtensions.kt
package com.demoproject.deliverytracker.api

import com.apollographql.apollo3.api.ApolloResponse
import com.apollographql.apollo3.api.Error
import com.apollographql.apollo3.api.Operation

fun <D : Operation.Data> ApolloResponse<D>.findDeliveryTrackerError(code: String):Error? {
return this.errors?.find {
it.extensions?.get("code") == code
}
}