I’ve already explained to you how to access MongoDB with Kotlin. Now, we’ll go one step further and expose a MongoDB connection via REST. We’ll build a RESTful webservice to perform the basic CRUD operations on a MongoDB database.

To build our webservice, we’ll use the web framework Ktor from Jetbrains. The easiest way to setup a Ktor project is with the Ktor plugin in IntelliJ (you don’t need IntelliJ or the plugin to follow the example, but it’s the easiest way to develop with Ktor and Kotlin). The plugin is not installed by default, so install it first and then restart IntelliJ.

After installing the plugin, we can create a new project (via File->New->Project) and then select Ktor. Now we can check the features we want to have setup automatically by the project. We’ll only check the following Server features:

  • Static Content (not really needed, but I always include this in any project)
  • Status Pages
  • Routing
  • Jackson
  • ContentNegotiation

This will generate a Gradle project where we only need to additionally add the dependency to the Java MongoDB driver.

After generating all this, the file gradle.build looks like this:

buildscript {
    repositories {
        jcenter()
    }
    
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'kotlin'
apply plugin: 'application'

group 'mongodb-rest-example'
version '1.0.0'
mainClassName = "io.ktor.server.netty.EngineMain"

sourceSets {
    main.kotlin.srcDirs = main.java.srcDirs = ['src']
    test.kotlin.srcDirs = test.java.srcDirs = ['test']
    main.resources.srcDirs = ['resources']
    test.resources.srcDirs = ['testresources']
}

repositories {
    mavenLocal()
    jcenter()
    maven { url 'https://kotlin.bintray.com/ktor' }
}

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    compile "io.ktor:ktor-server-netty:$ktor_version"
    compile "ch.qos.logback:logback-classic:$logback_version"
    compile "io.ktor:ktor-server-core:$ktor_version"
    compile "io.ktor:ktor-server-host-common:$ktor_version"
    compile "io.ktor:ktor-jackson:$ktor_version"
    compile "org.mongodb:mongodb-driver:3.6.3"
    testCompile "io.ktor:ktor-server-tests:$ktor_version"
}

Next we create a package com.polyglotphil.data.mongo and create the file MongoDataService in it. Here we’ll setup a connection to a MongoDB instance:

package com.polyglotphil.data.mongo

import com.mongodb.MongoClient
import org.bson.BsonDocument
import org.bson.BsonObjectId
import org.bson.Document
import org.bson.json.JsonParseException
import org.bson.types.ObjectId

class MongoDataService(mongoClient: MongoClient, database: String) {
    private val database = mongoClient.getDatabase(database)
}

We create a new class that will have a MongoClient and a database name to connect to. With these two members, we create the privatefield database (of class com.mongodb.client.MongoDatabase) with which we’ll work within the class functions.
Note that I already added all the imports that we’ll use in this class, so that I don’t have to mention to do this explicitly every time we add a new class from the Mongo Driver.

The first method that we’ll write, is one to receive all documents from a collection:

fun allFromCollection(collection: String):
        ArrayList<Map<String, Any>> {
    val mongoResult =
        database.getCollection(collection, Document::class.java)
    val result = ArrayList<Map<String, Any>>()
    mongoResult.find()
        .forEach {
            val asMap: Map<String, Any> = mongoDocumentToMap(it)
            result.add(asMap)
        }
    return result
}

private fun mongoDocumentToMap(document: Document): Map<String, Any> {
    val asMap: MutableMap<String, Any> = document.toMutableMap()
    if (asMap.containsKey("_id")) {
        val id = asMap.getValue("_id")
        if (id is ObjectId) {
            asMap.set("_id", id.toHexString())
        }
    }
    return asMap
}

We do two things in the function

  1. Retrieve all documents using the MongoDriever
  2. Transform the result to make maps of every single document. For this we’ve written the helper function mongoDocumentToMap which not only transforms the document to a map, but also transform the _id to a String if it is of type ObjectId. I did it to not expose the ObjectId type to the webservice.

Now that we have our first function, let’s expose it via REST. For this, we’ll use the file Applikation.kt that the Ktor plugin created for us. It has following content, after we add our first route:

package com.polyglotphil

import com.fasterxml.jackson.databind.SerializationFeature
import com.mongodb.MongoClient
import com.mongodb.MongoClientOptions
import com.mongodb.MongoCredential
import com.mongodb.ServerAddress
import com.polyglotphil.data.mongo.MongoDataService
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.jackson.jackson
import io.ktor.request.receiveText
import io.ktor.response.respond
import io.ktor.response.respondText
import io.ktor.routing.*
import org.bson.types.ObjectId

fun main(args: Array<String>): Unit =
    io.ktor.server.netty.EngineMain.main(args)

private val mongoDataService = MongoDataService(
    MongoClient(),
    "dev"
)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT)
        }
    }

    routing {
        // Static feature. Try to access `/static/ktor_logo.svg`
        static("/static") {
            resources("static")
        }

        install(StatusPages) {
            exception<AuthenticationException> { cause ->
                call.respond(HttpStatusCode.Unauthorized)
            }
            exception<AuthorizationException> { cause ->
                call.respond(HttpStatusCode.Forbidden)
            }

        }

        route("/mongo/example") {
            get {
                call.respond(
                    mongoDataService.allFromCollection("col")
                )
            }
        }
    }
}

class AuthenticationException : RuntimeException()
class AuthorizationException : RuntimeException()

Of course, the “/static” route, if you don’t want it. I’ve just let it there as the “/static” route may be useful to you if you want to expose some static files (like css, favicon, js or html files).

Let’s have a closer look at the parts that we added:

  • We added a file local variable mongoDataService and initialized it with a MongoClient connecting to the default host and port (localhost:27017). If you want to change it e.g. to a different port with username/passwort authentication (like I did in my post where I built a Clojure MongoDB Webservice), you just initialize the MongoClient differently:
private val mongoDataService = MongoDataService(
    MongoClient(
        ServerAddress("localhost", 27018),
        MongoCredential.createCredential(
            "dev-admin",
            "dev",
            "S3cuRE!".toCharArray()
        ),
        MongoClientOptions.builder().build()
    ),
    "dev"
)
  • We added a route to /mongo/example which will return the result of calling mongoDataService.allFromCollection("col"), so we’ll retrieve all documents contained in the collection named col. If you haven’t put anything in yet, you will get an empty result. To test your webservice, run the application (either with gradle run or by executing the main function of the Applikation.kt file). When your webservice runs, you can test it in the browser or with Postman (https://localhost:8080).

Next, we’ll add the CREATE functionality by adding the following function to the MongoDataService:

fun saveNewDocument(collection: String, document: String): String {
    try {
        val bsonDocument = BsonDocument.parse(document)
        // we create the id ourselves
        bsonDocument.remove("_id")
        val oid = ObjectId()
        bsonDocument.put("_id", BsonObjectId(oid))
        database.getCollection(collection, BsonDocument::class.java)
            .insertOne(bsonDocument)
        return oid.toHexString()
    } catch (ex: JsonParseException) {
        return "Invalid JSON: ${ex.localizedMessage}"
    }
}

The function receives a JSON document and stores it in MongoDB. On purpose we remove the any _id property and replace it by one we generate ourselves. If everything goes well, we return the ObjectId of the object we inserted, else we return an error message.

In the Application.kt file, we add the following route, to expose our saveNewDocument function:

route("/mongo/example") {
    get {
        call.respond(
            mongoDataService.allFromCollection("col")
        )
    }

    post {
        val documentAsString = call.receiveText()
        val oidOrErrorMessage =
            mongoDataService.saveNewDocument("col", documentAsString)
        if (ObjectId.isValid(oidOrErrorMessage)) {
            call.respond(HttpStatusCode.Created, oidOrErrorMessage)
        } else {
            call.respond(HttpStatusCode.BadRequest, oidOrErrorMessage)
        }
    }
}

If the MongoDataService returns a valid ObjectId, we return HTTP status 201 - Created and the id of the new document in the body. If MongoDataService returns an error message, we return HTTP status 400 - Bad Request.

We can now use Postman to create a few documents and check if our webservice works.

One thing that is still missing, is the possibility to retrieve single documents by id. To achieve this, we need following code in the MongoDataService:

fun getDocumentById(collection: String, id: String?): Map<String, Any>? {
    if (!ObjectId.isValid(id)) {
        return null
    }
    val document = database.getCollection(collection)
                     .find(Document("_id", ObjectId(id)))
    if (document != null && document.first() != null) {
        return mongoDocumentToMap(document.first())
    }
    return null
}

We always return null if we don’t find any element with the corresponding id. Here we explicitly require that the _id property must be an ObjectId. This matches the behaviour we implemented when we add new documents.

Next, we’ll expose it via REST:

get("/{id}") {
    val id: String? = call.parameters["id"]
    val document = mongoDataService.getDocumentById("col", id)
    if (document != null) {
        call.respond(document)
    } else {
        call.respond(HttpStatusCode.NotFound)
    }
}

We introduced something new here: we used the notation {id} to bind the route parameter, which we can then access with call.parameters["id"].

The next operation to implement is the update operation. Here is the code for the MongoDataService:

fun updateExistingDocument(
    collection: String,
    id: String?,
    document: String
): Pair<Int, String> {
    try {
        if (!ObjectId.isValid(id)) {
            return Pair(0, "ID not found")
        }
        val bsonDocument = BsonDocument.parse(document)
        bsonDocument.remove("_id")
        val filter = BsonDocument("_id", BsonObjectId(ObjectId(id)))
        val updatedValues =
            database.getCollection(collection, BsonDocument::class.java)
                .replaceOne(filter, bsonDocument).modifiedCount
        if (updatedValues < 1) {
            return Pair(0, "ID not found")
        } else {
            return Pair(1, "success")
        }
    } catch (ex: JsonParseException) {
        return Pair(-1, "Invalid JSON: ${ex.localizedMessage}")
    }
}

Here we return two values: if the change was successful and the error message. We make the following use of them in the application routes:

patch("/{id}") {
    val id: String? = call.parameters["id"]
    val documentAsString = call.receiveText()
    val (updatedRecords, message) =
        mongoDataService.updateExistingDocument("col", id, documentAsString)
    when (updatedRecords) {
        -1 -> call.respond(HttpStatusCode.BadRequest, message)
        0 -> call.respond(HttpStatusCode.NotFound, message)
        1 -> call.respond(HttpStatusCode.NoContent)
    }
}

Finally, in a similar manner, we can implement the delete operation:

fun deleteDocument(collection: String, id: String?): Pair<Int, String> {
    if (!ObjectId.isValid(id)) {
        return Pair(0, "ID not found")
    }
    val filter = BsonDocument("_id", BsonObjectId(ObjectId(id)))
    val updatedValues = database.getCollection(collection)
        .deleteOne(filter).deletedCount
    if (updatedValues < 1) {
        return Pair(0, "ID not found")
    } else {
        return Pair(1, "success")
    }
}
delete("/{id}") {
    val id: String? = call.parameters["id"]
    val (updatedRecords, message) =
        mongoDataService.deleteDocument("col", id)
    when (updatedRecords) {
        0 -> call.respond(HttpStatusCode.NotFound, message)
        1 -> call.respond(HttpStatusCode.NoContent)
    }
}

Now we have a fully running webservice to insert and update arbitrary JSON values. What we didn’t do here, is validate the input if you want to enforce some schema on your data. We also didn’t add any code, if you want to serialize and deserialize custom Java objects. These points are out of scope for this post, but you should keep them in mind, when you implement a webservice to expose a MongoDB instance.