blog/content/posts/serving-static-files-with-ktor.md
2025-12-12 15:30:04 +11:00

142 lines
4.4 KiB
Markdown

+++
template = "article.html"
title = "Serving static files with Ktor"
date = 2022-09-03T19:00:00+10:00
description = "A guide to serving static files with Ktor, including configuration for default files, HTML minification, and proper MIME types."
[taxonomies]
tags = ["kotlin", "ktor", "web"]
+++
Serving static files with [Ktor](https://ktor.io) for a static website seems easy enough:
```kotlin
fun main() {
embeddedServer(Netty, port = 8080) {
routing {
static {
files("static-data")
}
}
}.start(wait = true)
}
```
Done? Not quite. Opening [http://localhost:8080](http://localhost:8080) will only get you a 404,
even if you have an `index.html` file in the `static-data` folder. You have to let Ktor know you
want to opt-in serving a default file. Let's try again (omitting anything outside the `static`
block):
```kotlin
static {
files("static-data")
default("static-data/index.html")
}
```
Surely now this works? Well, kind of. The root folder will have a default `index.html`, but sadly if
you have any sub-folders with `index.html` files inside, they won't be served by default - that
`default()` function only works for the top-level folder. Let's work around that.
Let's store our `static-data` directory into a `File` variable, replace the `files` function call by
our own extension. Note that this extension will handle the fallback to `index.html` in all cases,
so we can also remove the call to `default()`:
```kotlin
val staticData = File("/path/to/static-data")
static {
filesWithDefaultIndex(staticData)
}
fun Route.filesWithDefaultIndex(dir: File) {
}
```
In there, we'll need a `get` handler:
```kotlin
fun Route.filesWithDefaultIndex(dir: File) {
get("{static_path...}") {}
}
```
This will catch anything inside the static path, and let us access the requested path through
`call.parameters.getAll("static_path")`. This gets us a list of URL segments that we need to join
with `File.separator`:
```kotlin
val relativePath = call.parameters
.getAll("static_path")
?.joinToString(File.separator) ?: return@get
```
Now, we have three options:
- The requested path points to an existing file. Great! We can just return it.
- The requested path points to a folder. If there's an `index.html` file in there, let's serve it.
- In any other case (the requested path doesn't exist, or points to a folder without an `index.html`
file), we let Ktor handle that (most likely returning a 404).
We can easily access the corresponding local file, and its fallback if it ends up being a directory:
```kotlin
val combinedDir = staticRootFolder?.resolve(dir) ?: dir
val file = combinedDir.combineSafe(relativePath)
val fallbackFile = file.combineSafe("index.html")
```
Now that we have all those defined, here's how we take the decision on what to serve:
```kotlin
val localFile = when {
file.isFile -> file
file.isDirectory && fallbackFile.isFile -> fallbackFile
else -> return@get
}
```
And finally, we serve the file:
```kotlin
call.respond(LocalFileContent(localFile, ContentType.defaultForFile(localFile)))
```
Great! Let's see what this looks like all together:
```kotlin
fun Route.filesWithDefaultIndex(dir: File) {
val combinedDir = staticRootFolder?.resolve(dir) ?: dir
get("{static_path...}") {
val relativePath = call.parameters
.getAll("static_path")
?.joinToString(File.separator) ?: return@get
val file = combinedDir.combineSafe(relativePath)
val fallbackFile = file.combineSafe("index.html")
val localFile = when {
file.isFile -> file
file.isDirectory && fallbackFile.isFile -> fallbackFile
else -> return@get
}
call.respond(LocalFileContent(localFile, ContentType.defaultForFile(localFile)))
}
}
```
Now if you try to open [http://localhost:8080](http://localhost:8080) or
[http://localhost:8080/foo](http://localhost:8080/foo), both should work (assuming there's
`index.html` files both at the root and in a folder named `foo`). But if you open
[http://localhost:8080/foo/](http://localhost:8080/foo/), with a trailing slash, it still doesn't
work. That's because Ktor handles the two routes (with and without trailing slash) differently (for
good reasons, see [here](https://youtrack.jetbrains.com/issue/KTOR-372) for some context).
In our case, that's not what we want. Luckily, Ktor comes with a plug-in to address this. All that's
needed is to install it like any other plugin:
```kotlin
install(IgnoreTrailingSlash)
```
And now everything should work as expected.