kog
A simple, experimental Kotlin web framework inspired by Clojure's Ring.
A kog application is a function that takes a Request
and returns a Response
.
Built on top of Jetty.
import com.danneu.kog.Server
import com.danneu.kog.Response
Server({ Response().text("hello world") }).listen(3000)
Goals
- Simplicity
- Middleware
- Functional composition
Table of Contents
- Install
- Quick Start
- Concepts
- JSON
- Routing
- Cookies
- Included Middleware
- HTML Templating
- WebSockets
- Caching
- Environment Variables
- Heroku Deploy
- Example: Tiny Pastebin Server
- Content Negotiation
- License
Install
repositories {
maven { url "https://jitpack.io" }
}
dependencies {
compile "com.danneu:kog:x.y.z"
// Or always get latest
compile "com.danneu:kog:master-SNAPSHOT"
}
Quick Start
Hello World
Hello world
") } // or use the Handler typealias: val handler: Handler = { req -> Response().html("Hello world
") } fun main(args: Arrayimport com.danneu.kog.Response import com.danneu.kog.Request import com.danneu.kog.Handler import com.danneu.kog.Server fun handler(req: Request): Response { return Response().html("Hello world
") } // or use the Handler typealias: val handler: Handler = { req -> Response().html("Hello world
") } fun main(args: Array<String>) { Server(handler).listen(3000) }
Type-Safe Routing
import com.danneu.json.Encoder as JE import com.danneu.kog.Router import com.danneu.kog.Response import com.danneu.kog.Request import com.danneu.kog.Handler import com.danneu.kog.Server val router = Router { get("/users", fun(): Handler = { req -> Response().text("list users") }) get("/users/" , fun(id: Int): Handler = { req -> Response().text("show user $id") }) get("/users//edit " , fun(id: Int): Handler = { req -> Response().text("edit user $id") }) // Wrap routes in a group to dry up middleware application group("/stories/" , listOf(middleware)) { get("/comments", listOf(middleware), fun(id: java.util.UUID): Handler = { Response().text("listing comments for story $id") }) } delete("/admin/users/" , listOf(ensureAdmin()), fun(id: Int): Handler = { req -> Response().text("admin panel, delete user $id") }) get("///", fun( a : Int, b : Int, c : Int): Handler = { req -> Response().json( JE.obj( "sum" to JE.num(a + b + c))) }) } } val handler = middleware1(middleware2(middleware3(router.handler()))) fun main( args : Array< String>) { Server(handler).listen( 3000) }
Concepts
A kog application is simply a function that takes a Request
and returns a Response
.
Request & Response
The Request and Response have an API that makes it easy to chain transformations together.
Example junk-drawer:
import com.danneu.kog.Status
import com.danneu.kog.json.Encoder as JE
import java.util.File
Response() // skeleton 200 response
Response(Status.NotFound) // 404 response
Response.notFound() <-- Sugar // 404 response
Response().text("Hello") // text/plain
Response().html("Hello
") // text/html
Response().json(JE.obj("number" to JE.num(42))) // application/json {"number": 42}
Response().json(JE.array(JE.num(1), JE.num(2), JE.num(3))) // application/json [1, 2, 3]
Response().file(File("video.mp4")) // video/mp4 (determines response headers from File metadata)
Response().stream(File("video.mp4")) // video/mp4
Response().setHeader(Header.AccessControlAllowOrigin, "*")
Response().type = ContentType(Mime.Html, mapOf("charset", "utf-8"))
Response().appendHeader(Header.Custom("X-Fruit"), "orange")
Response().redirect("/") // 302 redirect
Response().redirect("/", permanent = true) // 301 redirect
Response().redirectBack(request, "/") // 302 redirect
import com.danneu.kog.json.Decoder as JD import com.danneu.kog.Header // GET http://example.com/users?sort=created, json body is {"foo": "bar"} var handler: Handler = { request -> request.type // ContentType(mime=Mime.Html, params=mapOf("charset" to "utf-8")) request.href // http://example.com/users?sort=created request.path // "/users" request.method // Method.get request.params // Maprequest.json(decoder) // com.danneu.result.Result request.utf8() // "{\"foo\": \"bar\"}" request.headers // [(Header.Host, "example.com"), ...] request.getHeader(Header.Host) // "example.com"? request.getHeader(Header.Custom("xxx")) // null request.setHeader(Header.UserAgent, "MyCrawler/0.0.1") // Request }
Handler
typealias Handler = (Request) -> Response
Your application is a function that takes a Request and returns a Response.
val handler: Handler = { request in
Response().text("Hello world")
}
fun main(args: Array<String>) {
Server(handler).listen(3000)
}
Middleware
typealias Middleware = (Handler) -> Handler
Middleware functions let you run logic when the request is going downstream and/or when the response is coming upstream.
val logger: Middleware = { handler -> { request ->
println("Request coming in")
val response = handler(request)
println("Response going out")
response
}}
val handler: Handler = { Response().text("Hello world") }
fun main(args: Array<String>) {
Server(logger(handler)).listen()
}
Since middleware are just functions, it's trivial to compose them:
import com.danneu.kog.middleware.compose
// `logger` will touch the request first and the response last
val middleware = compose(logger, cookieParser, loadCurrentUser)
Server(middleware(handler)).listen(3000)
Tip: Short-Circuiting Lambdas
You often want to bail early when writing middleware and handlers, like short-circuiting your handler with a 400 Bad Request
when the client gives you invalid data.
The compiler will complain if you return
inside a lambda expression, but you can fix this by using a label@
:
val middleware: Middleware = { handler -> handler@ { req ->
val data = req.query.get("data") ?: return@handler Response.badRequest()
Response().text("You sent: $data")
}}
JSON
kog wraps the small, fast, and simple ralfstx/minimal-json library with combinators for working with JSON.
Note: json combinators and the result monad have been extracted from kog:
JSON Encoding
kog's built-in JSON encoder has these methods: .obj()
, .array()
, .num()
, .str()
, .null()
, .bool()
.
They all return com.danneu.json.JsonValue
objects that you pass to Response#json
.
import com.danneu.json.Encoder as JE
val handler: Handler = { req ->
Response().json(JE.obj("hello" to JE.str("world")))
}
import com.danneu.json.Encoder as JE
val handler: Handler = { req ->
Response().json(JE.array(JE.str("a"), JE.str("b"), JE.str("c")))
// Or
Response().json(JE.array(listOf(JE.str("a"), JE.str("b"), JE.str("c"))))
}
import com.danneu.json.Encoder as JE
val handler: Handler = { req ->
Response().json(JE.obj(
"ok" to JE.bool(true),
"user" to JE.obj(
"id" to JE.num(user.id),
"username" to JE.str(user.uname),
"luckyNumbers" to JE.array(JE.num(3), JE.num(9), JE.num(27))
)
))
}
It might seem redundant/tedious to call JE.str("foo")
and JE.num(42)
, but it's type-safe so that you can only pass things into the encoder that's json-serializable. I'm not sure if kotlin supports anything simpler at the moment.
JSON Decoding
kog comes with a declarative JSON parser combinator inspired by Elm's.
Decoder
is a decoder that will return Result
when invoked on a JSON string.
import com.danneu.json.Decoder as JD
import com.danneu.json.Encoder as JE
// example request payload: [1, 2, 3]
val handler = { request ->
request.json(JD.array(JD.int)).fold({ nums ->
// success
Response().json(JE.obj("sum" to JE.num(nums.sum())))
}, { parseException ->
// failure
Response.badRequest()
})
}
We can use Result#getOrElse()
to rewrite the previous example so that invalid user-input will defaults to an empty list of numbers.
import com.danneu.json.Decoder as JD
import com.danneu.json.Encoder as JE
// example request payload: [1, 2, 3]
val handler = { req ->
val sum = req.json(JD.array(JD.int)).getOrElse(emptyList()).sum()
Response().json(JE.obj("sum" to JE.num(sum)))
}
This authentication handler parses the username/password combo from the request's JSON body:
import com.danneu.json.Decoder as JD
import com.danneu.json.Encoder as JE
// example request payload: {"user": {"uname": "chuck"}, "password": "secret"}
val handler = { request ->
val decoder = JD.pairOf(
JD.get(listOf("user", "uname"), JD.string),
JD.get("password", JD.string)
)
val (uname, password) = request.json(decoder)
// ... authenticate user ...
Response().json(JE.obj("success" to JE.obj("uname" to JE.str(uname))))
}
Check out danneu/kotlin-json-combinator and danneu/kotlin-result for more examples.
Routing
kog's router is type-safe because routes only match if the URL params can be parsed into the arguments that your function expects.
Available coercions:
kotlin.Int
kotlin.Long
kotlin.Float
kotlin.Double
java.util.UUID
For example:
Router { // GET /uuid/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa -> 200 Ok // GET /uuid/AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA -> 200 Ok // GET /uuid/42 -> 404 Not Found // GET /uuid/foo -> 404 Not Found get("/uuid/" , fun(uuid: java.util.UUID): Handler = { req -> Response().text("you provided a uuid of version ${uuid.version} with a timestamp of ${uuid.timestamp}") }) }
Here's a more meandering example:
import com.danneu.kog.json.Encoder as JE import com.danneu.kog.Router import com.danneu.kog.Response import com.danneu.kog.Request import com.danneu.kog.Handler import com.danneu.kog.Server val router = Router(middleware1(), middleware2()) { get("/", fun(): Handler = { req -> Response().text("homepage") }) get("/users/" , fun(id: Int): Handler = { req -> Response().text("show user $id") }) get("/users//edit " , fun(id: Int): Handler = { req -> Response().text("edit user $id") }) // Wrap routes in a group to dry up middleware application group("/stories/" , listOf(middleware)) { get("/comments", listOf(middleware), fun(id: java.util.UUID): Handler = { Response().text("listing comments for story $id") }) } delete("/admin/users/" , listOf(ensureAdmin()), fun(id: Int): Handler = { req -> Response().text("admin panel, delete user $id") }) get("///", fun( a : Int, b : Int, c : Int): Handler = { req -> Response().json( JE.obj( "sum" to JE.num(a + b + c))) }) get( "/hello/world", fun( a : Int, b : String): Handler = { Response().text( "this route can never match the function (Int, Int) -> ...") }) get( "/hello/world", fun(): Handler = { Response().text( "this route *will* match") }) } } fun main( args : Array< String>) { Server(handler).listen( 3000) }
Router mounting
Router#mount(subrouter)
will merge a child router into the current router.
Useful for breaking your application into individual routers that you then mount into a top-level router.
val subrouter = Router {
get("/foo", fun(): Handler = { Response() })
}
val router = Router {
mount(subrouter)
}
curl http://localhost:3000/foo # 200 Ok
Or mount routers at a prefix:
val subrouter = Router {
get("/foo", fun(): Handler = { Response() })
}
val router = Router {
mount("/subrouter", subrouter)
}
curl http://localhost:3000/foo # 404 Not Found
curl http://localhost:3000/subrouter/foo # 200 Ok
Or mount routers in a group:
val subrouter = Router {
get("/foo", fun(): Handler = { Response() })
}
val router = Router {
group("/group") {
mount("/subrouter", subrouter)
}
}
Note: The mount prefix must be static. It does not support dynamic patterns like "/users/".
Cookies
Request Cookies
Request#cookies
is a MutableMap
which maps cookie names to cookie values received in the request.
Response Cookies
Response#cookies
is a MutableMap
which maps cookie names to cookie objects that will get sent to the client.
Here's a handler that increments a counter cookie on every request that will expire in three days:
import com.danneu.kog.Response import com.danneu.kog.Handler import com.danneu.kog.Server import com.danneu.kog.cookies.Cookie import java.time.OffsetDateTime fun Request.parseCounter(): Int = try { cookies.getOrDefault("counter", "0").toInt() } catch(e: NumberFormatException) { 0 } fun Response.setCounter(count: Int): Response = apply { cookies["counter"] = Cookie(count.toString(), duration = Cookie.Ttl.Expires(OffsetDateTime.now().plusDays(3))) } val handler: Handler = { request -> val count = request.parseCounter() + 1 Response().text("count: $count").setCounter(count) } fun main(args: Array<String>) { Server(handler).listen(9000) }
Demo:
$ http --session=kog-example --body localhost:9000
count: 1
$ http --session=kog-example --body localhost:9000
count: 2
$ http --session=kog-example --body localhost:9000
count: 3
Included Middleware
The com.danneu.kog.batteries
package includes some useful middleware.
Development Logger
The logger middleware prints basic info about the request and response to stdout.
import com.danneu.kog.batteries.logger
Server(logger(handler)).listen()
Static File Serving
The serveStatic middleware checks the request.path
against a directory that you want to serve static files from.
import com.danneu.kog.batteries.serveStatic
val middleware = serveStatic("public", maxAge = Duration.ofDays(365))
val handler = { Response().text(":)") }
Server(middleware(handler)).listen()
If we have a public
folder in our project root with a file message.txt
, then the responses will look like this:
$ http localhost:3000/foo
HTTP/1.1 404 Not Found
$ http localhost:3000/message.txt
HTTP/1.1 200 OK
Content-Length: 38
Content-Type: text/plain
This is a message from the file system
$ http localhost:3000/../passwords.txt
HTTP/1.1 400 Bad Request
Conditional-Get Caching
This middleware adds Last-Modified
or ETag
headers to each downstream response which the browser will echo back on subsequent requests.
If the response's Last-Modified
/ETag
matches the request, then this middleware instead responds with 304 Not Modified
which tells the browser to use its cache.
ETag
notModified(etag = true)
will generate an ETag header for each downstream response.
val router = Router(notModified(etag = true)) {
get("/", fun(): Handler = {
Response().text("Hello, world!)
})
}
First request gives us an ETag.
$ http localhost:9000
HTTP/1.1 200 OK
Content-Length: 13
Content-Type: text/plain
ETag: "d-bNNVbesNpUvKBgtMOUeYOQ"
Hello, world!
When we echo back the ETag, the server lets us know that the response hasn't changed:
$ http localhost:9000 If-None-Match:'"d-bNNVbesNpUvKBgtMOUeYOQ"'
HTTP/1.1 304 Not Modified
Last-Modified
notModified(etag = false)
will only add a Last-Modified
header to downstream responses if response.body is ResponseBody.File
since kog can read the mtime from the File's metadata.
If the response body is not a ResponseBody.File
type, then no header will be added.
This is only useful for serving static assets from the filesystem since ETags are unnecessary to generate when you have a file's modification time.
val router = Router {
// TODO: kog doesn't yet support mounting middleware on a prefix
use("/assets", notModified(etag = false), serveStatic("public", maxAge = Duration.ofHours(4)))
get("/") { Response().text("homepage")
}
Multipart File Uploads
To handle file uploads, use the com.danneu.kog.batteries.multipart
middleware.
This middleware parses file uploads out of "multipart/form-data"
requests and populates request.uploads : MutableMap
for your handler to access which is a mapping of field names to File
representations.
package com.danneu.kog.batteries.multipart
class SavedUpload(val file: java.io.File, val filename: String, val contentType: String, val length: Long)
In this early implementation, by the time your handler is executed, the file uploads have already been piped into temporary files in the file-system which will get automatically deleted.
import com.danneu.kog.Router import com.danneu.kog.batteries.multipart import com.danneu.kog.batteries.multipart.Whitelist val router = Router { get("/", fun(): Handler = { Response().html(""" """) }) post("/upload", multipart(Whitelist.only(setOf("file1"))), fun(): Handler = { req -> val upload = req.uploads["file1"] Response().text("You uploaded ${upload?.length ?: "--"} bytes") }) } fun main(args: Array<String>) { Server(router.handler()).listen(3000) }
Pass a whitelist into multipart()
to only process field names that you expect.
import com.danneu.kog.batteries.multipart
import com.danneu.kog.batteries.multipart.Whitelist
multipart(whitelist = Whitelist.all)
multipart(whitelist = Whitelist.only(setOf("field1", "field2")))
Basic Auth
Just pass a (name, password) -> Boolean
predicate to the basicAuth()
middleware.
Your handler won't get called unless the user satisfies it.
import com.danneu.kog.batteries.basicAuth
fun String.sha256(): ByteArray {
return java.security.MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
}
val secretHash = "a man a plan a canal panama".sha256()
fun isAuthenticated(name: String, pass: String): Boolean {
return java.util.Arrays.equals(pass.sha256(), secretHash)
}
val router = Router {
get("/", basicAuth(::isAuthenticated)) {
Response().text("You are authenticated!")
}
}
Compression / Gzip
The compress
middleware reads and manages the appropriate headers to determine if it should send a gzip-encoded response to the client.
Options:
compress(threshold: ByteLength)
(Default = 1024 bytes) Only compress the response if it is at least this large.compress(predicate = (String?) -> Boolean)
(Default = Looks up mime in https://github.com/jshttp/mime-db file) Only compress the response if its Content-Type header passespredicate(type)
.
Some examples:
bar
") }) // <-- Not compressed (not json) get("/c", fun(): Handler = { Response().jsonArray(1, 2, 3) }) // <-- Compressed } // These responses will be compressed if they are at least 1024 bytes group(compress(threshold = ByteLength.ofBytes(1024))) { get("/d", fun(): Handler = { Response().text("qux") }) // <-- Not compressed (too small) } }">import com.danneu.kog.batteries.compress
import com.danneu.kog.ByteLength
val router = Router() {
// These responses will be compressed if they are JSON of any size
group(compress(threshold = ByteLength.zero, predicate = { it == "application/json" })) {
get("/a", fun(): Handler = { Response().text("foo") }) // <-- Not compressed (not json)
get("/b", fun(): Handler = { Response().html("bar
") }) // <-- Not compressed (not json)
get("/c", fun(): Handler = { Response().jsonArray(1, 2, 3) }) // <-- Compressed
}
// These responses will be compressed if they are at least 1024 bytes
group(compress(threshold = ByteLength.ofBytes(1024))) {
get("/d", fun(): Handler = { Response().text("qux") }) // <-- Not compressed (too small)
}
}
HTML Templating
Templating libraries generally generate an HTML string. Just pass it to Response().html(html)
.
For example, tipsy/j2html is a simple templating library for generating HTML from your handlers.
compile "com.j2html:j2html:1.0.0"
Here's an example server with a "/" route that renders a file-upload form that posts to a "/upload" route.
import j2html.TagCreator.*
import j2html.tags.ContainerTag
import com.danneu.kog.Router
import com.danneu.kog.Response
import com.danneu.kog.Server
import com.danneu.kog.batteries.multipart
import com.danneu.kog.batteries.multipart.Whitelist
fun layout(vararg tags: ContainerTag): String = document().render() + html().with(
body().with(*tags)
).render()
val router: Router = Router {
get("/", fun(): Handler = {
Response().html(layout(
form().attr("enctype", "multipart/form-data").withMethod("POST").withAction("/upload").with(
input().withType("file").withName("myFile"),
button().withType("submit").withText("Upload File")
)
))
})
post("/upload", multipart(Whitelist.only(setOf("myFile"))), fun(): Handler = {
Response().text("Uploaded ${req.uploads["myFile"]?.length ?: "--"} bytes")
})
}
fun main(args: Array<String>) {
Server(router.handler()).listen(9000)
}
WebSockets
Check out examples/websockets.kt for a websocket example that demonstrates a websocket handler that echos back every message, and a websocket handler bound to a dynamic /ws/
route.
Take note of a few limitations explained in the comments that I'm working on fixing.
Idle Timeout
By default, Jetty (and thus kog) timeout connections that have idled for 30 seconds.
You can change this when initializing a kog Server
:
import com.danneu.kog.Server
import java.time.Duration
fun main(args: Array<String>) {
Server(handler, idleTimeout = Duration.ofMinutes(5)).listen(3000)
}
However, instead of changing kog's idleTimeout
, you probably want to have your websocket clients ping the server to keep the connections alive.
Often reverse proxies like nginx, Heroku's routing layer, and Cloudflare have their own idle timeout.
For example, here are Heroku's docs on the matter: https://devcenter.heroku.com/articles/websockets#timeouts
I believe this is also why websocket libraries like https://socket.io/ implement their own ping/pong.
Finally, it seems that Jetty's maximum idle timeout is 5 minutes, so passing in durations longer than 5 minutes seems to just max out at 5 minutes. If someone can correct me here, feel free to create an issue.
Caching
In-Memory Cache
I've been impressed with Ben Manes' ben-manes/caffeine library.
Easy to pick up and use in any project.
There's also Guava's Cache.
Environment Variables
Kog's Env
object provides a central way to access any customizations passed into an application.
First it reads from an optional .env
file, then it reads from system properties, and finally it reads from system environment variables (highest precedence). Any conflicts will be overwritten in that order.
For instance, if we had PORT=3000
in an .env
file and then launched our application with:
PORT=9999 java -jar app.java
Then this is what we'd see in our code:
import com.danneu.kog.Env
Env.int("PORT") == 9999
For example, when deploying an application to Heroku, you want to bind to the port that Heroku gives you via the "PORT"
env variable. But you may want to default to port 3000 in development when there is no port configured:
import com.danneu.kog.Server
import com.danneu.kog.Env
fun main(args: Array<String>) {
Server(router.handler()).listen(Env.int("PORT") ?: 3000)
}
Env
provides some conveniences:
Env.string(key)
Env.int(key)
Env.float(key)
Env.bool(key)
: True if the value is"true"
or"1"
, e.g.VALUE=true java -jar app.java
If the parse fails, null
is returned.
You can get a new, overridden env container with .fork()
:
Env.int("PORT") //=> 3000
Env.fork(mapOf("PORT" to "8888")).int("PORT") //=> 8888
Env.int("PORT") //=> 3000
Heroku Deploy
This example application will be called "com.danneu.kogtest".
I'm not sure what the minimal boilerplate is, but the following is what worked for me.
In ./build.gradle
:
buildscript {
ext.kotlin_version = "1.1-M03"
ext.shadow_version = "1.2.3"
repositories {
jcenter()
maven { url "http://dl.bintray.com/kotlin/kotlin-eap-1.1" }
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.github.jengelman.gradle.plugins:shadow:$shadow_version"
}
}
apply plugin: 'kotlin'
apply plugin: 'com.github.johnrengelman.shadow'
apply plugin: 'application'
mainClassName = 'com.danneu.kogtest.MainKt' // <--------------- CHANGE ME
repositories {
jcenter()
maven { url "http://dl.bintray.com/kotlin/kotlin-eap-1.1" }
maven { url 'https://jitpack.io' }
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
compile 'com.danneu:kog:master-SNAPSHOT'
}
task stage(dependsOn: ['shadowJar', 'clean'])
In ./src/main/kotlin/com/danneu/kogtest/main.kt
:
package com.danneu.kogtest
import com.danneu.kog.Env
import com.danneu.kog.Handler
import com.danneu.kog.Response
import com.danneu.kog.Server
fun main(args: Array<String>) {
val handler: Handler = { Response().text("Hello, world!") }
Server(handler).listen(ENV.int("PORT") ?: 3000)
}
Reminder: Bind to the PORT env variable that Heroku will set.
In ./Procfile
:
web: java -jar build/libs/kogtest-all.jar
Create and push to Heroku app:
heroku apps:create my-app
commit -am 'Initial commit'
git push heroku master
Example: Tiny Pastebin Server
I got this idea from: https://rocket.rs/guide/pastebin/.
This simple server will have two endpoints:
- Upload file:
curl --data-binary @example.txt http://localhost:3000
.- Uploads binary stream to a "pastes" directory on the server.
- Server responds with JSON
{ "url": "http://localhost:3000/
." }
- Fetch file:
curl http://localhost:3000/
.- Server responds with file or 404.
import com.danneu.kog.Router import com.danneu.kog.Response import com.danneu.kog.Handler import com.danneu.kog.util.CopyLimitExceeded import com.danneu.kog.util.limitedCopyTo import java.io.File import java.util.UUID val uploadLimit = ByteLength.ofMegabytes(10) val router = Router { // Upload file post("/", fun(): Handler = handler@ { req -> // Generate random ID for user's upload val id = UUID.randomUUID() // Ensure "pastes" directory is created val destFile = File(File("pastes").apply { mkdir() }, id.toString()) // Move user's upload into "pastes", bailing if their upload size is too large. try { req.body.limitedCopyTo(uploadLimit, destFile.outputStream()) } catch(e: CopyLimitExceeded) { destFile.delete() return@handler Response.badRequest().text("Cannot upload more than ${uploadLimit.byteLength} bytes") } // If stream was empty, delete the file and scold user if (destFile.length() == 0L) { destFile.delete() return@handler Response.badRequest().text("Paste file required") } println("A client uploaded ${destFile.length()} bytes to ${destFile.absolutePath}") // Tell user where they can find their uploaded file Response().json(JE.obj("url" to JE.str("http://localhost:${req.serverPort}/$id"))) }) // Fetch file get("/" , fun(id: UUID): Handler = handler@ { req -> val file = File("pastes/$id") if (!file.exists()) return@handler Response.notFound() Response().file(file) }) } fun main(args: Array<String>) { Server(router.handler()).listen(3000) }
Content Negotiation
TODO: Improve negotiation docs
Each request has a negotiator that parses the accept-*
headers, returning a list of values in order of client preference.
request.negotiate.mediaTypes
parses theaccept
header.request.negotiate.languages
parses theaccept-language
header.request.negotiate.encodings
parses theaccept-encoding
header.
Until the docs are fleshed out, here's a demo server that will illuminate this:
fun main(args: Array<String>) {
val handler: Handler = { request ->
println(request.headers.toString())
Response().text("""
languages: ${request.negotiate.languages()}
encodings: ${request.negotiate.encodings()}
mediaTypes: ${request.negotiate.mediaTypes()}
""".trimIndent())
}
Server(handler).listen(3000)
}
An example curl request:
curl http://localhost:3000 \
--header 'Accept-Language:de;q=0.7, fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5, de-CH;q=0.2' \
--header 'accept:application/json,TEXT/*' \
--header 'accept-encoding:gzip,DeFLaTE'
Corresponding response:
languages: [French[CH], French[*], English[*], German[*], *[*], German[CH]]
encodings: [Encoding(name='gzip', q=1.0), Encoding(name='deflate', q=1.0)]
mediaTypes: [MediaType(type='application', subtype='json', q=1.0), MediaType(type='text', subtype='*', q=1.0)]
Notice that values ("TEXT/*", "DeFLaTE") are always downcased for easy comparison.
Most acceptable language
Given a list of languages that you want to support, the negotiator can return a list that filters and sorts your available languages down in order of client preference, the first one being the client's highest preference.
import com.danneu.kog.Lang
import com.danneu.kog.Locale
// Request Accept-Language: "en-US, es"
request.negotiate.acceptableLanguages(listOf(
Lang.Spanish(),
Lang.English(Locale.UnitedStates)
)) == listOf(
Lang.English(Locale.UnitedStates),
Lang.Spanish()
)
Also, note that we don't have to provide a locale. If the client asks for en-US
, then of course Lang.English()
without a locale should be acceptable if we have no more specific match.
// Request Accept-Language: "en-US, es"
request.negotiate.acceptableLanguages(listOf(
Lang.Spanish(),
Lang.English()
)) == listOf(
Lang.English(),
Lang.Spanish()
)
The singular form, .acceptableLanguage()
, is a helper that returns the first result (the most preferred language in common with the client).
// Request Accept-Language: "en-US, es"
request.negotiate.acceptableLanguage(listOf(
Lang.Spanish(),
Lang.English()
)) == Lang.English()
Here we write an extension function Request#lang()
that returns the optimal lang between our available langs and the client's requested langs.
We define an internal OurLangs
enum so that we can exhaust it with when
expressions in our routes or middleware.
enum class OurLangs {
Spanish,
English
}
fun Request.lang(): OurLangs {
val availableLangs = listOf(
Lang.Spanish(),
Lang.English()
)
return when (this.negotiate.acceptableLanguage(availableLangs)) {
Lang.English() -> OurLangs.English
// Default to Spanish
else -> OurLangs.Spanish
}
}
router.get("/", fun(): Handler = { request ->
return when (request.lang()) {
OurLangs.Spanish() ->
Response().text("Les servimos en español")
OurLangs.English() ->
Response().text("We're serving you English")
}
})
License
MIT