Chapter 3: Reviews

Kotlin Multiplatform Mobile

link Reviews

Introduction

The reviews feature will add the ability to write comments and add ratings for each of your favorite (or least favorite) desserts.

Start by creating a Review.kt file in models directory and insert the following:

// 1
data class Review(override val id: String, val userId: String, val dessertId: String, val text: String, val rating: Int): Model

// 2
data class ReviewInput(val text: String, val rating: Int) 

Here's what the above code defines:

  1. The review data class provides the attributes for the model.
  2. The ReviewInput is the model that we are going to send when we want to modify the data source.

Sounds familiar? That's because we already wrote similar data classes in src/models/Dessert.kt.

Since each each review belongs to a dessert and user, we added a dessertId and userId.

To make reviews accessible from the desserts page, we will add an array of reviews to each Dessert.

Add the following inside src/models/Dessert.kt:

data class Dessert(override val id: String, val userId: String, var name: String, var description: String, var imageUrl: String, var reviews: List<Review> = emptyList()): Model

Alright, we just added an empty list of reviews for each dessert.

Now we can go ahead and implement the review repository, service and schema. Because the rest of this section is a "review" of the previous material, you may challenge yourself to implement the following:

  1. Full CRUD Reviews (Create, Read, Update and Destroy)
  2. For each getDessert query, fetch that dessert's list of reviews

link Review Repository

Getting started with a new piece of server logic requires a repository. In this case, the ReviewRepository will handle fetching, creating and updating existing reviews.

Let's begin by creating a new file in the repository directory called ReviewRepository.kt and insert the following:

import com.example.models.Review
import com.mongodb.client.MongoClient
import com.mongodb.client.MongoCollection
import org.litote.kmongo.*
import java.lang.Exception

class ReviewRepository(client: MongoClient) : RepositoryInterface<Review> {

    override lateinit var col: MongoCollection<Review>

    init {
        val database = client.getDatabase("test")
        col = database.getCollection<Review>("Review")
    }

    fun getReviewsByDessertId(dessertId: String): List<Review> {
        return try {
            val res = col.find(Review::dessertId eq dessertId)
                    ?: throw Exception("No review with that dessert ID exists")
            res.asIterable().map { it }
        } catch(t: Throwable) {
            throw Exception("Cannot find reviews")
        }
    }
}

Once again, the above code is responsible for creating, reading, updating and destroying our models.

In this case, we added a new method called getReviewsByDessertId, which will fetch a list of reviews that belong to the current dessert.

link Review Service

Moving up the chain here, we will create a review service that connects to the review repository.

Inside the services directory, create a file called ReviewService.kt and add the following:

import com.example.models.Review
import com.example.models.ReviewInput
import com.example.repository.ReviewRepository
import com.mongodb.client.MongoClient
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.UUID

class ReviewService: KoinComponent {
    private val client: MongoClient by inject()
    private val repo = ReviewRepository(client)

    fun getReview(id: String): Review {
        return repo.getById(id)
    }

    fun createReview(userId: String, dessertId: String, reviewInput: ReviewInput): Review {
        val uid = UUID.randomUUID().toString()
        val review = Review(
                id = uid,
                userId = userId,
                dessertId = dessertId,
                text = reviewInput.text,
                rating = reviewInput.rating
        )
        return repo.add(review)
    }

    fun updateReview(userId: String, reviewId: String, reviewInput: ReviewInput): Review {
        val review = repo.getById(reviewId)
        if (review.userId == userId) {
            val updates = Review(
                    id = reviewId,
                    dessertId = review.dessertId,
                    userId = userId,
                    text = reviewInput.text,
                    rating = reviewInput.rating
            )
            return repo.update(updates)
        }
        error("Cannot update review")
    }

    fun deleteReview(userId: String, reviewId: String): Boolean {
        val review = repo.getById(reviewId)
        if (review.userId == userId) {
            return repo.delete(reviewId)
        }
        error("Cannot delete review")
    }
} 

The review service will allow users to create, update and destroy reviews, but only if the associated userId belongs to that review.

In addition, we will want to add reviews for each selected dessert. Let's go ahead and include this with the existing dessert query.

link Dessert Reviews

When fetching any dessert, we would like to include a list of reviews from other users. To include an associated model's objects, we can include that model's repository at the service level.

In this case, we will include the ReviewRepository with the DessertService.

Back in src/services/DessertService.kt, add the following:

import com.example.models.Review
import com.example.repository.ReviewRepository

// ... 

class DessertService: KoinComponent {
    private val client: MongoClient by inject()
    private val repo: DessertRepository = DessertRepository(client)
    private val reviewRepo: ReviewRepository = ReviewRepository(client)

    fun getDessert(id: String): Dessert {
        val dessert = repo.getById(id)
        dessert.reviews = reviewRepo.getReviewsByDessertId(id)
        return dessert
    }

    // ...
}

The above code handles the following:

  1. Initializes a new reviewRepo inside the DessertService
  2. During the getDessert query, we call getReviewsByDessertId to include a list of reviews

link Review Schema

Now that the repository and service are wired up, we can create a review schema for our GraphQL SDL.

Create a new file in the graphql directory called ReviewSchema.kt and add the following:

import com.apurebase.kgraphql.Context
import com.apurebase.kgraphql.schema.dsl.SchemaBuilder
import com.example.models.*
import com.example.services.ReviewService

fun SchemaBuilder.reviewSchema(reviewService: ReviewService) {

    query("getReview") {
        description = "Get an existing review"
        resolver { reviewId: String ->
            try {
                reviewService.getReview(reviewId)
            } catch (e: Exception) {
                null
            }
        }
    }

    mutation("createReview") {
        description = "Create a new review"
        resolver { dessertId: String, reviewInput: ReviewInput, ctx: Context ->
            try {
                val userId = "abc"
                reviewService.createReview(userId, dessertId, reviewInput)
            } catch (e: Exception) {
                null
            }
        }
    }

    mutation("updateReview") {
        description = "Update an existing review"
        resolver { reviewId: String, reviewInput: ReviewInput, ctx: Context ->
            try {
                val userId = "abc"
                reviewService.updateReview(userId, reviewId, reviewInput)
            } catch (e: Exception) {
                null
            }
        }
    }

    mutation("deleteReview") {
        description = "Delete a review"
        resolver { reviewId: String, ctx: Context ->
            try {
                val userId = "abc"
                reviewService.deleteReview(userId, reviewId)
            } catch (e: Exception) {
                null
            }
        }
    }

    inputType<ReviewInput>{
        description = "The input of the review without the identifier"
    }

    type<Review>{
        description = "Review object with the attributes text and rating"
    }
} 

The above code defines the following:

  1. Initialize a Review Repository and define our available data types.
  2. Create query resolvers that fetch data from the review objects.
  3. Create mutation resolvers that send data when we want to modify the review objects.

Each resolver requires a unique name and a return value. You may notice that these resolvers do not look like the previous Kotlin functions we wrote. In KGraphQL, each resolver clause accepts a kotlin function and returns some data.

Now that we defined our GraphQL schema, let's install it and test it's functionality.

link Install Review Schema

Back in src/Application.kt, add the following to install the review schema:

install(GraphQL) {
    val dessertService = DessertService()
    val reviewService = ReviewService()
    playground = true
    schema {
        dessertSchema(dessertService)
        reviewSchema(reviewService)
    }
}

That's it! It's time to run the server, and open the GraphQL playground at localhost:8080/graphql.

Since we do not have any existing reviews, we can create some by testing mutations first.

Test Mutations

In the query section, add the following create mutation:

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

In the query variables section, add the following parameters:

{"dessertId": "YOUR_DESSERT_ID_HERE", "reviewInput": {"text": "Tasty!", "rating": 5}}

By this point, you should have created your first review! Go ahead and modify it with the following update mutation:

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

Finally, delete the review with the following mutation:

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

Make sure you have at least one review left over to test with.

Test Queries

With only one new query, we can test getting a single review:

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

As before, we can fetch a list of paginated desserts, and now see their associated reviews.

Modify the desserts paginated query to include reviews:

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

If you are able to return a list of reviews per dessert, congratulations!

Today we successfully added reviews to our desserts application.

Up next, we will add authentication for users to log in and sign up.

format_list_bulleted
help_outline