HTTP

In this article we will cover the HTTP server and client implementation details of UCloud. Both the server and client implementations are both provided by Ktor. Before you read this article, you should read about the general server and client systems. The client/server is used, only, if the RPC call has the http block attached to it. Additionally, the client will only be used if it is using the OutgoingHttpCall backend.

Server

Almost all ktor server code in use by UCloud is placed in the dk.sdu.cloud.calls.server.IngoingHttpInterceptor class. A Ktor engine is prepared by the KtorServerProviderFeature, this engine is passed to the IngoingHttpInterceptor. This class performs the following tasks, according to the semantics defined by the http block.

  1. Register a call handler with Ktor

  2. Parsing the request

  3. Producing the response

Register a call handler with Ktor

A call handler is registered with Ktor in IngoingHttpInterceptor.addCallListenerForCall, which is called in response to anRpcServer.implement invocation. The function immediately checks if an http block is attached, if not it will silently do nothing.

From here a handler is registered with Ktor in the ordinary way:

engine.application.routing {
    // toKtorTemplate performs a plain one-to-one mapping of the http/path block semantics to Ktor routing 
    // template
    route(httpDescription.path.toKtorTemplate(fullyQualified = true)) {
        method(httpDescription.method) {
            handle {
                try {
                    // Calls the handler provided by 'implement'
                    @Suppress("UNCHECKED_CAST")
                    rpcServer.handleIncomingCall(
                        this@IngoingHttpInterceptor,
                        call,
                        HttpCall(this as PipelineContext<Any, ApplicationCall>)
                    )
                } catch (ex: IOException) {
                    log.debug("Caught IOException:")
                    log.debug(ex.stackTraceToString())
                    throw RPCException.fromStatusCode(HttpStatusCode.BadRequest)
                }
            }
        }
    }
}

Parsing the request

The request object is parsed from the HTTP request using the semantics provided in the http block. Ktor is used only to read the raw request information, we do not rely on the built-in parsing mechanisms. You can read more about serialization/deserialization here.

Producing the response

Once the CallHandler has run the IngoingHttpInterceptor receives the response (success/error) from the RpcServer. The IngoingHttpInterceptor makes the corresponding calls to the Ktor server to write the response. For most calls, which do not implement the HttpServerConverter.OutgoingBody (see below), the standard serialization mechanisms are used. We do not rely on the built-in mechanisms of Ktor.

Custom converters

It is possible to override the default serialization/deserialization mechanisms of UCloud, in favor of something made specifically for the HTTP backend. To do this you must implement one of the HttpServerConverter interfaces.

Table: Commonly used types with custom converters (part of service-lib)

Name Description
BinaryStream Provides streaming content. This implements both the ingoing direction and outgoing direction for both the client and server component

Examples

Example: A custom object implementing a deserializer

// Note: The deserializers go on the companion object
class CustomDeserialization(val colorAsValue: Int) {
    companion object : HttpServerConverter.IngoingBody<CustomDeserialization> {

        private fun constructFromText(text: String): CustomDeserialization {
            return CustomDeserialization(
                when (text) {
                    "red" -> 0
                    "blue" -> 1
                    else -> 2
                }
            )
        }

        override suspend fun serverIngoingBody(
            description: CallDescription<*, *, *>,
            call: HttpCall
        ): CustomDeserialization = constructFromText(call.call.receiveChannel().toByteArray().toString(Charsets.UTF_8))
    }
}

Example: A custom object implementing the serializer

// Note: The serializers go on class itself
class CustomSerialization(val colorAsValue: Int) : HttpServerConverter.OutgoingBody {
    override fun serverOutgoingBody(description: CallDescription<*, *, *>, call: HttpCall): OutgoingContent {
        return when (colorAsValue) {
            0 -> TextContent("red", ContentType.Text.Plain)
            1 -> TextContent("blue", ContentType.Text.Plain)
            else -> TextContent("unknown", ContentType.Text.Plain)
        }
    }
}

interface HttpServerConverter.IngoingPath<T : Any>

abstract fun serverIngoingPath(
    description: CallDescription<*, *, *>,
    call: HttpCall,
    value: String
): T

UCloud will invoke the IngoingPath interface if the value being deserialized is bound in the path section of an http block.

interface HttpServerConverter.IngoingQuery<T : Any>

abstract fun serverIngoingQuery(
    description: CallDescription<*, *, *>,
    call: HttpCall,
    name: String,
    value: String
): T

UCloud will invoke the IngoingQuery interface if the value being deserialized is bound in the params section of an http block. Both the name and the value of the parameter will be supplied.

interface HttpServerConverter.IngoingBody<T : Any>

abstract suspend fun serverIngoingBody(
    description: CallDescription<*, *, *>,
    call: HttpCall
): T

UCloud will invoke the IngoingBody interface if the value being deserialized is bound in the body section of an http block.

interface HttpServerConverter.IngoingHeader<T : Any>

abstract fun serverIngoingHeader(
    description: CallDescription<*, *, *>,
    call: HttpCall,
    header: String,
    value: String
): T

UCloud will invoke the IngoingHeader interface if the value being deserialized is bound in the headers section of an http block.

interface HttpServerConverter.OutgoingBody

fun serverOutgoingBody(
    description: CallDescription<*, *, *>, 
    call: HttpCall
): OutgoingContent

UCloud will invoke the OutgoingBody interface if the value being produced as either a success or error type.

Client

The client is implemented in a way, very similar to the server. The client uses the http block to determine how the request should be serialized and how the response should be deserialized. Exactly like the server, the HTTP client will delegate this work to the serialization component. It is possible to override this behavior by implementing one of the interfaces in HttpClientConverter.

Custom converters

interface HttpClientConverter.OutgoingPath

fun clientOutgoingPath(call: CallDescription<*, *, *>): String

UCloud will invoke the OutgoingPath interface if the value being serialized is bound in the path section of an http block.

interface HttpClientConverter.OutgoingQuery

fun clientOutgoingQuery(call: CallDescription<*, *, *>): String

UCloud will invoke the OutgoingQuery interface if the value being serialized is bound in the params section of an http block.

interface HttpClientConverter.OutgoingBody

fun clientOutgoingBody(call: CallDescription<*, *, *>): OutgoingContent

UCloud will invoke the OutgoingBody interface if the value being serialized is bound in the body section of an http block.

interface HttpClientConverter.OutgoingHeader

fun clientOutgoingHeader(call: CallDescription<*, *, *>, headerName: String): String

UCloud will invoke the OutgoingHeader interface if the value being serialized is bound in the headers section of an http block. This will not base64 decode the header.

interface HttpClientConverter.OutgoingCustomHeaders

fun clientAddCustomHeaders(call: CallDescription<*, *, *>): List<Pair<String, String>>

UCloud will invoke the OutgoingCustomHeaders interface if the value being serialized is bound in the headers section of an http block. This will base64 decode the header.

interface HttpClientConverter.IngoingBody<T : Any>

suspend fun clientIngoingBody(
    description: CallDescription<*, *, *>,
    call: HttpResponse,
    typeReference: TypeReference<T>
): T

UCloud will invoke the IngoingBody interface if the value being deserialized as either a success or error type.