146 lines
4.5 KiB
Markdown
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,
|
|
)
|
|
}
|
|
```
|