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:
- The review data class provides the attributes for the model.
- 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:
- Full CRUD Reviews (Create, Read, Update and Destroy)
- 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:
- Initializes a new
reviewRepo
inside theDessertService
- During the
getDessert
query, we callgetReviewsByDessertId
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:
- Initialize a Review Repository and define our available data types.
- Create query resolvers that fetch data from the review objects.
- 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.