본문으로 건너뛰기

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 라이브러리를 사용하시면 코드를 간소화 할 수 있습니다.

Step 1. 프로젝트 설정

Step 1.1. Apply the plugin

여러분의 프로젝트의 build.gradle.kts 파일의 plugins에 Apollo Gradle Plugin(com.apollographql.apollo3)을 추가합니다.

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

Apollo Plugin에는 프로젝트를 빌드할 때 쿼리에서 모델을 생성하는 컴파일러가 포함되어 있습니다.

Step 1.2. Configure the Apollo Gradle plugin

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("Either the property 'deliveryTracker.introspection.accessToken' must exist, or both 'deliveryTracker.introspection.clientId' and 'deliveryTracker.introspection.clientSecret' properties must exist.")
}

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 access token from the response. Response received: $response")
}
"Bearer $accessToken"
})
}
}
}

Step 1.3. Add dependencies

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

Step 1.4. Configure the Gradle Properties

Schema Download 를 위해서는 인증 정보 설정이 필요합니다.

프로젝트 디렉토리의 gradle.properties 등을 수정하여 필요한 Gradle Properties 를 주입하여야 합니다.

Gradle Properties 설정 방법에 대해 더 자세히 알고 싶은 경우 이곳을 참고해주세요.

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

clientId, clientSecret 에 대해 알고 싶은 경우 이곳을 참고 해주세요.

Step 1.4. Reload Gradle project

마지막으로 변경 사항을 반영하기 위해 Gradle build script 를 reload 합니다.

이때 schema 파일이 없어 에러가 발생하는 경우 임시 schema 파일을 생성합니다.

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

Step 2. Download a schema

Gradle 의 downloadDeliveryTrackerApolloSchemaFromIntrospection task 를 실행하여 Schema를 다운로드 합니다.

./gradlew :downloadDeliveryTrackerApolloSchemaFromIntrospection

Step 3. Write your first query

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

Step 4. Execute your first query

아래 예시와 같이 API 를 호출하여 Delivery Tracker를 사용하실 수 있습니다.

Step 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)
}
}

Step 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()
}
}

Step 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
}
}