Apollo Android GraphQL Flow Bindings

5 minute read

Featured in Android Weekly #444 & Kotlin Weekly #228

In this blog post, we’ll explore how the Apollo-Android library gives you the ability to use Flows when making queries. The library provides a coroutines integration module. How does the module bridge to Flows? Let’s explore in this article.

GraphQL Query with Callbacks

The first step to making a GraphQL query with Apollo-Android is to create an Apollo Client. The library provides a builder to create the client.

val client: ApolloClient = ApolloClient.builder()
      .serverUrl("url")
      .build()

We set a URL to the GraphQL server and retrieve an instance of our client. The second step is to supply a query to the client. Assume we have a query specified.

val testQuery = TestQuery.builder().build()

client.query(testQuery).enqueue(
    
    object : ApolloCall.Callback<T>() {

        override fun onResponse(response: Response<T>) {
            ...
        }
        override fun onFailure(e: ApolloException) {
            ...
        }
        override fun onStatusEvent(event: ApolloCall.StatusEvent) {
            ...
        }
    })

In the code snippet above, I call the query method and enqueue a callback to get the request’s result. Each method in the callback informs us about different types of information. The onFailure method will tell us of a thrown exception while making a request. The library will call the onStatusEvent status changes such as scheduled, completed, and whether the data came from the cache or network. See the Apollo Call class for a list of status events.

Bridge Apollo Callback to Flow

How does the library allow you to make queries using Flows? The library uses callbackFlow provided by the Kotlin coroutines library to provide a bridge from the callback shown above to Flows.

Callback Flow

The documentation for the callbackFlow method states,

“Creates an instance of a cold Flow with elements that are sent to a SendChannel provided to the builder’s block of code via ProducerScope. It allows elements to be produced by code that is running in a different context or concurrently.”

See below how the Apollo Client uses callbackFlow.

fun <T> ApolloCall<T>.toFlow() = callbackFlow {
    clone().enqueue(
        object : ApolloCall.Callback<T>() {
             override fun onResponse(response: Response<T>) {
                  runCatching {
                     offer(response)
                  }
             }

            override fun onFailure(e: ApolloException) {
                   close(e)
            }

            override fun onStatusEvent(event: ApolloCall.StatusEvent) {
                   if (event == ApolloCall.StatusEvent.COMPLETED) {
                      close()
                  }
            }
        }
     )
     awaitClose { this@toFlow.cancel() }
}

Source: Apollo Android’s Coroutines Extensions

The library provides the toFlow extension above on the Apollo Client. As we did earlier, inside the method, the client enqueues an Apollo Callback. It does so inside callbackFlow. Callback Flow provides a channel to send data inside a producer coroutine scope. Anytime a method in Apollo Callback is invoked, an operation is performed on the channel.

The onResponse method sends data to the callback flow’s channel via the channel’s offer method. The onFailure method and completed status event close the channel. Lastly, the awaitClose function keeps the flow running. Otherwise, invoking the callback will close the channel.

The More You Know!

In the implementation above, the channel’s offer method is used to send the returned result. The call to offer method is wrapped around a try and catch. This is due to an known issue in the Kotlin coroutines library. See SendChannel.offer should never throw. At times, calling offer causes a JobCancellationException to surface to the client.

Usage

The toFlow method returns a Flow upon which we could apply operators, handle retries, and errors.

val client: ApolloClient = ApolloClient.builder()
      .serverUrl("url")
      .build()

val query = TestQuery.builder().build())

val flow = client.query(query).toFlow()

Flow Retries

Flow has operators to handle retries and errors. The retry extension on a Flow allows you to specify the number of retries to perform and a condition to determine when to perform a retry.

client
    .query(query)
     .toFlow()
     .retries(2) { throwable ->

      }

The first parameter to the retry block is the number of retries to perform. The second parameter gives you a throwable to specify if you want to do a retry on a specific exception.

Flow Errors

If an exception occurs, the catch Flow extension can intercept it.

client
    .query(query)
     .toFlow()
     .catch { throwable ->

      }

The catch operator provides a FlowCollector that allows you to emit a wrapper type when an exception occurs.

Using the coroutines integration module in Apollo Android’s library provides you the benefit of using these operators.

How Apollo Flow Binding Evolved

The strategy to bridge from ApolloCallback to Flows has evolved in the previous releases of the library. Previously, the library provided an API that gives you a Channel or a Flow when making a query. Let’s look at the initial implementation of the toChannel and toFlow method in the library.

Channel Extension

An earlier version of the library provided a toChannel method to read results.

class ChannelCallback<T>(
   val channel: Channel<Response<T>>
) : ApolloCall.Callback<T>() {   

     override fun onResponse(response: Response<T>) {   
         channel.offer(response)  
     }  
 
      override fun onFailure(e: ApolloException) {   
          channel.close(e)   
      }  
 
      override fun onStatusEvent(event: ApolloCall.StatusEvent) {  
          if (event == ApolloCall.StatusEvent.COMPLETED) {   
            channel.close()  
          }
      }
}

fun <T> ApolloCall<T>.toChannel(): Channel<Response<T>> {  
  
      checkCapacity(capacity)  
      val channel = Channel<Response<T>>(capacity)   
 
      channel.invokeOnClose {  
         cancel()   
      }  
  
      enqueue(ChannelCallback(channel))  
      return channel   
}

Source: Apollo Android’s Early Channel Extension

The approach is similar to using the callbackFlow method we saw earlier. The toChannel method enqueues a callback which communicates with a channel to send data. However, in this case, the channel is created manually and given to the callback.

You could get the results of the request by using the receive method on a channel.

val channel = client
    .query(query)
    .toChannel()

val response = channel.receive()

Flow Extension

The initial implementation also had a variant of the toFlow extension on the Apollo Client.

fun <T> ApolloQueryWatcher<T>.toFlow(capacity: Int) = flow {   
  
    checkCapacity(capacity)  
    val channel = Channel<Response<T>>(capacity)   
  
     enqueueAndWatch(ChannelCallback(channel = channel))  
  
     try {  
        for (item in channel) {  
           emit(item)   
        }  
     } finally {  
        cancel()
     }
}

Source: Apollo Android’s Early Flow Extension

This implementation doesn’t use a callbackFlow. It creates a Flow using the flow builder. It reads from a channel inside a loop to read the results and emit them to the flow.

As we could see, the approaches for bridging from Apollo Callback to Flow has evolved, and it has gotten straightforward as the Kotlin coroutines library has added more features. The callbackFlow makes it easier to bride callbacks to Flows.

Summary

  • Use a callbackFlow to bridge callbacks to Flows

  • CallbackFlow method launches a coroutine on a producer scope and provides a channel to send data from the callback.

  • Converting to a Flow gives you the benefits of using its extensive operators for retries, errors, or mapping operators.

Resources