Last time we tested the local data source with a simple request.
Now let’s add some real power to it.
First, declare a bunch of useful functions in the LocalSource
interface. These have to do with our future app’s functionality like adding recipes and corresponding ingredients and instructions.
internal interface LocalSource {
suspend fun getPizza(): String
suspend fun addIngredient(recipeId: Long, ingredient: Ingredient): Long
suspend fun addInstruction(recipeId: Long, instruction: Instruction): Long
suspend fun addRecipe(recipe: Recipe): Long
suspend fun getRecipes() : List<Recipe>
suspend fun getRecipe(recipeId: Long) : Recipe
}
Some of them return Long
, because we’ll add new entries to the database, which will assign an id
to each of them and return that id
as Long
.
Now implement these functions in LocalSourceImpl
:
override suspend fun addIngredient(recipeId: Long, ingredient: Ingredient) = withContext(dispatcher) {
transaction {
val recipe = RecipeEntity[recipeId.toInt()] [1]
IngredientEntity.new { [2]
name = ingredient.name
amount = ingredient.amount.toBigDecimal()
metric = ingredient.metric
this.recipe = recipe
}.id.value.toLong()
}
}
override suspend fun addInstruction(recipeId: Long, instruction: Instruction) = withContext(dispatcher) {
transaction {
val recipe = RecipeEntity[recipeId.toInt()]
InstructionEntity.new {
order = instruction.order
description = instruction.description
this.recipe = recipe
}.id.value.toLong()
}
}
override suspend fun addRecipe(recipe: Recipe) = withContext(dispatcher) {
withContext(dispatcher) {
val recipeId = transaction {
RecipeEntity.new {
title = recipe.title
}.id.value.toLong()
} [3]
recipe.ingredients.forEach{
addIngredient(recipeId, it)
} [4]
recipe.instructions.forEach{
addInstruction(recipeId, it)
}
recipeId
}
}
override suspend fun getRecipes() : List<Recipe> = withContext(dispatcher) {
transaction {
RecipeEntity.all().map { it.toRecipe() }
}
}
override suspend fun getRecipe(recipeId: Long): RecipeResponse = withContext(dispatcher) {
transaction {
RecipeEntity[recipeId.toInt()].toRecipeResponse()
}
}
These are basic functions that will fill up our database with recipes, ingredients and instructions.
[1] When creating an ingredient or instruction, we have to specify recipeId which this item belongs to and get the appropriate recipe
[2] Then we’ll create a new ingredient or instruction entity and attach it to our recipe
[3] When adding a new recipe to the database we first create a new recipe entry
[4] Then add all the instructions and ingredients to it
Now let’s bind those functions to our backend. Just like we did with getPizza, we’ll add some more routes for adding and getting recipes in Api.kt
:
internal fun Routing.api(localSource: LocalSource) {
pizza(localSource)
addRecipe(localSource)
getRecipes(localSource)
getRecipe(localSource)
}
Implement them in the same file:
private fun Routing.addRecipe(localSource: LocalSource) {
post("/recipes") {
val recipeId = localSource.addRecipe(call.receive())
call.respond(recipeId)
}
}
private fun Routing.getRecipes(localSource: LocalSource) {
get("/recipes") {
localSource.getRecipes().let {
call.respond(it)
}
}
}
private fun Routing.getRecipe(localSource: LocalSource) {
get("/recipes/{recipeId}") {
val recipeId = call.parameters["recipeId"]!!.toLong()
localSource.getRecipe(recipeId).let {
call.respond(it)
}
}
}
[1] When adding a Recipe we specify the Recipe data class as the body input and get a newly created recipe entity id from the database
[2] To get a recipe by id we need to lookup for the recipe with the id, that we specified in API route
One last thing before we test it: we want to be able to serialize our classes between the client and the server. To do so, we’ll need kotlinx.serialization
plugin. Let’s adjust our dependencies.
First, add this to the Versions.kt
:
const val KOTLIN_VERSION = "1.6.10"
In the backend
module add the serialization plugin to build.gradle.kts
:
plugins {
kotlin("jvm")
application
id("com.github.johnrengelman.shadow") version Versions.SHADOW_JAR_VERSION
kotlin("plugin.serialization") version Versions.KOTLIN_VERSION
}
You may also need to adjust the project build.gradle.kts
so that it matches the latest Kotlin version you specified, for example like so:
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN_VERSION}")
classpath("com.android.tools.build:gradle:7.1.0-alpha10")
}
Sync the project.
Then add @Serializable
annotation to all our model data classes like this:
@Serializable
data class Recipe(
val id: Long = 0,
val title: String,
val ingredients: List<Ingredient>,
val instructions: List<Instruction>
)
You may need to do a manual import:
import kotlinx.serialization.Serializable
Now let’s test this in action. Use Postman for it. Rebuild and make the backend again and run it locally as before:
export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres
java -jar ./backend/build/libs/backend.jar -port=9000
Then try adding a Pizza Dough recipe with Postman. Send a POST
request to our API endpoint http://localhost:9000/recipes
. You can use the following body:
{
"title":"Pizza dough",
"ingredients": [
{
"name": "flour",
"amount": 2.0,
"metric": "cup"
},
{
"name": "water",
"amount": 1.0,
"metric": "cup"
},
{
"name": "flour",
"amount": 2.5,
"metric": "cup"
},
{
"name": "salt",
"amount": 1.0,
"metric": "teaspoon"
},
{
"name": "sugar",
"amount": 1.0,
"metric": "teaspoon"
},
{
"name": "dry yeast",
"amount": 7.0,
"metric": "gram"
},
{
"name": "olive oil",
"amount": 3.0,
"metric": "tablespoons"
}
],
"instructions":[
{
"order": 1,
"description": "Mix water, sugar and yeast. Let it froth for 10 minutes"
},
{
"order": 2,
"description": "Mix dough, salt, olive oil. Add water mixture from previous step."
},
{
"order": 3,
"description": "Knead the dough for 10 minutes"
}
]
}
We get 500 Internal Server Error
😱
Don’t be upset! We’ll find out why.
To help us investigate the cause we can install StatusPages
in Application.module()
and let it return the cause of the error like this :
install(StatusPages) {
exception<Throwable> { cause ->
call.respond(cause.toString())
}
}
Now if you rebuild the backend and try the same request again you’ll see
io.ktor.features.CannotTransformContentToTypeException: Cannot transform this request's content to com.example.recipecollection.backend.model.Recipe
This happens because we forgot to install ContentNegotiation
between the client and the server to help us serialize objects. Let’s do it now, add this to you application:
install(ContentNegotiation) {
json()
}
Build the backend and try sending the request again.
Voilà!
We get the recipe id
in return.
Now try posting the Recipe
again.
Then try the GET /recipes
endpoint and you’ll get our Pizza Dough recipe in return.
Time to start cooking! 🧑🍳