Ktor: Altering served content
When serving files with Ktor, there might be times when you need to alter those files. For example, you might want to inject a script in every served HTML file. For this purpose, we can leverage plugins. Plugins can hook at different stages of the request/response pipeline:
Let’s write a plugin that transforms a specific type of files - going with the previous example of injecting a script to every served HTML file. Our plugin needs to:
- Take a script URL as an input. If not provided, it won’t do anything.
- Add that script as a
<script>
element in the<head>
of any HTML file served by this Ktor server. - Obviously, not interfere with any other file format.
Let’s start by defining an empty plugin and its configuration:
class PluginConfiguration {
var scriptUrl: String? = null
}
val ScriptInjectionPlugin = createApplicationPlugin(
name = "ScriptInjectionPlugin",
createConfiguration = ::PluginConfiguration,
) {
val scriptUrl = pluginConfig.scriptUrl
// The rest of our plugin goes here.
}
With this simple definition, we can add our plugin to a Ktor server:
embeddedServer(Netty, port = 8080) {
install(ScriptInjectionPlugin) {
scriptUrl = "http://foo.bar/my/injected/script.js"
}
}
Now, let’s see how we can transform the body. Ktor offers a few handlers to hook into the pipeline shown above. The main ones are:
onCall
is fairly high level, and is mostly useful to get information about a request (e.g. to log a request).onCallReceive
allows to transform data received from the client before it’s processed.onCallRespond
allows to transform data before sending it to the client. That’s the one we’re after.
There are a few other handlers for specific use-cases, detailed here.
Let’s hook into onCallRespond
, and its helper transformBody
. We also need to check if the
content type we’re sending is HTML, otherwise, we just forward the response body as-is:
onCallRespond { _ ->
transformBody { data ->
if (data is OutgoingContent.ReadChannelContent &&
data.contentType?.withoutParameters() == ContentType.Text.Html &&
scriptUrl != null
) {
// We are serving an HTML file.
transform(data, scriptUrl)
} else {
data
}
}
}
We can then define our transform()
helper, which needs to:
- Read the body,
- Transform it,
- Store it into a
OutgoingContent
for Ktor to continue its pipeline.
First, let’s get the injection itself out of the way. Let’s assume we have our HTML file into a string, and want a new string with the injected HTML. Jsoup comes in handy for this kind of operations:
private fun injectLiveReloadFragment(target: String, scriptUrl: String): String {
return Jsoup.parse(target).apply {
head().appendElement("script").attributes().add("src", scriptUrl)
}.toString()
}
Now, let’s actually read and return the body:
Note
This transformation assumes that the HTML files processed there are fairly small. As such, it fully reads the channel Ktor provides in memory before transforming it. Any high-traffic server or longer files should probably not read the content in that way.
private suspend fun transform(
data: OutgoingContent.ReadChannelContent,
scriptUrl: String,
): OutgoingContent {
// This channel provided by Ktor gives a view of the file about to be returned.
val channel = data.readFrom()
// Let's ready everything in the channel into a String.
val content = StringBuilder().run {
while (!channel.isClosedForRead) {
channel.readUTF8LineTo(this)
append('\n')
}
toString()
}
// Inject our script URL.
val htmlContent = injectLiveReloadFragment(content, scriptUrl)
// Prepare our new content (htmlContent contains it as a string) for Ktor to process.
return WriterContent(
body = {
withContext(Dispatchers.IO) {
write(htmlContent)
}
},
contentType = ContentType.Text.Html,
)
}