Ktor (pronounced kay-tor) is an open source Kotlin framework for building asynchronous web applications. This post shows how to create a small RESTful CRUD service with Ktor.
Getting started
In this example we use Maven as build tool. Besides standard Kotlin dependencies we need to add the Ktor dependencies to our pom.xml:
<project> <properties> ... <ktor.version>1.3.2</ktor.version> </properties> <repositories> <repository> <id>jcenter</id> <url>https://jcenter.bintray.com</url> </repository> </repositories> <dependencies> <dependency> <groupId>io.ktor</groupId> <artifactId>ktor-server-core</artifactId> <version>${ktor.version}</version> </dependency> <dependency> <groupId>io.ktor</groupId> <artifactId>ktor-server-netty</artifactId> <version>${ktor.version}</version> </dependency> <dependency> <groupId>io.ktor</groupId> <artifactId>ktor-gson</artifactId> <version>${ktor.version}</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.3</version> </dependency> </dependencies> ... </project>
You can find the full pom.xml (including standard Kotlin dependencies and plugins) on GitHub.
For building a server application we need ktor-server-core. We use Netty as web server and therefore add ktor-server-netty. To work with JSON requests/responses we use Ktor's GSON integration with ktor-gson. Ktor uses SLF4J for logging and needs a SLF4J provider, we use logback-classic.
To see if Ktor is ready, we can run the following piece of code:
fun main() { embeddedServer(Netty, 8080) { routing { get("/foo") { call.respondText("Hey", ContentType.Text.Html) } } }.start(wait = true) }
This defines a single endpoint that listens for GET requests on /foo. Running the main method starts an embedded Netty server listening on port 8080. We can now open http://localhost:8080/foo in a web browser and should see the text Hey as response.
Configuring JSON handling
Next we need to set up JSON handling in Ktor. We do this by adding the ContentNegotiation feature with GSON converters to our Ktor application:
fun main() { embeddedServer(Netty, 8080) { install(ContentNegotiation) { gson { setPrettyPrinting() } } routing { ... } }.start(wait = true) }
With the install() method we can add a feature to our application. Features can provide additional functionality which can be added to the request and response processing pipeline. The ContentNegotiation feature provides automatic content conversion based on Accept and Content-Type headers. The conversion is handled by GSON.
This way we can return a Kotlin object from an endpoint and the content conversion will be handled by Ktor. If a client accepts JSON (Accept header with value application/json) Ktor will use GSON to convert the Kotlin object to JSON before it is sent to the client.
Implementing get operations
In this example we create a simple CRUD API for products. For this we use two small helper classes: Product and Storage.
Product is a basic product representation containing a name, a description and an id:
class Product( val id: String, val name: String, val description: String )
Storage acts as our simplified product database, it uses a MutableMap to save products:
class Storage { private val products = mutableMapOf<String, Product>() fun save(product: Product) { products[product.id] = product } fun getAll() = products.values.toList() fun get(id: String) = products[id] fun delete(id: String) = products.remove(id) }
With these two classes we can now start to create our Ktor endpoints. Of course we can just add more routes to the main method we used above. However, we typically want to separate different types of routes into different files. Therefore, we create our own Route extension function, named product() in a separate file:
fun Route.product(storage: Storage) { route("/products") { // GET /products get { call.respond(storage.getAll()) } // GET /products/{id} get("/{id}") { val id = call.parameters["id"]!! val product = storage.get(id) if (product != null) { call.respond(product) } else { call.respond(HttpStatusCode.NotFound) } } } }
This defines two GET operations below the /products route. One to obtain all products and one to obtain a single product by id. In the single product endpoint we use call.parameters["id"] to obtain the product id from the request URI. With call.respond() we can define the response we want to send to the client.
Next we have to create our product storage and add our routes to the server. We do this by calling our Route extension function within the routing section of our server:
fun main() { embeddedServer(Netty, 8080) { install(ContentNegotiation) { gson { setPrettyPrinting() } } routing { val productStorage = Storage() // add some test data productStorage.save(Product("123", "My product", "A nice description")) productStorage.save(Product("456", "My other product", "A better description")) // add product routes product(productStorage) } }.start(wait = true) }
We can now run this application and send a GET request to http://localhost:8080/products. We should get a JSON array containing two products as response:
Request:
GET http://localhost:8080/products
Response:
[ { "id": "123", "name": "My product", "description": "A nice description" }, { "id": "456", "name": "My other product", "description": "A better description" } ]
Implementing create and update operations
To create a new product, we use the POST operation on /products. Once a product has been created, we respond with HTTP 201 (Created) and a Location header which points to the newly created product. Our Ktor code looks like this:
fun Route.product(storage: Storage) { route("/products") { ... // POST /products post { val data = call.receive<Product>() val product = Product( id = UUID.randomUUID().toString(), name = data.name, description = data.description ) storage.save(product) call.response.headers.append("Location", "http://localhost:8080/products/${product.id}") call.respond(HttpStatusCode.Created) } } }
With call.receive() we can convert the request body JSON to a Product object. Note that we use the data object from call.receive() to create another product object that is then passed to storage.save(). If we would pass data directly to storage.save() it would be possible for the client to define the product id. Like the other fields, id will be mapped from JSON into the Product object with call.receive(). However, we want to make sure we get a new server generated product id. An alternative solution would be to create a specific transfer object that does not contain the id field.
With call.response.headers.append() we add Location header to the response.
The following snippets show an example for a possible request/response combination:
Request:
POST http://localhost:8080/products Content-Type: application/json { "name": "New product", "description": "This product is new" }
Response:
201 (Created) Location: http://localhost:8080/products/70f01c0f-0a6f-4227-a064-02383590a5ab
A product is updated by sending a PUT request to /products/{id}. The Ktor endpoint looks like this:
fun Route.product(storage: Storage) { route("/products") { ... // PUT /products/{id} put("/{id}") { val id = call.parameters["id"]!! val product = storage.get(id) if (product != null) { val data = call.receive<Product>() product.name = data.name product.description = data.description storage.save(product) } else { call.respond(HttpStatusCode.NotFound) } } } }
Again we use call.parameters and call.receive() to get the product id and the content from the request body. We check if a product with the given id is available. If this is the case we update the product otherwise HTTP 404 (Not Found) is send to the client.
Deleting products
We delete products by sending a DELETE request to /products/{id}. The endpoint implementation does not use any new Ktor features, but for sake of completeness here is it:
fun Route.product(storage: Storage) { route("/products") { ... // DELETE /products/{id} delete("/{id}") { val id = call.parameters["id"]!! val product = storage.delete(id) if (product != null) { call.respond(HttpStatusCode.OK) } else { call.respond(HttpStatusCode.NotFound) } } } }
Conclusion
Ktor provides an easy way to build a REST API with Kotlin. It uses Kotlin Lambdas to define routes and endpoints. We learned how to access request information, send JSON responses, set response headers and a few other things by building this small CRUD example service. You can find the example code on GitHub.
Leave a reply