blog/content/posts/ktor-altering-served-content.md
2025-12-12 15:30:04 +11:00

146 lines
4.5 KiB
Markdown

+++
template = "article.html"
title = "Ktor: Altering served content"
date = 2022-09-06T14:00:00+10:00
description = "Learn how to create Ktor plugins that transform response content, with a practical example of injecting scripts into HTML files."
[taxonomies]
tags = ["kotlin", "ktor", "web"]
+++
When serving files with [Ktor](https://ktor.io), 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](https://ktor.io/docs/plugins.html). Plugins can hook at different stages
of the request/response pipeline:
{% mermaid(caption="Ktor request/response pipeline") %}
graph LR
Request --> Plugin1[Plugin]
Plugin1 --> Handler
Handler --> Plugin2[Plugin]
Plugin2 --> Response
{% end %}
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:
```kotlin
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:
```kotlin
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](https://ktor.io/docs/custom-plugins.html#other).
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:
```kotlin
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](https://jsoup.org/) comes in handy for
this kind of operations:
```kotlin
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:
{% aside() %}
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.
{% end %}
```kotlin
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,
)
}
```