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:
- shared - the shared business logic, written in pure Kotlin
- buildSrc - gradle dependency manager used for managing third-party libraries
- androidApp - the source code for the Android application, written in Kotilin
- iosApp - the source code the iOS application, written in Swift
Within the shared directory, there are six directories:
- androidMain and androidTest - Android platform-specific code and unit tests
- commonMain and commonTest - Shared repository code for iOS and Android and unit tests
- 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:
- Download the GraphQL schema as JSON or SDL files
- Write your client-side app queries and mutations
- 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:
- On Android, we can consider ROOM database storage or even a user preferences key.
- On iOS, we can consider Core Data or even the NSUserDefaults as a preference key.
- 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:
- Create three new database tables,
Dessert
,Review
andUserState
. - Allows access to manage the
Dessert
andUserState
tables. TheDessert
table persists favorite desserts while theUserState
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:
- Configure SQLDelight for the KMM repository
- Create database drivers for iOS and Android
- Write SQL statements that describe our app database
- 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.
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:
currentToken
- fetches a persisted auth token from local database storagerefreshToken
- 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!