A Retrofit library használatakor annotációk segítségével tudunk HTTP kéréseket kezelni. Gyakorlatilag egy interface-t kell megírnunk, amiben definiáljuk a végpontokat, ahhoz hasonlóan, mintha a saját REST Controllerünket hoznánk létre. A Retrofit a háttérben (I guess reflection-nel) létrehozza belőle az implementációt.
Előnyök:
- Nem kell kézzel kliens kódot írni, az adott apikat sima metódusként tudjuk hívni
- Többféle parser támogatott (Gson, Moshi, stb.)
- Többféle klienst támogat (OkHttp, stb.)
- Könnyen kiterjeszthető, pl. interceptorokkal
Hátrány:
- Reflection-ök használata
- Mivel annotációkat használunk, így nem tudjuk minden részét kivezetni az application.yml-be, mivel azokból nem lesz fordítás idejű konstans. Lsd: Kotlin - Static konstansokKotlin - Static konstansok
Annotációkhoz, pl @JsonFormat jöhet jól, ha nem akarjuk 50x ugyanazt a Stringet bemásolni. Kotlinnál erre lehet használni a companion object-et, ebből fordítási idejű konstans lesz, amit már annotá...
A példa az OkHttp klienst használja.
Dependencies
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-jackson:2.11.0")
Suspend és egyéb coroutine fgv-ek használatához:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
Használat
Az API leírásához csak a következő szükséges:
interface CoffeeApiClient {
@POST("coffee")
suspend fun updateCoffee(@Body request: CoffeeRequest, @Query("coffeeId") coffeeId: Int): Response<Unit>
}
Ezt nyilván szét lehet még customizálni egyéb header-ök, query paraméterek, stb. segítségével.
Ha ez megvan, akkor létre kell hozni egy Retrofit példányt:
class CoffeeApiClientFactory(
private val jsonMapper: ObjectMapper,
private val config: Config,
private val tokenAuthenticator: TokenAuthenticator,
private val defaultHeadersInterceptor: DefaultHeadersInterceptor,
private val defaultParamsInterceptor: DefaultParamsInterceptor,
private val tokenInterceptor: TokenInterceptor,
): ClientFactory<CoffeeApiClient> {
override fun createClient(): CoffeeApiClient {
val client = OkHttpClient.Builder()
.addInterceptor(tokenInterceptor)
.authenticator(tokenAuthenticator)
.addInterceptor(defaultHeadersInterceptor)
.addInterceptor(defaultParamsInterceptor)
.build()
val retrofit = Retrofit.Builder()
.client(client)
.baseUrl(config.baseUrl!!)
.addConverterFactory(JacksonConverterFactory.create(jsonMapper))
.build()
val service: CoffeeApiClient = retrofit.create(CoffeeApiClient::class.java)
return service
}
}
Ezt követően már hívhatóak is az interface-ben definiált végpontok. Ez a CoffeeApiClientFactory már nem egy minimalista megvalósítás, de megmutatja, hogy hogy lehet egyedi interceptorokat, authentikációt, stb. beállítani. Ezek nyilván elhagyhatóak.
Példa interceptorok
class TokenInterceptor(
private val tokenRepository: TokenRepository
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken: String? = tokenRepository.getAccessToken()
val request: Request = newRequestWithAccessToken(chain.request(), accessToken)
val response: Response = chain.proceed(request)
return response
}
private fun newRequestWithAccessToken(request: Request, accessToken: String?): Request {
if(accessToken == null) return request
return request.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
}
}
Ez az interceptor lekéri a tárolt tokent, majd beállítja a request Authorization header-jébe. Ha nincs token, vagy lejárt, akkor a requestet változatlanul továbbítja. Ekkor a kérésünk valószínűleg egy 403-mas hibát fog kapni.
Ha 403-mas hibát kapunk, akkor a beállított Authenticator fogja kezelni a hibát.
class TokenAuthenticator(
private val tokenRepository: TokenRepository
) : Authenticator {
// AtomicBoolean in order to avoid race condition
private var tokenRefreshInProgress: AtomicBoolean = AtomicBoolean(false)
private var request: Request? = null
override fun authenticate(route: Route?, response: Response): Request? {
return runBlocking {
request = null
// Checking if a token refresh call is already in progress or not
// The first request will enter the if block
// Later requests will enter the else block
if (!tokenRefreshInProgress.get()) {
tokenRefreshInProgress.set(true)
// Refreshing token
refreshToken()
request = buildRequest(response.request.newBuilder())
tokenRefreshInProgress.set(false)
} else {
// Waiting for the ongoing request to finish
// So that we don't refresh our token multiple times
waitForRefresh(response)
}
// return null to stop retrying once responseCount returns 3 or above.
if (responseCount(response) >= 3) {
null
} else request
}
}
// Refresh your token here and save them.
private suspend fun refreshToken() {
// Simulating a token refresh request
delay(200)
tokenRepository.refreshToken()
delay(200)
}
// Queuing the requests with delay
private suspend fun waitForRefresh(response: Response) {
while (tokenRefreshInProgress.get()) {
delay(100)
}
request = buildRequest(response.request.newBuilder())
}
private fun responseCount(response: Response?): Int {
var result = 1
while (response?.priorResponse != null && result <= 3) {
result++
}
return result
}
// Build a new request with new access token
private fun buildRequest(requestBuilder: Request.Builder): Request {
val token = tokenRepository.getAccessToken()
return requestBuilder
.header(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_VALUE)
.header(HEADER_AUTHORIZATION, HEADER_AUTHORIZATION_TYPE + token)
.build()
}
companion object {
const val HEADER_AUTHORIZATION = "Authorization"
const val HEADER_CONTENT_TYPE = "Content-Type"
const val HEADER_CONTENT_TYPE_VALUE = "application/json"
const val HEADER_AUTHORIZATION_TYPE = "Bearer "
}
}
class DefaultHeadersInterceptor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val modifiedRequest = originalRequest.newBuilder()
.addHeader("CustomHeaderParam", "42")
.build()
return chain.proceed(modifiedRequest)
}
}
class DefaultParamsInterceptor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val modifiedRequest = originalRequest.newBuilder()
.url(originalRequest.url.newBuilder()
.addQueryParameter("queryParam", "someValue")
.build())
.build()
return chain.proceed(modifiedRequest)
}
}
Megj.: JWT autentikációnál, ha szintén Retrofittel akarjuk megoldani a JWT lekérést, refresh-t, stb, akkor azt egy külön Client-ben kell megoldanunk, amin nincs AuthInterceptor, Auth, stb.