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:
- BaseRepository - an open class for initializing repositories
- DessertRepository - the repository for fetching dessert data
- AuthRepository - the repository for signing up and signing in
- 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:
- Creating two new data classes to handle a list of paginated desserts and a single dessert's details.
- Converting each query response object to use a method named
toDessert()
- we could also name this functiontoModel()
, 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:
- Create a
DessertRepository
class with a dependency on anapolloProvider
. TheDessertRepository
implements the open classBaseRepository
. By implementing this class, we get access to theapolloClient
anddatabase
instances. - Write a coroutine function called
getDesserts
, which fetches the paginated list of desserts and returns aDesserts
response object. - For the response object, we can send a generated query called
GetDessertsQuery
, and execute it on the apollo client. - Return the
Desserts
response object from the GraphQL server, and map it to our client-side models usingtoDesserts()
.
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:
- Create a coroutine function to send
DessertInput
data and return an optionalDessert
object. - For the response object, we can send a generated mutation called
NewDessertMutation
, and execute it on the apollo client. - Return the
Dessert
response object from the GraphQL server, and map it to our client-side models usingtoDessert()
.
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:
getReview
- using areviewId
, query for and return an optionalReview?
model.newReview
- using adessertId
andReviewInput
, create and return an optionalReview?
model.updateReview
- using areviewId
andReviewInput
, update and return an optionalReview?
model.deleteReview
- using areviewId
, delete aReview
and return an optionalBoolean?
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!