In this article, we are going to learn how to build a server with the Ktor framework. This article will cover how to create a Ktor project using IntelliJ Ultimate and without it, build a basic server that returns a "Hello World" as a response, how to serve static files, and finally, an HTTP API that performs CRUD operations.
Ktor
Ktor is an asynchronous framework for creating microservices, web applications and more. Written in Kotlin from the ground up. More about it here.
Requirements
Java SDK installed
Gradle installed (Download it from here, install it and add it to the PATH environment)
How to create a Ktor project with IntelliJ Ultimate
We open IntelliJ Ultimate, and select "New Project".
Then, we select "Ktor" in the left panel, write a name for the project, choose a "Location" and "Build system" if we want, or we can leave the default values, and click on "Next".
We select the plugins we are going to use, for start this example, we will use "Routing" and "Call Logging". After we select the plugins, we click on "Create".
IntelliJ creates the project and generates the plugins files we are going to use, and the Application.kt
file.
We open the Application.kt
file, and click on the "Play" button. There are two buttons, anyone works.
The building process will start.
Once is completed, we will have the app running.
If we navigate to localhost:8080
, we should see the following message.
How to create a Ktor with another code editor.
To create a new Ktor project, we use the Ktor project generator on this web page.
Then, we click on "Add plugins", and select "Routing" and "Call Logging".
We click on "Generate project" and a .zip folder will be downloaded to our machine.
We unzip it and open the folder with a code editor. For this example, I will use VS Code.
We execute the gradle run
command in the root folder.
After the building is completed, we will see this output in the command line:
If we navigate to localhost:8080, we should see the following message:
Ktor server
Src/main/kotlin Folder
In the src/main/kotlin
folder we have the Application.kt
file and the plugins folder which contains the Routing.kt
and Monitoring.kt
files.
Application.kt
package com.example
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.example.plugins.*
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureMonitoring()
configureRouting()
}
The Application.kt
file is the main entry point for a Ktor application.
The main()
function in the Application.kt
file is responsible for starting the Ktor server. The module()
function is responsible for configuring the application and loading the plugins. In the code snippet above, configureMonitoring()
install the plugin responsible for collecting the metrics of the app. Also, this file is where we define the port and host from where our application should start listening.
Routing.kt
package com.example.plugins
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*
fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
The routing()
function in the Routing.kt
file is responsible for defining routes. The routing()
function takes a block of code as its argument. The code in the block of code defines the routes for the application.
The routes are defined using the get()
, post()
, put()
, delete()
, and head()
functions. These functions take a path as their argument and a handler as their return value.
The handler is the function that will be called when a request is made to the specified path. Inside the handler, we can get access to the ApplicationCall, which handles client requests and send the defined response.
In the code snippet above the configureRouting()
function installs the StatusPages plugin and configures it to respond to all Throwable exceptions with a 500 Internal Server Error response.
The routing()
function defines a route that responds to GET requests to the /
path. Then, the handler for this route simply responds with the text "Hello World!".
The StatusPages
plugin allows you to customize the way that Ktor responds to errors. The exception handler allows you to handle calls that result in a Throwable exception. In the code above it will throw the 500 HTTP status code for any exception.
Installing the StatusPages plugin in the configureRouting()
function allows this plugin to be applied to all routes in the application.
For more information about the Routing
and StatusPages
plugins, you can consult the documentation here.
Serving Static Files
We are going to serve an HTML file. First, we create a folder in the root directory, called "static" with an HTML file in there.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Index File</title>
</head>
<body>
<h1>Hello, this is a HTML file</h1>
</body>
</html>
Then, we go to Routing.kt
and add the following code in the Application.configureRouting()
function.
routing {
staticFiles("/static", File("static"), index="index.html")
...
}
This maps /static
to the folder "static" and serve the index.html
file as default.
package com.example.plugins
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.routing.get
import java.io.File
fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
routing {
staticFiles("/static", File("static"), index="index.html")
get("/") {
call.respondText("Hello World!")
}
}
}
In Routing.kt
, we import io.ktor.server.http.content.*
, io.ktor.server.routing.get
, java.io
.File
and use the staticFiles
function to define the route that serves the file, the folder and the file that is served as default.
We run the server and navigate to localhost:8080/static
. We should receive the following response:
Also, if we don't want to define a default file, we just keep the staticFile
function like this:
routing {
staticFiles("/static", File("static"))
As the documentation says, Ktor recursively serves up any file from static
as long as the URL path and the filename match.
For example, if we have more than one file in the folder "static", and the filename of one of them is hello.html
, to serve it, the URL path would be /static/hello.html
.
HTTP API
Adding serialization plugin
For the HTTP API, we need to install the serialization plugin.
We open build.gradle.kts
file and add the following dependency:
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
Also, we have to declare the plugin in the same file.
plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "1.8.0"
}
Complete build.gradle.kts
.
val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project
plugins {
kotlin("jvm") version "1.9.0"
id("io.ktor.plugin") version "2.3.2"
}
group = "com.example"
version = "0.0.1"
application {
mainClass.set("com.example.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
repositories {
mavenCentral()
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version")
implementation("io.ktor:ktor-server-status-pages-jvm:$ktor_version")
implementation("io.ktor:ktor-server-call-logging-jvm:$ktor_version")
implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
Application.kt
We have to declare a function inside the Application.module()
function that loads the serialization plugin.
package com.example
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.example.plugins.*
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureMonitoring()
configureRouting()
configureSerialization()
}
In the code above, we add configureSerialization()
, which is responsible for loading the serialization plugin. But, we have not define that function yet.
Now, we create the Serialization.kt
file inside the plugins folder.
Serialization.kt
package com.example.plugins
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}
In this file, we define the configureSerialization()
function, responsible for installing the ContentNegotiation plugin.
Users.kt
We create the models package inside the src/main/kotlin/com/example
folder.
package com.example.models
import kotlinx.serialization.Serializable
@Serializable
data class User(val id: String, val firstName: String, val lastName: String, val email: String)
In the code above, we import the kotlinx.serialization.Serializable
library and, we define a data class called User
. The User
class has four properties: id
, firstName
, lastName
, email
.
The @Serializable
annotation tells the Kotlin compiler to generate code that can be used to serialize and deserialize User
objects.
For this example app, we are not going to use a database.
In the same file, we declare a mutable list of User
objects called userStorage
. The mutableListOf
function creates a new list that can be modified.
val userStorage = mutableListOf<User>()
The User
objects in the list can be added, removed, or changed at any time. This way we will be able to have CRUD functionality.
We create a package named routes
, inside the src/main/kotlin/com/example
folder.
Then, inside the routes
package, we create the UserRouter.kt
file.
UserRouter.kt
package com.example.routes
import com.example.models.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.userRouting() {
route("/user") {
get {
}
get("{id}") {
}
post {
}
put("{id}") {
}
delete("{id}") {
}
}
}
In this file, we declare the userRouting
function, in which we group all the routes for the /user
endpoint.
GET route
package com.example.routes
import com.example.models.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.userRouting() {
route("/user") {
get {
if (userStorage.isNotEmpty()) {
call.respond(userStorage)
} else {
call.respondText ("No users found", status = HttpStatusCode.OK)
}
}
...
}
}
Now, we added a handler for the get
route to see if there are any users in the userStorage
list. If there are no users in the list, the route will respond with a 200 OK
status code and the text "No users found".
Before we start the server to try this functionality, we have to go Routing.kt
file and declare the userRouting()
function inside the configureRouting()
function.
Routing.kt
...
import com.example.routes.*
fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
routing {
staticFiles("/static", File("static"))
get("/") {
call.respondText("Hello World!")
}
userRouting()
}
}
Now we can start the server and navigate to localhost:8080/user
. We should receive the following response:
We received that message because so far there is no data in the userStorage
.
Let's add a handler to create a user.
POST route
post {
val customer = call.receive<User>()
customerStorage.add(user)
call.respondText("User stored correctly", status = HttpStatusCode.Created)
}
We run the server. And use an HTTP client to make a POST request.
If we navigate to localhost:8080/user
or make a GET request with an HTTP client, we should see the following response.
GET ID route
get("{id}") {
val id = call.parameters["id"] ?: return@get call.respondText(
"Missing id",
status = HttpStatusCode.BadRequest
)
val user =
userStorage.find { it.id == id } ?: return@get call.respondText(
"No user with id $id",
status = HttpStatusCode.NotFound
)
call.respond(user)
}
This handler retrieves the user with the specified ID from the userStorage
list and returns it to the client. If the user does not exist, the handler returns a 404 Not Found
error.
The handler first uses the call.parameters
function to get the value of the id
parameter from the request. If the id
parameter is not present, the handler returns a 400 Bad Request
error.
Next, the handler uses the userStorage.find
function to find the user with the specified ID in the userStorage
list. If the user is not found, the handler returns a 404 Not Found
error.
If the user is found, the handler returns the user to the client. The respond
function sends a response to the client.
PUT route
put("{id}") {
val id = call.parameters["id"] ?: return@put call.respondText(
"No id provided",
status = HttpStatusCode.BadRequest
)
val user =
userStorage.find { it.id == id } ?: return@put call.respondText(
"No user with this id: $id",
status = HttpStatusCode.NotFound
)
val newData = call.receive<User>()
val indexUser = userStorage.indexOf(user)
userStorage[indexUser] = newData
call.respondText("User updated", status = HttpStatusCode.OK)
}
Now, we implement the option that allows the clients to updates an element in the userStorage
. This handler updates the user with the specified ID with the data from the request body. If the user does not exist, the handler returns a 404 Not Found
error.
The handler first uses the call.parameters
function to get the value of the id
parameter from the request. If the id
parameter is not present, the handler returns a 400 Bad Request
error.
Next, the handler uses the userStorage.find
function to find the user with the specified ID in the userStorage
list. If the user is not found, the handler returns a 404 Not Found
error.
If the user is found, the handler uses the call.receive<User>
function to read the data from the request body and create a new User
object. The new User
object is then used to update the existing user in the userStorage
list. The index of the existing user in the list is used to ensure that the correct user is updated.
Finally, the handler returns a 200 OK
status code and the text "User updated".
DELETE route
delete("{id}") {
val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.BadRequest)
if (userStorage.removeIf{ it.id == id}) {
call.respondText("User removed correctly", status = HttpStatusCode.Accepted)
} else {
call.respondText("User Not Found", status = HttpStatusCode.NotFound)
}
}
In the DELETE route, with a specified ID, this handler deletes a user from the userStorage
list. If the user does not exist, the handler returns a 404 Not Found
error.
Similar to the GET and PUT handlers, the delete handler first uses the call.parameters
function to get the value of the id
parameter from the request. If the id
parameter is not present, the handler returns a 400 Bad Request
error.
Next, the handler uses the userStorage.removeIf
function to remove the user with the specified ID from the userStorage
list. If the user is not found, the handler returns a 404 Not Found
error.
Finally, the handler returns a 202 Accepted
status code if the user was deleted successfully, or a 404 Not Found
status code if the user was not found.
Monitoring Plugin
When we create this project, we select the Call logging plugin. With this plugin, we can see in the console every request made to the server.
package com.example.plugins
import io.ktor.server.plugins.callloging.*
import org.slf4j.event.*
import io.ktor.server.request.*
import io.ktor.server.application.*
fun Application.configureMonitoring() {
install(CallLogging) {
level = Level.INFO
filter { call -> call.request.path().startsWith("/") }
}
}
The CallLogging
plugin logs information about every incoming request, including the request method, path, headers, and body. The configureMonitoring
function configures the CallLogging
plugin with the following settings:
The log level is set to
INFO
. This means that only informational messages will be logged.The filter function is used to only log requests that start with the "/" path.
Conclusion
In this article, we have learned how to build a server with Ktor that serves static files and performs CRUD operations through an HTTP API. We started by creating a new project and adding plugins. We also learned how to use Ktor's routing features to create an endpoint.
In my opinion, Ktor has amazing documentation. I don't have any experience using Kotlin, but it was easy to learn, and it allows me to learn quickly how to build a server.
Thank you for taking the time to read this article.
If you have any recommendations about other packages, architectures, how to improve my code, my English, or anything; please leave a comment or contact me through Twitter, or LinkedIn.
The source code is here.
Top comments (0)