Serving static files with Ktor
Serving static files with Ktor for a static website seems easy enough:
fun main() {
embeddedServer(Netty, port = 8080) {
routing {
static {
files("static-data")
}
}
}.start(wait = true)
}
Done? Not quite. Opening 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):
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()
:
val staticData = File("/path/to/static-data")
static {
filesWithDefaultIndex(staticData)
}
fun Route.filesWithDefaultIndex(dir: File) {
}
In there, we’ll need a get
handler:
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
:
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:
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:
val localFile = when {
file.isFile -> file
file.isDirectory && fallbackFile.isFile -> fallbackFile
else -> return@get
}
And finally, we serve the file:
call.respond(LocalFileContent(localFile, ContentType.defaultForFile(localFile)))
Great! Let’s see what this looks like all together:
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 or
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/, 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 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:
install(IgnoreTrailingSlash)
And now everything should work as expected.