Published on

How to do X with Retrofit

Authors

Kotlin Specific

Using Suspend Functions

This is as simple as swaping out the usual Retrofit interface method definition with a suspend and a normal response (i.e. no wrappers to the response object).

You need to be using Retrofit 2.6.0 or higher to be able to use this.

For example the following:

@GET("persons/{id_number}")
fun getPerson(@Path("id_number") idNumber: String, @Query("ages") ages: QueryParameterList<Int>? = null): Call<List<Person>>

Becomes:

@GET("persons/{id_number}")
suspend fun getPerson(@Path("id_number") idNumber: String, @Query("ages") ages: QueryParameterList<Int>? = null): List<Person>

Authentication

Basic Auth

You will need to create an interceptor as below and then wire it up in your Retrofit config.

import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response

class BasicAuthInterceptor constructor(user: String, password: String) : Interceptor {
    private var credentials: String = Credentials.basic(user, password)

    override fun intercept(chain: Interceptor.Chain): Response {
        val request: Request = chain.request()
        val authenticatedRequest: Request = request.newBuilder()
            .header("Authorization", credentials).build()
        return chain.proceed(authenticatedRequest)
    }
}

Then configure it when you build your Retrofit service:

import com.fasterxml.jackson.annotation.JsonProperty
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.slf4j.LoggerFactory
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query


interface PersonService {
    // ... service methods here as normal

    companion object {
        private val logger = LoggerFactory.getLogger(this::class.java)

        fun instance(properties: PersonServiceProperties): PersonService {
            logger.info("START & END :: instance - properties $properties")
            return Retrofit.Builder()
                .client(buildHttpClient(properties))
                .baseUrl(properties.baseUrl)
                .addConverterFactory(JacksonConverterFactory.create(JacksonConfiguration().objectMapperForPersonService()))
                .build()
                .create(PersonService::class.java)
        }

        private fun buildHttpClient(properties: PersonServiceProperties): OkHttpClient = if (properties.debug.toBoolean()) {
            val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().setLevel(level = HttpLoggingInterceptor.Level.BODY)
            OkHttpClient.Builder().addInterceptor(logging)
        } else {
            OkHttpClient.Builder()
        }
            .addInterceptor(BasicAuthInterceptor(user = properties.username, password = properties.password))
            .build()
    }

}

OAuth

This involves 3 pieces:

  1. Creating a class that extends okhttp3.Authenticator
  2. Creating a service to hit the OAuth token endpoint
  3. Adding what you created in 2. combined with 1. as part of building up the service that actually needs the OAuth before each call

The authenticator looks as follows:

import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route

// authService here is what we will build below for 2.
class TokenAuthenticator(private val properties: Properties, internal val authService: AuthService) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val credential: String = Credentials.basic(username = properties.username, password = properties.password)
        val currentHeaderAuthorization: String? = response.request.header(AuthService.HEADER_AUTHORIZATION)
        val responseCount: Int = response.responseCount

        if (currentHeaderAuthorization == credential || currentHeaderAuthorization != null || responseCount > 2) {
            return null
        }

        val token: AuthTokenResponse = authService.refreshToken(credential)

        return response.request.newBuilder()
            .header(AuthService.HEADER_AUTHORIZATION, "${token.tokenType} ${token.accessToken}")
            .header("Content-Type", "application/x-www-form-urlencoded")
            .build()
    }

    private fun AuthService.refreshToken(credential: String): AuthTokenResponse = this.getAuthenticationToken(
        authorization = credential,
        contentType = "application/x-www-form-urlencoded;charset=UTF-8",
        params = hashMapOf(
            "grant_type" to "client_credentials"
        )
    ).execute().body() ?: throw RuntimeException("Null returned when trying to authenticate")
}

The service to hit the OAuth endpoint looks as follows:

import io.github.resilience4j.retrofit.CircuitBreakerCallAdapter
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.slf4j.LoggerFactory
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Header
import retrofit2.http.POST

interface AuthService {

    @FormUrlEncoded
    @POST("oauth/token")
    fun getAuthenticationToken(
        @Header(HEADER_AUTHORIZATION) authorization: String,
        @Header(CONTENT_TYPE) contentType: String,
        @FieldMap params: HashMap<String, String>
    ): retrofit2.Call<AuthTokenResponse>

    companion object {
        private val logger = LoggerFactory.getLogger(AuthService::class.java)
        private const val serviceName = "SomeAPIS[Auth]"

        const val HEADER_AUTHORIZATION: String = "Authorization"
        const val CONTENT_TYPE: String = "Content-Type"

        fun instance(properties: Properties): AuthService {
            logger.info("START & END :: instance")

            return Retrofit.Builder().let { builder ->
                logger.info("START & END :: instance - properties $properties")
                builder.client(httpClientBuilder(properties).build())
            }.baseUrl(properties.environment.baseURL)
                .addConverterFactory(JacksonConverterFactory.create(JacksonConfiguration.lowerSnakeCaseConfiguration))
                .build().create(AuthService::class.java)
        }

        private fun httpClientBuilder(properties: Properties): OkHttpClient.Builder {
            val httpClient = if (properties.mustLogAPI) {
                val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().setLevel(level = HttpLoggingInterceptor.Level.BODY)
                OkHttpClient.Builder().addInterceptor(logging)
            } else {
                OkHttpClient.Builder()
            }
            return httpClient
        }
    }
}

Finally we tie it all together by using it in the service that needs OAuth before calling an endpoint:

import io.github.resilience4j.retrofit.CircuitBreakerCallAdapter
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.slf4j.LoggerFactory
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import java.time.LocalDateTime

interface MyService {

  // service methods over here as usual

    companion object {

        private val logger = LoggerFactory.getLogger(MyService::class.java)
        private const val serviceName = "MyService[General]"


        fun instance(properties: Properties, authService: AuthService): RealPayService {
            logger.info("START & END :: instance - properties $properties")
            return Retrofit.Builder()
                .client(buildHttpClient(properties, authService))
                .baseUrl(properties.environment.baseURL)
                .addConverterFactory(JacksonConverterFactory.create(JacksonConfiguration.upperCamelCaseConfiguration))
                .addConverterFactory(QueryConverterFactory.create())
                .build()
                .create(RealPayService::class.java)
        }

        private fun buildHttpClient(properties: Properties, authService: AuthService): OkHttpClient = if (properties.mustLogAPI) {
            val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().setLevel(level = HttpLoggingInterceptor.Level.BODY)
            OkHttpClient.Builder().addInterceptor(logging)
        } else {
            OkHttpClient.Builder()
        }
            .authenticator(TokenAuthenticator(properties = properties, authService = authService))
            .build()
    }
}

Interceptors

Log All Requests and Responses

This can be done by adding a HttpLoggingInterceptor when you build up the HttpClient for your service

This snippet is what does it but the entire code block below that will show you the full context

val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().setLevel(level = HttpLoggingInterceptor.Level.BODY)
            OkHttpClient.Builder().addInterceptor(logging)

Full snippet:

import io.github.resilience4j.retrofit.CircuitBreakerCallAdapter
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.slf4j.LoggerFactory
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import java.time.LocalDateTime

interface MyService {

  // service methods over here as usual

    companion object {

        private val logger = LoggerFactory.getLogger(MyService::class.java)
        private const val serviceName = "MyService[General]"


        fun instance(properties: Properties, authService: AuthService): RealPayService {
            logger.info("START & END :: instance - properties $properties")
            return Retrofit.Builder()
                .client(buildHttpClient(properties, authService))
                .baseUrl(properties.environment.baseURL)
                .addConverterFactory(JacksonConverterFactory.create(JacksonConfiguration.upperCamelCaseConfiguration))
                .addConverterFactory(QueryConverterFactory.create())
                .build()
                .create(RealPayService::class.java)
        }

        private fun buildHttpClient(properties: Properties, authService: AuthService): OkHttpClient = if (properties.mustLogAPI) {
            val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().setLevel(level = HttpLoggingInterceptor.Level.BODY)
            OkHttpClient.Builder().addInterceptor(logging)
        } else {
            OkHttpClient.Builder()
        }
            .authenticator(TokenAuthenticator(properties = properties, authService = authService))
            .build()
    }
}

Building up the URL

Optional Query Parameters

This is as simple as making the query parameter nullable. If you are working in Kotlin you can make it telescope to a null.

@GET("persons/{id_number}")
suspend fun getPerson(@Path("id_number") idNumber: String, @Query("ages") ages: QueryParameterList<Int>? = null): List<Person>

In the above ages is an optional query parameter. When null is assigned to it, Retrofit will not add it to the query parameter list

Comma Separated List in Query Parameters

This relies on the fact that Retrofit:

  1. Allows complex objects to be used as query parameters
  2. Uses the toString of an object to convert the complex object to a string for the query

This also uses the following custom class to more easily facilitate this parameter list conversion:

data class QueryParameterList<T>(
    private val separator: Char = ',',
    val values: List<T>
) {
    override fun toString(): String = values.joinToString(separator = separator.toString())
}

Then finally to use this simply define it as follows:

@GET("persons/{id_number}")
suspend fun getPerson(@Path("id_number") idNumber: String, @Query("ages") ages: QueryParameterList<Int>? = null): List<Person>

In the above ages is an optional query parameter. When null is assigned to it, Retrofit will not add it to the query parameter list

Sources