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

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

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


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

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

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

Add permissions for S3 to this user

In stage 4 it should look like this:

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:

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
:

@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

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.