Chapter 7: KMM Repository

Kotlin Multiplatform Mobile

link KMM Repository

Introduction

Now that we have completed the shared networking and database logic, we can start writing data fetching queries with the shared repository.

In this section, we will create the following repositories:

  1. BaseRepository - an open class for initializing repositories
  2. DessertRepository - the repository for fetching dessert data
  3. AuthRepository - the repository for signing up and signing in
  4. ReviewRepository - the repository for fetching review data

Prior to creating the repositories we would like to generate the GraphQL queries and mutations.

link Generate Queries

Inside the graphql directory, create a file named Queries.graphql and add the following:

query GetDesserts($page: Int!, $size: Int!) {
  desserts(page: $page, size: $size) {
    results {
      id
      userId
      name
      description
      imageUrl
    }
    info {
      count
      pages
      next
      prev
    }
  }
}

query GetDessert($dessertId: String!) {
  dessert(dessertId:$dessertId) {
    id
    userId
    name
    description
    imageUrl
    reviews {
      id
      dessertId
      userId
      text
      rating
    }
  }
}

query GetProfile {
  getProfile {
    user {
      id
      email
    }
    desserts {
      id
      userId
      name
      description
      imageUrl
    }
  }
}

query GetReview($reviewId:String!) {
  getReview(reviewId:$reviewId) {
    id
    dessertId
    userId
    rating
    text
  }
}

The queries we are adding should reflect the most recent version of your GraphQL server. Let's generate Kotlin code for our GraphQL schema models, once we add the mutations.

link Generate Mutations

Inside the graphql directory, create a file named Mutations.graphql and add the following:

mutation NewDessert($input: DessertInput!) {
  createDessert(dessertInput: $input) {
    id
    name
    description
    imageUrl
    userId
  }
}

mutation UpdateDessert($dessertId: String!, $input: DessertInput!) {
  updateDessert(dessertId: $dessertId, dessertInput: $input) {
    id
    name
    description
    imageUrl
    userId
  }
}

mutation DeleteDessert($dessertId: String!) {
  deleteDessert(dessertId: $dessertId)
}

mutation NewReview($dessertId:String!, $reviewInput: ReviewInput!) {
  createReview(dessertId:$dessertId, reviewInput:$reviewInput) {
    id
    text
    rating
    dessertId
    userId
  }
}

mutation UpdateReview($reviewId:String!, $reviewInput: ReviewInput!) {
  updateReview(reviewId:$reviewId, reviewInput:$reviewInput) {
    id
    text
    rating
    dessertId
    userId
  }
}

mutation DeleteReview($reviewId:String!) {
  deleteReview(reviewId:$reviewId)
}

mutation SignIn($userInput:UserInput!) {
  signIn(userInput:$userInput) {
    token
    user {
      id
      email
    }
  }
}

mutation SignUp($userInput:UserInput!) {
  signUp(userInput:$userInput) {
    token
    user {
      id
      email
    }
  }
}

We have now added all queries and mutations to the application. If you should ever decide to alter the schema, be sure to re-download the schema and update your queries or mutations.

Now that we generated all the queries and mutations, we can begin writing repositories with a BaseRepository.

link BaseRepository

Most of our repositories share the same dependencies: an ApolloClient and Database, so we can create an open class to instantiate these two properties.

Create a new directory in the shared/src/commonMain/kotlin/com/example/justdesserts/shared directory called repository, and add a file inside the new directory called BaseRepository.kt.

import com.apollographql.apollo.ApolloClient
import com.example.justdesserts.shared.ApolloProvider
import com.example.justdesserts.shared.cache.Database

open class BaseRepository(apolloProvider: ApolloProvider) {
    val apolloClient: ApolloClient = apolloProvider.apolloClient
    val database: Database = apolloProvider.database
}

We can use this class to initialize an apolloClient and database instance for each repository.

Prior to getting started with the repositories, we will create two data mapper class called DessertMapper and ReviewMapper.

Data Model Mappers

When developing with typesafe languages such as Kotlin or Swift, it is important to keep your data models consistent. When we query data from a GraphQL server, it is returned using different types:

  • GetDessertsQuery.Result.Dessert
  • GetDessertsQuery.Desserts
  • GetProfileQuery.Dessert
  • GetDessertQuery.Dessert

...and many more!

Each of these types can be referred to as a Data Transfer Object, or DTO, which is meant to convert response data to client-side models.

In order to keep these return types consistent, we can create a model mapper that transforms each query result to a single type: Dessert.

We already generated a Dessert.kt model by creating a SQLite database table in the previous chapter. Now we can convert the returned query types to conform to the Dessert model, using a model mapper. A model mapper simply takes an existing input and creates a new data class.

link DessertMapper

Queries

To get started with the dessert model mapper, create a file in the shared/src/commonMain/kotlin/com/example/justdesserts/shared/cache directory called DessertMapper.kt and add the following query mappers:

import com.example.justdesserts.GetDessertQuery
import com.example.justdesserts.GetDessertsQuery
import com.example.justdesserts.GetProfileQuery
import com.example.justdesserts.NewDessertMutation
import com.example.justdesserts.UpdateDessertMutation

// 1
data class Desserts(val results: List<Dessert>, val info: GetDessertsQuery.Info?)
data class DessertDetail(val dessert: Dessert, val reviews: List<Review>)

// 2
fun GetDessertsQuery.Result.toDessert() = Dessert(
    id = id,
    userId = userId,
    name = name,
    description = description,
    imageUrl = imageUrl
)

fun GetDessertsQuery.Desserts.toDesserts() = Desserts(
    results = results.map {
        it.toDessert()
    },
    info = info
)

fun GetDessertQuery.Dessert.toDessert() = Dessert(
    id = id,
    userId = userId,
    name = name,
    description = description,
    imageUrl = imageUrl
)

fun GetDessertQuery.Dessert.toDessertDetail() =
    DessertDetail(
    dessert = this.toDessert(),
    reviews = emptyList() // reviews.map { it.toReview() }
)

The above code is responsible for:

  1. Creating two new data classes to handle a list of paginated desserts and a single dessert's details.
  2. Converting each query response object to use a method named toDessert() - we could also name this function toModel(), but it helps to be specific for each model.

Some of you may be wondering why we are going through with all these mappers, so let me explain!

Unless we map each query, we will end up passing around objects that have to be converted on the client side, which becomes very messy.

For instance, if we wanted to display a list of GetDessertsQuery.Result.Dessert objects, then display a details screen with a GetDessertQuery.Dessert object, we could never "pass" the object itself, since these two objects are not matching types. Instead, we would need to convert it to an object with Dessert properties each time.

Going forward, we will continue to map query objects for consistent results across the application.

ProfileQuery and Mutations

When querying a profile, creating or updating desserts, we can map the response objects as well. Add the following model mappers to DessertMapper.kt:

fun GetProfileQuery.Dessert.toDessert() = Dessert(
    id = id,
    userId = userId,
    name = name,
    description = description,
    imageUrl = imageUrl
)

fun NewDessertMutation.CreateDessert.toDessert() = Dessert(
    id = id,
    userId = userId,
    name = name,
    description = description,
    imageUrl = imageUrl
)

fun UpdateDessertMutation.UpdateDessert.toDessert() = Dessert(
    id = id,
    userId = userId,
    name = name ,
    description = description,
    imageUrl = imageUrl
)

So while it helps to generate the queries and mutations per GraphQL schema, organizing the models as a set of data classes requires some extra effort upfront.

link ReviewMapper

Like the dessert mapper, we will need to map our reviews from DTO's to data classes. Let's create a file in the shared/src/commonMain/kotlin/com/example/justdesserts/shared/cache directory called ReviewMapper.kt and add the following:

Queries

import com.example.justdesserts.GetDessertQuery
import com.example.justdesserts.GetReviewQuery
import com.example.justdesserts.NewReviewMutation
import com.example.justdesserts.UpdateReviewMutation

fun GetDessertQuery.Review.toReview() = Review(
    id = id,
    userId = userId,
    dessertId = dessertId,
    text = text,
    rating = rating.toLong()
)

fun GetReviewQuery.GetReview.toReview() = Review(
    id = id,
    userId = userId,
    dessertId = dessertId,
    text = text,
    rating = rating.toLong()
)

Be sure to go back to DessertMapper.kt and replace emptyList() with reviews.map { it.toReview() }.

Mutations

fun NewReviewMutation.CreateReview.toReview() = Review(
    id = id,
    userId = userId,
    dessertId = dessertId,
    text = text,
    rating = rating.toLong()
)


fun UpdateReviewMutation.UpdateReview.toReview() = Review(
    id = id,
    userId = userId,
    dessertId = dessertId,
    text = text,
    rating = rating.toLong()
)

Now that we mapped the GraphQL response DTO's to data classes, we can move on to the DessertRepository and query for the desserts (finally!).

link DessertRepository

Queries

Create a file inside the shared/src/commonMain/kotlin/com/example/justdesserts/shared/repository directory called DessertRepository.kt and add the following:

import com.example.justdesserts.*
import com.example.justdesserts.shared.ApolloProvider
import com.example.justdesserts.shared.cache.Dessert
import com.example.justdesserts.shared.cache.DessertDetail
import com.example.justdesserts.shared.cache.Desserts
import com.example.justdesserts.shared.cache.toDessert
import com.example.justdesserts.shared.cache.toDesserts
import com.example.justdesserts.shared.cache.toReview
import com.example.justdesserts.type.DessertInput
import kotlinx.coroutines.flow.single

// 1
class DessertRepository(apolloProvider: ApolloProvider): BaseRepository(apolloProvider) {
  // 2
  suspend fun getDesserts(page: Int, size: Int): Desserts? {
      // 3 
      val response = apolloClient.query(GetDessertsQuery(page, size)).execute().single()
      // 4
      return response.data?.desserts?.toDesserts()
  }

  suspend fun getDessert(dessertId: String): DessertDetail? {
    val response = apolloClient.query(GetDessertQuery(dessertId)).execute().single()
    return response.data?.dessert?.toDessertDetail()
  }
}

The above code handles the following:

  1. Create a DessertRepository class with a dependency on an apolloProvider. The DessertRepository implements the open class BaseRepository. By implementing this class, we get access to the apolloClient and database instances.
  2. Write a coroutine function called getDesserts, which fetches the paginated list of desserts and returns a Desserts response object.
  3. For the response object, we can send a generated query called GetDessertsQuery, and execute it on the apollo client.
  4. Return the Desserts response object from the GraphQL server, and map it to our client-side models using toDesserts().

Coroutines

Still curious about coroutines? Here's what you need to know:

  • By definition, a Kotlin coroutine is a sub-process for asyncronous work.
  • Any function marked with the suspend keyword is known as a coroutine function.
  • Coroutines help us avoid writing callbacks, and in many cases unblock the application's main thread.
  • Coroutines unblock the main thread by "suspending" execution until a result is returned (or an error is thrown) - this suspended execution is known as "continuation."

For more information on Kotlin coroutines visit kotlinlang.org

Mutations

We can continue with the DessertRepository by adding the mutations for adding, updating and deleting desserts. Add the following mutations to DessertRepository.kt:

// 1
suspend fun newDessert(dessertInput: DessertInput): Dessert? {
  // 2
  val response = apolloClient.mutate(NewDessertMutation(dessertInput)).execute().single()
  // 3
  return response.data?.createDessert?.toDessert()
}

suspend fun updateDessert(dessertId: String, dessertInput: DessertInput): Dessert? {
  val response = apolloClient.mutate(UpdateDessertMutation(dessertId, dessertInput)).execute().single()
  return response.data?.updateDessert?.toDessert()
}

suspend fun deleteDessert(dessertId: String): Boolean? {
  val response = apolloClient.mutate(DeleteDessertMutation(dessertId)).execute().single()
  return response.data?.deleteDessert
}

The above code handles the following:

  1. Create a coroutine function to send DessertInput data and return an optional Dessert object.
  2. For the response object, we can send a generated mutation called NewDessertMutation, and execute it on the apollo client.
  3. Return the Dessert response object from the GraphQL server, and map it to our client-side models using toDessert().

Persistence

For saving desserts to the "favorites" tab, we will utilize the SQLDelight database module. Add the following functions inside DessertRepository.kt:

fun saveFavorite(dessert: Dessert) {
    database.saveDessert(dessert)
}

fun removeFavorite(dessertId: String) {
  database.deleteDessert(dessertId)
}

fun updateFavorite(dessert: Dessert) {
  database.updateDessert(dessert)
}

fun getFavoriteDessert(dessertId: String): Dessert? {
  return database.getDessertById(dessertId)
}

fun getFavoriteDesserts(): List<Dessert> {
  return database.getDesserts()
}

For added modularity, you may consider separating the DessertRepository into a remote repository and local repository.

That's all for the DessertRepository, well done so far! Up next, we will create the AuthRepository.

link AuthRepository

The AuthRepository will be used for signing in and signing up, as well as managing local user state.

This time, we will add the mutations and then queries.

In the shared/src/commonMain/kotlin/com/example/justdesserts/shared/repository directory, create a file named AuthRepository.kt and add the following mutations:

Mutations

import com.example.justdesserts.GetProfileQuery
import com.example.justdesserts.SignInMutation
import com.example.justdesserts.SignUpMutation
import com.example.justdesserts.shared.ApolloProvider
import com.example.justdesserts.shared.cache.Dessert
import com.example.justdesserts.shared.cache.UserState
import com.example.justdesserts.shared.cache.toDessert
import com.example.justdesserts.type.UserInput
import kotlinx.coroutines.flow.single

class AuthRepository(apolloProvider: ApolloProvider): BaseRepository(apolloProvider) {

    suspend fun signIn(userInput: UserInput): String {
        val response = apolloClient.mutate(SignInMutation(userInput)).execute().single()
        response.data?.signIn?.let { data ->
            data.user.also {
                database.saveUserState(data.user.id, data.token)
            }
            return data.token
        }
        throw Exception("Could not sign in")
    }

    suspend fun signUp(userInput: UserInput): String {
        val response = apolloClient.mutate(SignUpMutation(userInput)).execute().single()
        response.data?.signUp?.let { data ->
            data.user.also {
                database.saveUserState(data.user.id, data.token)
            }
            return data.token
        }
        throw Exception("Could not sign up")
    }
}

Similar to the previous DessertRepository, we send mutation data using the apolloClient and return an auth token value. You may notice the also block executes saveUserState, which persists the auth token string and current user's id. The also block will only execute if user is not null.

Queries

For authenticated queries, we only need to fetch the current user's profile and manage the user state. So we can add the following queries inside AuthRepository.kt:

suspend fun getProfileDesserts(): List<Dessert> {
    val response = apolloClient.query(GetProfileQuery()).execute().single()
    return response.data?.getProfile?.desserts?.map { it.toDessert() } ?: emptyList()
}

fun getUserState(): UserState? {
    return database.getUserState()
}

fun deleteUserState() {
    return database.deleteUserState()
}

Inside getProfileDesserts, you may notice that if for any reason the returned data is null, we will return an emptyList() instead.

For managing user state, we once again utlize the SQLDelight database module and call the functions written in the previous section.

We are so close to finishing this section! Only the ReviewRepository remains.

link Code Lab - ReviewRepository

For an added challenge, try to implement the ReviewRepository on your own first. The ReviewRepository will live inside the same directory as the previous two repositories.

Challenge: Create a file inside the shared/src/commonMain/kotlin/com/example/justdesserts/shared/repository directory called ReviewRepository.kt, and include the following functions:

  1. getReview - using a reviewId, query for and return an optional Review? model.
  2. newReview - using a dessertId and ReviewInput, create and return an optional Review? model.
  3. updateReview - using a reviewId and ReviewInput, update and return an optional Review? model.
  4. deleteReview - using a reviewId, delete a Review and return an optional Boolean? value.

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

Solution Code
import com.example.justdesserts.DeleteReviewMutation
import com.example.justdesserts.GetReviewQuery
import com.example.justdesserts.NewReviewMutation
import com.example.justdesserts.UpdateReviewMutation
import com.example.justdesserts.shared.ApolloProvider
import com.example.justdesserts.shared.cache.Review
import com.example.justdesserts.shared.cache.toReview
import com.example.justdesserts.type.ReviewInput
import kotlinx.coroutines.flow.single

class ReviewRepository(apolloProvider: ApolloProvider) : BaseRepository(apolloProvider) {
    suspend fun getReview(reviewId: String): Review? {
        val response = apolloClient.query(GetReviewQuery(reviewId)).execute().single()
        return response.data?.getReview?.toReview()
    }

    suspend fun newReview(dessertId: String, reviewInput: ReviewInput): Review? {
        val response = apolloClient.mutate(NewReviewMutation(dessertId, reviewInput)).execute().single()
        return response.data?.createReview?.toReview()
    }

    suspend fun updateReview(reviewId: String, reviewInput: ReviewInput): Review? {
        val response = apolloClient.mutate(UpdateReviewMutation(reviewId, reviewInput)).execute().single()
        return response.data?.updateReview?.toReview()
    }

    suspend fun deleteReview(reviewId: String): Boolean? {
        val response = apolloClient.mutate(DeleteReviewMutation(reviewId)).execute().single()
        return response.data?.deleteReview
    }
}

If you were able to complete the ReviewRepository, well done!

With the ReviewRepository completed, there's just one tiny missing piece of the KMM Repository.

link ActionType

For the client-side, we would like to determine what kind of action a user is taking on a particular entity. The actions a user can take are CREATE, UPDATE and DESTROY.

Let's create a new ActionType enum to capture these values.

Inside the shared/src/commonMain/kotlin/com/example/justdesserts/shared/cache directory, create a file named ActionType.kt and add the following:

enum class ActionType {
    CREATE,
    UPDATE,
    DELETE
}

Today we generated a client-side GraphQL schema, wrote custom data mappers and implemented the shared KMM repositories. This was a very tough challenge, so give yourselves a round of applause!

Up next, we will get started with creating the client-side UI for iOS and Android. Stay tuned!

link References

format_list_bulleted
help_outline