KMPizza
Kotlin Multiplatform + Pizza = ❤️

Step 9: Add images to KMP backend using AWS S3 Storage

There is one thing that is currently missing in our recipes:
mouthwatering pictures of pizza with lots of cheese, like this one:

pizza cheese yummy

So let’s add pictures to our backend. We’ll use Amazon S3 Storage to upload and store images.

Attention: it requires creating an AWS account and binding in to your credit card, but we’ll only use free features, so no worries about having to pay. If you want to continue without images then just skip this part.

First, we need to register. Go to AWS portal and create an AWS account.
Verifying and binding your account to your credit card may take several days, then you’ll be able to start using AWS services.

When you’re verified, go to Amazon Services -> Storage -> S3

go to aws

There create your first bucket, where you’ll store pizza pictures.

create bucket

configure bucket

You can choose an AWS rregion for your bucket. To reduce latency choose the closest to you. Also do not choose “block public access”.

cblock public access

After your create a bucket it should appear in the list like this:

list of buckets

Remember your bucketName and region for future use in the app. In this case they are: bucketName: kmpizza and region: eu-central-1

We’ll also need an accessKey and a secretKey.

Go to IAM Console. And add a new user with programmatic access

create new user

Add permissions for S3 to this user

permissions

In stage 4 it should look like this:

stage 4

When your user is created, copy your secretKey and accessKey

accessKey: ******
secretKey: ******

Now that we have Amazon Storage all set up and ready, let’s get back to our backend module.

First, add AWS SDK to Versions.kt:

const val AWS_VERSION = "2.17.102"

object Jvm {
...
const val AWS_JAVA_SDK = "software.amazon.awssdk:bom:$AWS_VERSION"
const val AWS_S3 = "software.amazon.awssdk:s3"

}

Then implement it in your backend build.gradle.kts dependencies:

implementation(platform(Versions.Jvm.AWS_JAVA_SDK))
implementation(Versions.Jvm.AWS_S3)

Now let’s create a aws package and put it next to exposed in the storage directory:

create aws package

Inside this package create a FileStorage interface with a function to save files:

import java.io.File

interface FileStorage {
   suspend fun save(file: File): String
}

Then create an AmazonFileStorage class that will implement this save function:

class AmazonFileStorage : FileStorage {

   private val client: S3Client

   private val bucketName: String = System.getenv("bucketName") [1]

   init {
       val region = System.getenv("region") [2]
       val accessKey = System.getenv("accessKey")
       val secretKey = System.getenv("secretKey")

       val credentials = AwsBasicCredentials.create(accessKey, secretKey)
       val awsRegion = Region.of(region)
       client = S3Client.builder()
           .credentialsProvider(StaticCredentialsProvider.create(credentials)) [3]
           .region(awsRegion)
           .build() as S3Client
   }

   override suspend fun save(file: File): String = [4]
   withContext(Dispatchers.IO) {
       client.putObject(
               PutObjectRequest.builder().bucket(bucketName).key(file.name).build(),
               RequestBody.fromFile(file)
           ) [5]
       val request = GetUrlRequest.builder().bucket(bucketName).key(file.name).build()
       client.utilities().getUrl(request).toExternalForm()
   }
}

[1] Get the bucket name from environment variables
[2] Also get other settings to initiate an AWS S3 client: region (optional), access and secret Keys
[3] Use specified settings to build an S3Client
[4] Implement the save function from the FileStorage Interface
[5] Use the object request to put a file into the AWS S3 bucket and get the source url

We want to use AWS functionality to upload images from the application when saving new recipes, so add FileStorage to LocalSourceImpl’s constructor like this:

internal class LocalSourceImpl(
   private val fileStorage: FileStorage,
   application: io.ktor.application.Application
) : LocalSource {
. . .
}

Just like with our local storage we want to use the AWS storage simply by injecting where we need it, so add one line to your KoinModule.kt and modify the way we create LocalSourceImpl by injecting the Amazon storage into it:

internal fun Application.getKoinModule() = module {
   single<FileStorage> { AmazonFileStorage() }
   single<LocalSource> { LocalSourceImpl(get(), this@getKoinModule) }
}

Let’s add a request to save images.
First, we’ll extend our recipes with a list of images that will show how delicious it is. Add a RecipeImage data class to your Entity.kt file

@Serializable
data class RecipeImage(
   val id: Long = 0, 
   val image: String
)

We can create recipes with images in two steps:
1) use the previously created Recipe entity to post a recipe to the backend and receive the id of newly created Recipe
2) add pictures to it.

But to receive recipes with images directly from the backend whenever we need them we’ll introduce a RecipeReponse entity:

 @Serializable
data class RecipeResponse(
   val id: Long = 0,
   val title: String,
   val ingredients: List<Ingredient>,
   val instructions: List<Instruction>,
   val images: List<RecipeImage>
)

Now we need to adjust our database. In a new image package create a RecipeImageTable: create recipe image table

 @Serializable
object RecipeImageTable : IntIdTable("recipeImages") { 
   val image = varchar("image", 1024)
   val recipe = reference(
       "recipe_id",
       RecipeTable,
       onDelete = ReferenceOption.CASCADE,
       onUpdate = ReferenceOption.CASCADE
   )
}

And a corresponding Entity:

class RecipeImageEntity(id: EntityID<Int>) : IntEntity(id) {
   companion object : IntEntityClass<RecipeImageEntity>(RecipeImageTable)
 
   var image by RecipeImageTable.image
   var recipe by RecipeEntity referencedOn RecipeImageTable.recipe
}

fun RecipeImageEntity.toRecipeImage() = RecipeImage(id.value.toLong(), image)

Finally, we need to adjust our RecipeEntity by adding a recipeImage property:

val recipeImages by RecipeImageEntity referrersOn RecipeImageTable.recipe

Add changing the converter function accordingly:

fun RecipeEntity.toRecipeResponse() = RecipeResponse(
   id.value.toLong(),
   title,
   ingredients.map { it.toIngredient() },
   instructions.map { it.toInstruction() },
   recipeImages.map { it.toRecipeImage() })

Let’s use our newly created entities.
In your LocalSource interface declare this function:

suspend fun saveImage(recipeId: Long, image: File)

Also change the return type of getRecipes and getRecipe:

suspend fun getRecipes() : List<RecipeResponse>
suspend fun getRecipe(recipeId: Long) : RecipeResponse

And in the LocalSourceImpl write the following implementations:

override suspend fun saveImage(recipeId: Long, image: File) {
   withContext(dispatcher) {
       val imageUrl = fileStorage.save(image) [1]       
           transaction {
           val recipe = RecipeEntity[recipeId.toInt()] [2]
           RecipeImageEntity.new { [3] 
               this.image = imageUrl
               this.recipe = recipe
           }
       }
   }
}

[1] Save the image in S3 storage and get the image url in return
[2] Get the recipe from the database that the image should be attributed to
[3] Create a new recipe image entity and save a reference to the recipe

And don’t forget to change the return type of getRecipes and getRecipe:

override suspend fun getRecipes(): List<RecipeResponse> = withContext(dispatcher) {
   transaction {
       RecipeEntity.all().map { it.toRecipeResponse() }
   }
}

override suspend fun getRecipe(recipeId: Long): RecipeResponse = withContext(dispatcher) {
   transaction {
       RecipeEntity[recipeId.toInt()].toRecipeResponse()
   }
}

Also, we have to create a new image recipe table in the LocalSourceImpl:

transaction {
   SchemaUtils.createMissingTablesAndColumns(
       RecipeTable,
       IngredientTable,
       InstructionTable,
       RecipeImageTable
   )
}

Finally, let’s add the routes to our api. Add this function to Routing:

addRecipeImage(localSource) 

And then implement it:

private fun Routing.addRecipeImage(localSource: LocalSource) {
   post("/recipes/{recipeId}/recipeImage") {
       val recipeId = call.parameters["recipeId"]!!.toLong()
 
       var image: File? = null

       call.receiveMultipart().forEachPart { [1]
           when (it) { 
               is PartData.FileItem -> image = it.toFile() [2]
               else -> Unit
           }
           it.dispose()
       }
       localSource.saveImage(
           recipeId, 
           image ?: throw BadRequestException("image part is missing")
       )
       image?.delete()[3]
       call.respond(HttpStatusCode.OK)
   }
}

[1] We receive the image file from the client side as a multipart form data
[2] This will help us transform part data to a file type so that we can save the image data in our database. It is unresolved for now and still has to be implemented in the next step
[3] If an error happens we throw an exception and do not save the image file

As mentioned in [2] we still need to create the toFile function that will help us transform the PartData.FileItem to a File. Create a utils package in our backend package and put an Extensions.kt file there with the following function

import io.ktor.http.content.*
import java.io.File
:
fun PartData.FileItem.toFile() = File(originalFileName!!).also { file ->
   streamProvider().use { input ->
       file.outputStream().buffered().use { output ->
           input.copyTo(output)
       }
   }
}

Now we can test uploading pictures to the recipes. First rebuild and make the project. In the terminal set local environment variables according to your AWS settings.

export bucketName=kmpizza
export secretKey=******
export accessKey=******
export region=eu-central-1
export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres

then run the backend locally as many times before:

java -jar ./backend/build/libs/Backend.jar -port=9000

Check with postman if you still have any recipes in your local database, otherwise add the pizza dough recipe again. If your database is fresh you get you recipeId: 1, otherwise choose the id of the recipe you want to add pictures to.

Now you can add an image to your recipe. Using that id:

http://localhost:9000/recipes/1/recipeImage

post image

We get status 200 OK in response. Let’s check if the recipe has an image now with getRecipes. Indeed in our recipe we see a list of images now:

 "images": [
            {
                "id": 1,
                "image": "https://kmpizza.s3.eu-central-1.amazonaws.com/kmpizza.jpg"
            }
        ]

Yay! 🥳
We’ve built our very functional backend to store delicious recipes with images. In the next step we’ll see how to publish our backend on Heroku automatically. Soon after that we’ll move on to using the backend we built within the shared code between iOS and Android.