Chapter 6: Apollo Client and SQLDelight

Kotlin Multiplatform Mobile

link Apollo Client and SQLDelight

Introduction

It is time to get started with the KMM repository. The repository will contain the core business logic for our client-side application.

Before getting started, let's see an overview of the KMM repository:

As shown above, the KMM repository will be responsible for:

  • Creating a local database driver with SQLDelight
  • Connecting to a remote GraphQL server with Apollo Client
  • Creating, reading, updating and destroying data from the server

In this section, we will implement the shared logic for networking and data storage.

Without further ado, let's start building the KMM repository.

link Installation

Download the starter project by running the following:

git clone --branch start https://github.com/Maelstroms38/kmm-apollo.git

KMM Repository Structure

Since we are developing with a monolith-style repository our project is structured with the following four root directories:

  1. shared - the shared business logic, written in pure Kotlin
  2. buildSrc - gradle dependency manager used for managing third-party libraries
  3. androidApp - the source code for the Android application, written in Kotilin
  4. iosApp - the source code the iOS application, written in Swift

Within the shared directory, there are six directories:

  1. androidMain and androidTest - Android platform-specific code and unit tests
  2. commonMain and commonTest - Shared repository code for iOS and Android and unit tests
  3. iosMain and iosTest - iOS platform-specific code and unit tests

During this section, we will see several examples of which code belongs in commonMain, androidMain and iosMain.

link Apollo Client

In order to send queries and mutations to the GraphQL server, we can initialize an Apollo Client instance. To initialize it, create a file called ApolloProvider.kt in the shared/src/commonMain/kotlin/com/example/justdesserts/shared directory and add the following:

import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.network.http.ApolloHttpNetworkTransport

class ApolloProvider {
    internal val apolloClient: ApolloClient = ApolloClient(
        networkTransport = ApolloHttpNetworkTransport(
            serverUrl = "https://your-heroku-app-name.herokuapp.com/graphql",
            headers = mapOf(
                "Accept" to "application/json",
                "Content-Type" to "application/json",
            ),
        )
    )
}

The above code creates an internal instance of the ApolloClient and connects to your GraphQL server.

Because the class is declared as internal, it will only become accessible to files inside the same shared module.

It also sends a map of headers, which can be configured for every subsequent request. In order to provide a user's auth token in the request headers, we can configure the ApolloClient to intercept each request and include a persisted auth token.

link Generate GraphQL Schema

Generating GraphQL schema queries and mutations follows a standard process:

  1. Download the GraphQL schema as JSON or SDL files
  2. Write your client-side app queries and mutations
  3. Use code generation to convert your queries and mutations to type-safe classes

In order to generate the queries and mutations, we need to download a local copy of the GraphQL schema. We can do so with a new gradle task at the command line.

Create a new directory called graphql in the shared/src/commonMain/kotlin/com/example/justdesserts/shared directory. Then at the root of the project, run the following command:

./gradlew downloadApolloSchema --endpoint="https://your-heroku-app-name.herokuapp.com/graphql" --schema="src/commonMain/graphql/com.example.justdesserts/schema.json"

If the command succeeded you should see a new file named schema.json in your project's graphql directory.

We just downloaded the GraphQL schema to our project, and can now generate the necessary queries and mutations.

link Token Persistence

To persist the auth token on the mobile application, we have several approaches to consider:

  1. On Android, we can consider ROOM database storage or even a user preferences key.
  2. On iOS, we can consider Core Data or even the NSUserDefaults as a preference key.
  3. With KMM, we can consider SQLDelight to persist the token on both platforms.

While all of the above approaches are viable, choice #3 is the approach that implements persistent storage on both platforms.

Up next, we will create a SQLite database using SQLDelight.

link SQLDelight

Before getting started with SQLDelight, let's learn more about this database library and what it offers.

In case you are not familiar, SQLite is a relational database management system that is typically embedded with the client-side storage. It is a popular choice for web apps and mobile apps, and arguably the most widely used database engine, which has bindings to many languages.

To learn more about SQLite, visit sqlite.org

SQLDelight provides SQLite databases for mobile applications. It has a multiplatform database driver, which is implemented on both iOS and Android. In case you are wondering, a database driver is a program that implements a protocol for a database connection.

To learn more about SQLDelight and KMM, visit kotlinlang.org

link SQLDelight Tables

As we are working with SQLDelight, we can write SQL statements to insert query our local database tables. While no previous SQL experience is necessary, it could not hurt to reference a cheat sheet.

For a cheat sheet of useful SQL commands, visit sqltutorial.org

Create a new directory in the shared/src/commonMain/ folder called sqldelight. Inside the sqldelight directory, create a project path (shared/src/commonMain/sqldelight/com/example/justdesserts/shared/cache/JustDesserts.sq) to a file named JustDesserts.sq.

Add the following SQL statements to create and manage three new database tables:

-- 1. Create Dessert, Review and UserState tables

CREATE TABLE Dessert (
    id TEXT NOT NULL PRIMARY KEY,
    userId TEXT NOT NULL,
    name TEXT NOT NULL,
    description TEXT NOT NULL,
    imageUrl TEXT NOT NULL
);

CREATE TABLE Review (
    id TEXT NOT NULL PRIMARY KEY,
    dessertId TEXT NOT NULL,
    userId TEXT NOT NULL,
    text TEXT NOT NULL,
    rating INTEGER NOT NULL
);

CREATE TABLE UserState (
   userId TEXT NOT NULL,
   token TEXT NOT NULL
);

The above code handles the following:

  1. Create three new database tables, Dessert, Review and UserState.
  2. Allows access to manage the Dessert and UserState tables. The Dessert table persists favorite desserts while the UserState table persists a logged-in user's authentication token.

By building the project, we can verify that these SQL statements are valid and generate typesafe Kotlin code.

Build the project. If the gradle build succeeds, you may see new typesafe Kotlin code generated, such as Dessert.kt, Review.kt and UserState.kt.

SQLite Driver

SQLDelight provides multiple platform-specific implementations of the SQLite driver, so you should create it for each platform separately.

These drivers provide access to each platform's implementation of the SQLite database store. For each platform, we declare a driver and pass in the appropriate context, along with the database name.

In this section, we will incorporate SQLDelight with the following steps:

  1. Configure SQLDelight for the KMM repository
  2. Create database drivers for iOS and Android
  3. Write SQL statements that describe our app database
  4. Perform database queries using type-safe Kotlin APIs

IntelliJ Plugin

You can install the IntelliJ plugin for SQLDelight here: https://cashapp.github.io/sqldelight/multiplatform_sqlite/intellij_plugin/

link Database Driver Factory

Create a directory named cache in the shared/src/commonMain/kotlin/com/example/justdesserts/shared directory and add a file named DatabaseDriverFactory.kt with the following contents:

import com.squareup.sqldelight.db.SqlDriver

expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

As an expect class, KMM provides us with an abstract class definition to be implemented on each platform. We can also see an example expect class with Platform.kt.

For each expect class, we need an actual class as well.

Expect / Actual

Expected and actual declarations allow developers to focus on platform-specific implementations.

iOS and Android are so vastly different, they often require dedicated teams to work in parallel.

The expect and actual pattern encourages developers to create shared logic using one class declaration, and decide how each platform should take care of the business logic.

Builder Book

As an example, let's see how we could implement shared logic to display a UUID string on each platform:

// Common
expect fun randomUUID(): String
// Android
import java.util.UUID
actual fun randomUUID() = UUID.randomUUID().toString()
// iOS
import platform.Foundation.NSUUID
actual fun randomUUID(): String = NSUUID().UUIDString()

The above code can be used with both platforms. Something of note is that the iOS implementation is written in Kotlin, not Swift. This means that KMM will only compile shared code that supports multiplatform libraries.

For more information on connecting to platform-specific APIs, visit kotlinlang.org

Android Database Driver

For the Android-specific implementation of the SQLDelight database driver, add a file to the shared/src/androidMain/kotlin/com/example/justdesserts/shared/cache/ directory called DatabaseDriverFactory.kt with the following contents:

import android.content.Context
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver

actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver {
        return AndroidSqliteDriver(JustDesserts.Schema, context, "desserts.db")
    }
}

The above code is responsible for creating an AndroidSqliteDriver instance, with the correct context and database named desserts.db.

iOS Database Driver

For the iOS-specific implementation of the SQLDelight database driver, add a file to the shared/src/iosMain/kotlin/com/example/justdesserts/shared/cache/ directory called DatabaseDriverFactory.kt with the following contents:

import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.drivers.native.NativeSqliteDriver

actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver {
        return NativeSqliteDriver(JustDesserts.Schema, "desserts.db")
    }
}

The above code is responsible for creating an NativeSqliteDriver instance with a database named desserts.db.

That's it for the expect and actual implementation of SQLDelight. Up next, we will write SQL statements to generate typesafe Kotlin APIs.

Before continuing, be sure to build the project using the androidApp build configuration. If the gradle build is successful, nicely done!

link SQL Statements

Return to JustDesserts.sq and add the following SQL statements to create and manage the three database tables:

-- 2. Create, Read, Update or Destroy desserts or userState
insertDessert:
INSERT INTO Dessert(id, userId, name, description, imageUrl)
VALUES(?, ?, ?, ?, ?);

updateDessertById:
UPDATE Dessert
SET name = ?, description = ?, imageUrl = ?
WHERE id = ?;

removeAllDesserts:
DELETE FROM Dessert;

selectAllDesserts:
SELECT * FROM Dessert;

selectDessertById:
SELECT * FROM Dessert
WHERE id = ?;

removeDessertById:
DELETE FROM Dessert
WHERE id = ?;

selectUserState:
SELECT * FROM UserState
LIMIT 1;

insertUserState:
INSERT INTO UserState(userId, token)
VALUES(?, ?);

removeUserState:
DELETE FROM UserState;

link Database

To access the new models, we need to wire up our SQL statements to the Kotlin shared repository. Create a new file in the shared/src/commonMain/kotlin/com/example/justdesserts/shared/cache directory called Database.kt.

Create a new internal class called Database and insert the following:

class Database(databaseDriverFactory: DatabaseDriverFactory) {
    private val database = JustDesserts(databaseDriverFactory.createDriver())
    private val dbQuery = database.justDessertsQueries

    internal fun clearDatabase() {
        dbQuery.transaction {
            dbQuery.removeAllDesserts()
        }
    }

    internal fun getDesserts(): List<Dessert> {
        return dbQuery.selectAllDesserts().executeAsList()
    }

    internal fun getDessertById(dessertId: String): Dessert? {
        return dbQuery.selectDessertById(dessertId).executeAsOneOrNull()
    }

    internal fun saveDessert(dessert: Dessert) {
        dbQuery.transaction {
            insertDessert(dessert)
        }
    }

    internal fun updateDessert(dessert: Dessert) {
        dbQuery.transaction {
            updateDessertById(dessert)
        }
    }

    internal fun deleteDessert(dessertId: String) {
        dbQuery.transaction {
            removeDessert(dessertId)
        }
    }

    internal fun getUserState(): UserState? {
        return dbQuery.selectUserState().executeAsOneOrNull()
    }

    internal fun saveUserState(userId: String, token: String) {
        dbQuery.transaction {
            insertUserState(userId, token)
        }
    }

    internal fun deleteUserState() {
        dbQuery.transaction {
            removeUserState()
        }
    }

    private fun removeDessert(dessertId: String) {
        dbQuery.removeDessertById(dessertId)
    }

    private fun insertDessert(dessert: Dessert) {
        dbQuery.insertDessert(
            dessert.id,
            dessert.userId,
            dessert.name,
            dessert.description,
            dessert.imageUrl
        )
    }

    private fun updateDessertById(dessert: Dessert) {
        dbQuery.updateDessertById(
            name = dessert.name,
            description = dessert.description,
            imageUrl = dessert.imageUrl,
            id = dessert.id
        )
    }

    private fun insertUserState(userId: String, token: String) {
        dbQuery.insertUserState(userId, token)
    }

    private fun removeUserState() {
        dbQuery.removeUserState()
    }
}

The above code takes each SQL statement we wrote earlier and implements it using typesafe Kotlin syntax.

By now, you should be able to verify the project compiles and builds successfully.

Finally, we will include the new Database class with our ApolloProvider.

link Apollo Provider Database

By including the Database class with the ApolloProvider, we can fetch a user's token from local storage and include it with subsequent request headers.

To include it with each request, we use an Apollo interface called TokenProvider, which provider interceptors for fetching the current token and refresh token.

Back in shared/src/commonMain/kotlin/com/example/justdesserts/shared/ApolloProvider.kt, include the following code to create an instance of the SQLDelight database and a list of request interceptors.

import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.interceptor.BearerTokenInterceptor
import com.apollographql.apollo.interceptor.TokenProvider
import com.apollographql.apollo.network.http.ApolloHttpNetworkTransport
import com.example.justdesserts.shared.cache.Database
import com.example.justdesserts.shared.cache.DatabaseDriverFactory

class ApolloProvider(databaseDriverFactory: DatabaseDriverFactory) : TokenProvider {

    internal val database = Database(databaseDriverFactory)
    internal val apolloClient: ApolloClient = ApolloClient(
        networkTransport = ApolloHttpNetworkTransport(
            serverUrl = "https://your-heroku-app-name.herokuapp.com/graphql",
            headers = mapOf(
                "Accept" to "application/json",
                "Content-Type" to "application/json",
            ),
        ),
        interceptors = listOf(BearerTokenInterceptor(this))
    )

    override suspend fun currentToken(): String {
        return database.getUserState()?.token ?: ""
    }

    override suspend fun refreshToken(previousToken: String): String {
        return ""
    }
}

By adding the TokenProvider interface, we can implement two new methods:

  1. currentToken - fetches a persisted auth token from local database storage
  2. refreshToken - fetches a refresh token from local database storage (not implemented)

These methods are invoked each time we send a query to the GraphQL server, so we no longer have to worry about accessing the auth token on Android or iOS platforms.

link Code Lab - LoggingInterceptor

In addition to a BearerTokenInterceptor, we can include a custom logger for logging requests and debugging issues at runtime.

Challenge: Implement a LoggingInterceptor class that will log requests and print any relevant debug information such as request UUIDs, names, variables and response times.

In order to create the logging interceptor, you will need to create a class that implements the ApolloRequestInterceptor protocol, and include it with the list of interceptors inside ApolloProvider.kt.

Once again, try to implement this on your own before taking a peak at the solution code.

Solution Code

For the full implementation, please refer to the logger feature branch on github.

Before continuing, be sure to build the project using the androidApp build configuration. If the gradle build is successful, congratulations!

Today we implemented an ApolloClient instance that supports authentication. We also created a SQLite database with the SQLDelight library.

Up next, we will create the shared repositories for desserts, reviews and authentication. Stay tuned!

link References

format_list_bulleted
help_outline