With a javascript web app, we generally need to get, put, post, or delete data from a REST api. But how Batman?

Disclosure

I based this off the kotlin conf fullstack app.

Which in turn is using XMLHttpRequest from javascript.

Kotlin Serializer

This will be utilizing Kotlinx Serialization heavily. The common construct I've been doing. Is that the response and objects we push are in a common module. Annotated with the serialization library.

Data Class Models

I'm going to stub out several data class models. These are just meant to demonstrate what's goin on. In this example I will be creating a simple Pet record, returning a list of pet objects.

These data classes usually live under the common module for your project.

Stub Data Classes

import kotlinx.serialization.Serializable

@Serializable
enum class PetType {
    DOG,
    CAT,
    FISH,
    LIZARD,
    TIGER,
    PANDA,
    NARWHAL
}

@Serializable
enum class PetGender {
    MALE,
    FEMALE,
    OTHER
}

@Serializable
data class Pet(
        val type: String,
        val gender: String,
        val age: Number
)

@Serializable
data class PetResponse(
        val error: Boolean,
        val data: List<String>
)

Why a $XResponse

It does make much more sense to do something like.

@Serializable
data class RestResponse<R> (
        val error: Boolean,
        val data: List<R>
)

However I was encountering issues parsing the json into an object with generics. I'm still working on that but for now this works.

Primer on Reified

Basically with type erasure of the JVM. We need the type information available at runtime. This allows that to happen. It must be used with inline. Which copies that code to every place it's used. Ergo it can be an expensive option. But it copies the function with the explicit type information. Meaning it's available at runtime. This is important in our context.

Common Response Handling

In Javascript this is an asynchronous call. So we will need something to resume the coroutine on completion and appropiately handle it.

fun statusHandler(xhr: XMLHttpRequest, coroutineContext: Continuation<String>) {
    if (xhr.readyState == XMLHttpRequest.DONE) {
        if (xhr.status / 100 == 2) {
            coroutineContext.resume(xhr.response as String)
        } else {
            coroutineContext.resumeWithException(RuntimeException("HTTP error: ${xhr.status}"))
        }
    } else {
        null
    }
}

So if this anything in the 200 branch of response status codes. It's returning that to the coroutine once it finished running. Otherwise it raises an exception.

HTTP Verbs

There is a lot of similarity in logic between these layers. Some only differ on the verb specification. An example is POST and PUT. So we can write a wrapper function to just pass through the verb.

enum class HTTPVerbs {
    POST,
    GET,
    PUT,
    UPDATE,
    DELETE
}

Uploading

Whether a PUT, UPDATE, or POST. The logic is very similar between them. It's generally just changing the verb.

Put & Post

With a PUT Request we are taking in a data as a string. Then sending that up to the REST endpoint. With the header of content type, json. The casting to a string is handled a layer up, via a helper function. So we still have type safety, and a contractual guarentee that it meets the expected class. Because we should never call the raw httpPutOrPost method.

XML HTTP Request Wrapper

suspend fun httpPutOrPost(url: String, data: String, httpVerb: HTTPVerbs): String = suspendCoroutine { c ->
    when (httpVerb) {
        HTTPVerbs.POST, HTTPVerbs.PUT -> {
            val xhr = XMLHttpRequest()
            xhr.onreadystatechange = { _ -> statusHandler(xhr, c) }
            xhr.open(httpVerb.name, url, true)
            xhr.setRequestHeader("Content-type", "application/json; charset=utf-8")
            xhr.send(data)
        }
        else -> console.log("An unsupported verb was passed through to this function")
    }
}

Here we take in one of the HTTPVerbs. Using the when expression. We verify it is of either POST or PUT then process the request. Anything else is logged as an error.

Higher Level POST & Put Overlay

suspend inline fun <reified S : Any, reified R : Any> uploadBase(url: String,
                                                                 httpVerb: HTTPVerbs,
                                                                 data: S, dataClazz: KClass<S>,
                                                                 dataSerializer: KSerializer<S>,
                                                                 responseClazz: KClass<R>,
                                                                 responseSerializer: KSerializer<R>,
                                                                 debug: Boolean = true): R {
    val sendJsonContext = SerialContext()
            .apply { registerSerializer(dataClazz, dataSerializer) }
    val jsonString = JSON(context = sendJsonContext).stringify(data)
    val response = httpPutOrPost(url, jsonString, httpVerb)
    val responseJsonContext = SerialContext()
            .apply { registerSerializer(responseClazz, responseSerializer) }
    return JSON(context = responseJsonContext).parse(response)
}
  • url: The url to push data too.
  • httpVerb: One of the HTTP Verbs from the above enum.
  • data: The data we want to send part of the data class.
  • dataClazz: A representation of the data class.
  • dataSerializer: The kotlin x serializer interface.
  • responseClazz: A representation of the response class.
  • responseSerializer: The kotlin x serialization interface.

I'm assuming we have two generic types we pass to the function.

  • S Is what we send up i.e. send a Pet object.
  • R Is the response object, i.e. PetResponse.

We pass up the data which will be serialized to a string. the Data class and an instance of the serializer. This will be used to build out a JSON Context. The same is provided for the response, except utilizing the response object.

With this we can have a very simple function that just takes in the data object we wish to see.

Putting a Bow on It

suspend fun createPet(pet: Pet): PetResponse {
    return fetchBase(
       "http://$APIHost/api/v1/user",
       Pet::class,
       Pet.serializer(),
       PetResponse::class, 
       PetResponse.serializer()
   )
}

We essentially just pre populated a lot of the arguments, so that the api grammar is cleaner. This allows us to then do the following.

launch {
       val newPet = buildPet()
       val petUpdateResponse = updatePet(newPet)
       setState { pet = petUpdateResponse }
}

The above snippet asynchronously updates the user at the API endpoint.

Get

XML HTP Request Wrapper

suspend fun httpGet(url: String): String = suspendCoroutine { c ->
    val xhr = XMLHttpRequest()
    xhr.onreadystatechange = { _ -> statusHandler(xhr, c) }
    xhr.open("GET", url)
    xhr.send()
}

This is pretty much the same as the upload aspect. Except that we are using GET, and not pushing anything up.

Higher Level Overlay

suspend inline fun <reified R : Any> getBase(url: String, clazz: KClass<R>, serializer: KSerializer<R>, debug: Boolean = true): R {
    if (debug) console.log("Fetching for url $url, and class of ${clazz.simpleName}")
    val rawData = httpGet(url)
    console.log(rawData)
    val jsonContext = SerialContext()
            .apply { registerSerializer(clazz, serializer) }
    val parsed = JSON(context = jsonContext).parse<R>(rawData)
    if (debug) console.log("Received a response of:\n $parsed")
    return parsed
}

Again very similar to the update. Except we have limited this to only a receiver and no sending class.

The Bow

suspend fun fetchAllPets(): PetResponse {
    return getBase("http://localhost/api/v1/pets", PetResponse::class, PetResponse.serializer())
}

Pretty simple to use in your javascript apps.

To Do

This is a good start at wrapping up alot of the logic, and making it relatively simple to add new rest endpoints. It can be even cleaner with the common modules. Specifying a getter/creater, and then implement specifically at the platform level.

Additional Items

  • Functional Response, something akin to an option type.
  • Additional content type supporting.
  • Better error handling.

Located here