Migrate from Bazel
This commit is contained in:
commit
016dbd0814
59 changed files with 7044 additions and 0 deletions
146
content/posts/ktor-altering-served-content.md
Normal file
146
content/posts/ktor-altering-served-content.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
+++
|
||||
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,
|
||||
)
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue