Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building reliable web services with Kotlin Ktor

Julien Salvi
December 02, 2021

Building reliable web services with Kotlin Ktor

Through this session, you will learn how to setup a Kotlin Ktor project with some routes using the framework tools and features (Routing, DataConversion, ContentNegotiation...) and communicate with a sample Android app.

Let see how we can easily setup a Postgres database connection with Exposed, an ORM library for Kotlin and see how clean architecture can be a good choice for developing your brand new API.

We can even go further and see that we can actually share code from your backend application and your Android application (say hi to Swagger!).

Julien Salvi

December 02, 2021
Tweet

More Decks by Julien Salvi

Other Decks in Programming

Transcript

  1. Building reliable web services with Kotlin Ktor Ktor for backend

    development Julien Salvi (Come to the kotlin side we have cookies) @KotlinKoders
  2. Table of Contents Introduction Setup Ktor project First steps with

    Ktor A Ktor overview Build your web service Share code thanks to Swagger A source of truth for your API Ktor features, Postgres, Exposed… 01 02 03 04
  3. A ktor overview • Developed and maintained by JetBrains •

    Stable release in 2018 • Latest release 1.6.6 and 2.0.0 in EAP • Asynchronous framework for building web services, web apps and more written in Kotlin
  4. A ktor overview • Ktor is lightweight. Use the features

    you want with easy configuration • Ktor is extensible. Configurable pipeline where you can extend what your need • Ktor is multiplatform. Use it on mobile, for backends or web apps • Ktor is asynchronous. Thanks to Kotlin coroutines we can build high scalable microservices
  5. Let’s build a tiny web service • Setup default routes

    • Use Ktor Features • Setup a Postgres database that runs with docker-compose • Use Exposed for dealing with the database, Valiktor for data validation • Use Swagger as a single source of truth
  6. Postgres HTTPS requests (POST, GET, DELETE…): https://api.swcook.io/recipes https://api.swcook.io/recipes/10 https://api.swcook.io/ingredients {

    "position": 1, "description": "Cut the mushrooms in thin slices", "prep_time": 5 } Shared code CookApp
  7. Features and libraries Features & libraries Purpose Locations Routing ContentNegotiation

    Negotiating media types and serialization StatusPage Handle exceptions DataConversion Type conversion Logback Logging Koin Dependency injection Exposed ORM framework to deal with the Postgres database Valiktor Data validation Flyway Database versioning Moshi JSON library HikariCP JDBC connection pool
  8. Locations • Create routes that are type-safe • Easy configuration

    • Support basic types (Int, String, Boolean…) by default • Typed methods for defining route handlers: get, post, patch, delete… implementation("io.ktor:ktor-locations:1.6.6") Feature: Locations
  9. Routing with Locations object Routes { @Location("/recipes") class Recipes {

    @Location("/{uid}") data class ByUid(val recipes: Recipes, val uid: UUID) { @Location("/ingredients") data class Ingredients(val app: ByUid) { @Location("/{ingredientUid}") data class ByUid(val ingredients: Ingredients, val ingredientUid: UUID) } } } @Location("/recipes/{uid}/ingredients/{ingredientUid}") class RecipeIngredientDetails(val uid: UUID, val ingredientUid: UUID) }
  10. Routing with Locations fun Application.module(testing: Boolean = false) { install(Locations)

    routing { recipes() } } // RecipeController.kt fun Route.recipes() { get<Routes.Recipes> { } post<Routes.Recipes> { } patch<Routes.Recipes.ByUid> { route -> } delete<Routes.Recipes.ByUid> { route -> } }
  11. Routing with Locations // GET request with parameters get<Routes.Recipes> {

    val page = call.parameters["page"].toInt() val size = call.parameters["size"].toInt() ... call.respond(HttpStatusCode.OK, response) } // POST request with body post<Routes.Recipes> { val request = call.receive<PostRecipeRequest>() } // POST request with multipart data (upload files for example) post<Routes.Recipes.ByUid.Backdrop> { route -> val uid = route.uid val multipart = call.receiveMultipart() }
  12. Data Conversion • Serialize and deserialize a list of values

    (Date, UUID…) • Can be combined with Locations to support custom types • Simple configuration • Part of the core features Feature: DataConversion
  13. DataConversion fun DataConversion.Configuration.converters() { convert<UUID> { decode { values, _

    -> values.singleOrNull()?.let { value -> UUID.fromString(value) } } encode { value -> when (value) { null -> emptyList() is UUID -> listOf(value.toString()) else -> throw DataConversionException("Cannot convert $value as UUID") } } } }
  14. Moshi • JSON library for made by Square • Deserialize

    data received from requests and serialize responses • Lightweight, no HTML escaping, custom type adapters • Used by Swagger gradle plugin 👀 implementation("com.squareup.moshi:moshi:1.12.0") square/moshi
  15. Valiktor • Data validation for requests • Type-safe DSL to

    validate objects • Supports suspending functions natively • Multiple extensions (Spring, Joda… ) implementation("org.valiktor:valiktor-core:0.12.0") Github: Valiktor
  16. Valiktor """ { "position": 1, "description": "Cut the mushrooms in

    thin slices", "prep_time": 5 } """ @JsonClass(generateAdapter = true) data class PostStepRequest( @Json(name = "position") @field:Json(name = "position") var position: Long, @Json(name = "description") @field:Json(name = "description") var description: String, @Json(name = "prep_time") @field:Json(name = "prep_time") var prepTime: Long )
  17. Valiktor post<Routes.Recipes> { val request = call.receive<PostRecipeRequest>() request.validate() val created

    = recipeService.add(request.recipe.toEntity()) if (created != null) { val response = PostRecipeResponse(recipe = created.renderer()) call.respond(HttpStatusCode.Created, response) } else { call.respond(HttpStatusCode.Conflict) } }
  18. Error Handling: Status Page • Properly handle errors and exceptions

    in the application • Three configurations: exceptions, status and statusFile • Simple configuration • Part of the core features Feature: StatusPage
  19. StatusPage fun StatusPages.Configuration.exceptions() { exception<ConstraintViolationException> { exception -> // Basic

    ConstraintViolationException handler val violations = exception.constraintViolations.map { violation -> "${violation.property}:${violation.constraint.name}" } call.respondText(status = HttpStatusCode.UnprocessableEntity) { violations.toString() } } exception<Throwable> { exception -> application.log.error("Unhandled exception", exception) call.respond(HttpStatusCode.InternalServerError) } }
  20. PostgreSQL database with Docker Compose • Persistent data with Postgres

    database • Run PostgreSQL database and pgadmin on a Docker container thanks to Docker Compose • Compose is a tool to run multi-container Docker apps • Use a YAML file to configure services
  21. Docker Compose version: "3.7" services: postgres: image: postgres:12-alpine restart: always

    environment: POSTGRES_DB: postgres POSTGRES_USER: admin POSTGRES_PASSWORD: admin PGDATA: /var/lib/postgresql/data volumes: - postgres-data:/var/lib/postgresql/data ports: - "5432:5432" volumes: postgres-data:
  22. Docker Compose # Run/stop docker compose docker-compose up / down

    # Check volumes docker volume ls # Remove volume docker volume rm <volume>
  23. Setup database connection # In your application.conf database { connection

    { jdbc = "jdbc:postgresql://localhost:5432/postgres" jdbc = ${?DATABASE_JDBC} user = admin user = ${?DATABASE_USER} password = admin password = ${?DATABASE_PASSWORD} } }
  24. Setup database connection private val appConfig: ApplicationConfig = HoconApplicationConfig(ConfigFactory.load()) lateinit

    var dataSource: DataSource val dbConfig = appConfig.config("ktor.database") val config = HikariConfig() config.jdbcUrl = dbConfig.property("connection.jdbc").getString() config.username = dbConfig.property("connection.user").getString() config.password = dbConfig.property("connection.password").getString() config.isAutoCommit = false config.maximumPoolSize = 3 config.transactionIsolation = "TRANSACTION_REPEATABLE_READ" config.validate() dataSource = HikariDataSource(config) Database.connect(dataSource) // Exposed Database
  25. Exposed • ORM framework for Kotlin by JetBrains • Supports

    PostgreSQL, MySQL, H2, MariaDB, SQLite… + DataSource JDBC connection with HikariCP • DSL or DAO API for querying the database • Supports basic and advanced CRUD operations Github: Exposed
  26. Setup Tables and DAO object RecipeTable : ExtendedUUIDTable(name = "recipe")

    { val title: Column<String> = text(name = "title") val description: Column<String> = text(name = "description") val cookingTime: Column<Int> = integer(name = "cooking_time").default(0) } class Recipe(id: EntityID<UUID>) : ExtendedUUIDEntity(id, RecipeTable) { companion object : ExtendedUUIDEntityClass<Recipe>(RecipeTable) var title by RecipeTable.title var description by RecipeTable.description var cookingTime by RecipeTable.cookingTime val steps by Step referrersOn StepTable.recipe var ingredients by Ingredient via RecipeIngredientTable }
  27. CRUD Operations // CREATE val recipe = Recipe.new { title

    = recipe.title description = recipe.description cookingTime = recipe.cookingTime } // READ val recipe = Recipe.findById(id) // Ingredient.find { IngredientTable.id eq id }.first() // UPDATE val recipe = Recipe.findById(id) recipe?.title = title // DELETE val recipe = Recipe.findById(id) recipe?.delete()
  28. Transactions // Basic transaction val ingredient = transaction { createIngredient(entity)

    } // Coroutine support with suspend functions val ingredient = newSuspendedTransaction { Ingredient.findById(id)?.toEntity() } // Deferred transaction suspendedTransactionAsync(Dispatchers.IO) { Recipe.findById(recipeId)?.ingredients?.map(Ingredient::toEntity) }
  29. Referencing // Many-to-one object StepTable : ExtendedUUIDTable(name = "step") {

    ... val recipe = reference("recipe", RecipeTable) val recipe = reference("video", VideoTable).nullable() } class Step(id: EntityID<UUID>) : ExtendedUUIDEntity(id, StepTable) { ... var recipe by Recipe referencedOn StepTable.recipe var video by Video optionalReferencedOn StepTable.video } class Recipe(id: EntityID<UUID>) : ExtendedUUIDEntity(id, RecipeTable) { ... val steps by Step referrersOn StepTable.recipe }
  30. Referencing // Many-to-many object RecipeIngredientTable : Table() { val recipe

    = reference(RecipeTable.tableName, RecipeTable) val ingredient = reference(IngredientTable.tableName, IngredientTable) override val primaryKey = PrimaryKey(recipe, ingredient) } class Recipe(id: EntityID<UUID>) : ExtendedUUIDEntity(id, RecipeTable) { ... var ingredients by Ingredient via RecipeIngredientTable } class Ingredient(id: EntityID<UUID>) : ExtendedUUIDEntity(id, IngredientTable) { ... var recipes by Recipe via RecipeIngredientTable }
  31. Swagger Gradle Codegen • Gradle plugin to generate network code

    from Swagger file by Nicola Corti • Swagger relies on OpenAPI for RESTful web services, supports YAML or JSON formats • Generates Retrofit interfaces, uses Moshi for serialization and supports Kotlin coroutines • Having a single source of truth Yelp/swagger-gradle-codegen
  32. Swagger module swagger-api/ ├── .build/ │ └── com/example/swcook/front │ ├──

    apis/ │ ├── models/ │ └── tools ├── api-client.yml └── build.gradle.kts
  33. Swagger module swagger-api/ ├── .build/ │ └── com/example/swcook/front │ ├──

    apis/ │ ├── models/ │ └── tools ├── api-client.yml └── build.gradle.kts
  34. Swagger: gradle build plugins { id("com.yelp.codegen.plugin") version "1.4.1" } generateSwagger

    { platform = "kotlin-coroutines" packageName = "com.example.swcook.front" inputFile = file("./api-client.yml") outputDir = file("./build/") }
  35. Swagger: define paths paths: /recipes/{recipe_id}: get: tags: - recipes summary:

    Get recipe details operationId: "getRecipe" parameters: - name: recipe_id in: path required: true type: string format: uuid responses: '200': description: All recipes details schema: $ref: '#/definitions/Recipe'
  36. Swagger: define models definitions: Recipe: type: object required: - id

    - title - cooking_time properties: id: type: string format: uuid title: type: string description: type: string cooking_time: type: integer format: int32 date: type: string format: date-time steps: type: array items: $ref: '#/definitions/Step' ingredients: type: array items: $ref: '#/definitions/Ingredient'
  37. Swagger: retrofit & models @JvmSuppressWildcards interface RecipesApi { /** *

    Get recipe details * @param recipeId (required) */ @Headers("Content-Type: application/json") @GET("/recipes/{recipe_id}") suspend fun getRecipe( @retrofit2.http.Path("recipe_id") recipeId: UUID ): Recipe }
  38. Swagger: retrofit & models @JsonClass(generateAdapter = true) data class Recipe(

    @Json(name = "id") @field:Json(name = "id") var id: UUID, @Json(name = "title") @field:Json(name = "title") var title: String, @Json(name = "description") @field:Json(name = "description") var description: String, @Json(name = "cooking_time") @field:Json(name = "cooking_time") var cookingTime: Int, @Json(name = "date") @field:Json(name = "date") var date: ZonedDateTime? = null, @Json(name = "steps") @field:Json(name = "steps") var steps: List<Step>? = null, @Json(name = "ing") @field:Json(name = "ing") var ing: List<Ingredient>? = null, )
  39. Swagger Gradle Codegen • Front models for your backend and

    remote models for your Android app will be the same • No more object type surprise! • Strong contract between clients and APIs • Improvements: kotlinx.serialization support, date type config… Yelp/swagger-gradle-codegen
  40. CREDITS: This presentation template was created by Slidesgo, including icons

    by Flaticon, infographics & images by Freepik Thanks! Do you have any questions? Have fun with Ktor Julien Salvi @KotlinKoders