Skip to content
Kona

Request & response

Every piece of HTTP code in Kona works with two small classes from the org.konaframework.http package:

  • Request — an immutable snapshot of what the client sent: method, path, headers, query string, and body. It also carries a mutable attributes map that Kona and your middleware use to pass values along (route parameters live there).
  • Response — what you send back: a status code, a string body, and a map of headers. Responses are mutable and chainable, so middleware can adjust them on the way out.

A handler is any function that takes a Request and returns a Response (the Handler interface). Route actions, controller methods, and the end of a middleware chain are all handlers. This page explains both classes in detail; Routing explains how a request reaches your handler, and Kernel & server explains how the bytes on the socket become a Request in the first place.

When the embedded server (KonaHttpServer) receives a call, it builds a Request from the raw exchange:

Field Source
method The HTTP method as sent, for example GET or POST
path The raw request path, without the query string, still percent-encoded (for example /files/a%20b)
headers One entry per header name; when a client repeats a header, only the first value is kept
query The query string parsed into name → value, percent-decoded; a repeated name keeps the last value, and ?flag with no = becomes flag → ""
bodyBytes Raw request body bytes (binary-safe)
body UTF-8 string view of the body (empty when the server only supplied bytes)
attributes Starts empty; the kernel adds route parameters, middleware can add anything

The embedded server stores the wire body as bodyBytes. Text helpers (form for URL-encoded/JSON, logging) use a UTF-8 body string when you construct requests in tests. There is no streaming body API yet, so large uploads are held in memory.

You can also build requests yourself, which is how most HTTP tests in Kona are written — you hand the request straight to the kernel without opening a socket:

val get = Request.get("/users/42")
val form = Request.post("/login", "email=ada%40example.com&password=secret")
val json = Request(
method = "POST",
path = "/users",
headers = mapOf("Content-Type" to "application/json"),
body = """{"name":"Ada","age":36}""",
)

The constructor’s trailing parameters are optional in both languages: Java can call new Request("DELETE", "/users/42") and stop there. Request.post takes the body as a second argument; Kotlin may omit it, Java passes it explicitly (use "" for an empty body).

In Java, the fields are read through getters: getMethod(), getPath(), getHeaders(), getQuery(), getBody(), getAttributes().

Use header(name) to read a header. The lookup is case-insensitive, so header("content-type") and header("Content-Type") return the same value. A second overload supplies a default when the header is absent.

val agent = request.header("User-Agent") // String? — null when missing
val accept = request.header("Accept", "*/*") // falls back to "*/*"

Prefer header(...) over reading the headers map directly. The map keeps the names exactly as the server delivered them, and map lookups are case-sensitive.

Kona gives you three ways to read simple string input, and the difference between them matters when the same name appears in both the query string and the body.

The parsed query string, exactly as described in the table above. request.query["page"] in Kotlin, request.getQuery().get("page") in Java.

form() parses the body into a Map<String, String>:

  • An empty body gives an empty map.
  • If the Content-Type header contains application/json (case-insensitive, so application/json; charset=utf-8 counts), the body is read as a JSON object and flattened to strings: text values stay as-is, null becomes "", and numbers, booleans, arrays, and nested objects become their JSON text (36, true, ["a","b"]). A JSON body that is not an object (for example a top-level array) gives an empty map.
  • If the content type is multipart/form-data, text fields are parsed into the map; file parts are available via file / hasFile / allFiles (see below).
  • For URL-encoded forms (application/x-www-form-urlencoded or a missing content type), the body is parsed as a=1&b=two, with names and values percent-decoded. A repeated field keeps the last value.

form() parses on every call; store the result in a local variable if you read it many times.

  • input(key, default) reads from data() (query + body + any merge overlay) and stringifies the value.
  • all() returns the same bag as strings (null values omitted).
val page = request.input("page", "1")
val search = request.input("q") // default is null
val everything: Map<String, String> = request.all()
API Meaning
only("a", "b") Subset of data() for the listed keys
except("a") data() without those keys
has("a") Key exists (including null)
filled("a") Key exists and is not empty
missing("a") Key is absent
whenHas / whenFilled / whenMissing Run a callback when the condition holds
API Meaning
boolean("on") Truthy for 1 / true / on / yes (Java: booleanValue)
date("when") ISO local date / date-time → LocalDate, or null
bearerToken() Token from Authorization: Bearer …
ip() kona.clientIp after TrustProxies, else kona.remoteAddr
userAgent() User-Agent header

merge(map) copies the current input bag (or natural input on first call) and overlays new values. replace(map) swaps the entire bag. Both affect data(), input, validate, and related helpers for the rest of the request.

validate(rules) runs the Validator and remembers the result. After a successful call, safe() returns that same map. Calling safe() before validate throws IllegalStateException.

form() and all() always give you strings, which is right for HTML forms but loses information for JSON APIs. data() returns Map<String, Any?> with natural JSON types:

JSON value Kotlin / Java type in data()
"Ada" String
36 Int / Integer (larger values become Long or BigInteger)
1.5 Double
true Boolean
null null
[...] List
{...} Map

When the request is JSON (same Content-Type check as above), data() returns the query string merged with the parsed body object, with body keys winning on conflict. For multipart, data() merges query, text fields, and UploadedFile values under their field names. For other requests, data() returns all(), so all values are strings.

For multipart/form-data:

API Meaning
file(name) The UploadedFile, or null
hasFile(name) True when the file is present and valid (non-empty, no upload error)
allFiles() Map of field name → UploadedFile

UploadedFile exposes clientOriginalName, clientMimeType, contents, size, extension(), hashName(), and store / storeAs (writes through a FilesystemAdapter).

val payload = request.data()
val tags = payload["tags"] as? List<*> ?: emptyList<Any>()
val active = payload["active"] == true

Validation reads input through data(), which is why rules such as boolean, array, and integer behave correctly for JSON bodies.

attributes is a mutable map that lives for exactly one request. It is the standard place for one stage of the pipeline to hand data to a later one: an authentication middleware stores the current user, a handler reads it.

request.setAttribute("user", currentUser) // returns the Request, so calls chain
val user = request.getAttribute("user") as User?

The kernel writes route parameters here before your handler runs. For a route /users/{id} and the path /users/42, both getAttribute("id") and getAttribute("route.id") return "42" (always a String). Use the route. form when a plain name could collide with something your middleware stores.

After the router matches a path, the kernel stores each {param} on the request under both param and route.param. Prefer the dedicated helper:

API Behavior
route(key) String value or null
route(key, default) String value or default
val id = request.route("id")!!
val slug = request.route("slug", "home")

Values are always strings from the path. Details and encoding rules: Routing → Path parameters.

Static factories cover the common cases. Body factories set a Content-Type for you; the first three accept an optional status code (default 200).

Factory Body Content-Type / notes
Response.text(body, status) The string as given text/plain; charset=utf-8
Response.html(body, status) The string as given text/html; charset=utf-8
Response.json(payload, status) payload serialized to JSON application/json; charset=utf-8
Response.noContent() Empty, status 204 text/plain; charset=utf-8 (no body is sent)
Response.redirect(to, status) Empty, status 302 by default Sets Location
Response.back(request, fallback, status) Empty redirect Previous URL → Referer → fallback
Response.binary(bytes, contentType, status) Raw bytes Defaults to application/octet-stream
Response.stream(writer, contentType, status) Bytes produced by writing to an OutputStream (buffered in memory for adapters) Defaults to application/octet-stream
`Response.file(bytes path, …)` Inline file bytes
`Response.download(bytes path, filename, …)` Attachment bytes

json accepts any value the bundled Jackson serializer understands: maps, lists, strings, numbers, booleans, null (serialized as null), Kotlin data classes, Java records, and ordinary classes with getters.

Binary factories set bodyBytes() so the embedded server writes the payload without UTF-8 re-encoding. Prefer them for PDFs, images, and anything that is not text.

Chain header(name, value), withHeaders(map), cookie(...), withStatus, and withBody / withBodyBytes on any response. Response.stream lets app code write incrementally to an OutputStream; the result is buffered so every server adapter can emit a complete body today.

data class UserView(val id: Int, val name: String)
router.get("/hello") { Response.text("Hello") }
router.get("/page") { Response.html("<h1>Welcome</h1>") }
router.get("/users/{id}") { Response.json(UserView(42, "Ada")) }
router.post("/users") { Response.json(mapOf("id" to 43), 201) }
router.delete("/users/{id}") { Response.noContent() }
router.post("/save") { Response.redirect("/done").with("status", "Saved") }
router.get("/report.pdf") { Response.download(pdfBytes, "report.pdf") }

Response.redirect("/path") returns a redirect with a Location header. Chain flash helpers for the next request (applied by StartSession):

  • with(key, value) — flash a value
  • withInput(map) — flash _old_input for form redisplay (request.old("field"))
  • withErrors(map) — flash validation errors

Response.back(request) targets the session’s previous URL (recorded on successful GETs), then the Referer header, then /.

Response has public, mutable status, body, and headers, plus binary bodyBytes(). The fluent helpers each change one part and return the same object:

  • header(name, value) sets a header, replacing any earlier value stored under the same name.
  • withStatus(code) changes the status.
  • withBody(content) replaces the text body (clears any binary payload).
  • withBodyBytes(bytes) sets a binary payload for the server to write as-is.
return Response.json(mapOf("ok" to true))
.withStatus(202)
.header("Location", "/jobs/7")
.header("Cache-Control", "no-store")

The no-argument constructor (Response() / new Response()) gives status 200, an empty body, and a text/plain content type; it is handy when middleware builds a response from scratch. In Java, getStatus()/setStatus(...), getBody()/setBody(...), and getHeaders() expose the same fields.

Two details about response headers:

  • Header names in a Response are stored exactly as written. To replace the content type set by a factory, use the same spelling, Content-Type; a different spelling such as content-type adds a second entry instead.
  • A response holds one value per header name. Calling header("Set-Cookie", …) twice keeps only the second value.

Read cookies from the Cookie request header:

API Behavior
cookies() Map of name → value
cookie(name) / cookie(name, default) Single cookie

On the response side, chain cookie(Cookie) or cookie(name, value, …) with path, domain, maxAge, secure, httpOnly, and sameSite options. See the Cookie type in org.konaframework.http.

API Behavior
session() RequestSession from attribute "session" when StartSession ran
old(key) / old(key, default) Flashed old input for form redisplay
expectsJson() True when Accept prefers JSON without HTML, or Content-Type is JSON — drives validation error shape

Flash helpers on redirects (with, withInput, withErrors) are applied when the session saves — see Validation and Session.

After the kernel returns, the server writes every header, then the body encoded as UTF-8 with a matching Content-Length. For 204 No Content responses and for HEAD requests, no body is sent at all. See Kernel & server for the full sequence.

Construction: Request(...), Request.get(path), Request.post(path, body).

Reading: header, query, form, input, all, data, only/except, has/filled/missing, whenHas/whenFilled/whenMissing, boolean, date, bearerToken, ip, userAgent, route, file/hasFile/allFiles, cookies/cookie, session/old, expectsJson, validate/safe, merge/replace, attributes.

Factories: text, html, json, noContent, redirect, back, binary, file, download.

Mutators: header, withHeaders, cookie, withStatus, withBody, withBodyBytes, with, withInput, withErrors.

Why is input("id") empty but the path has an id?
Path parameters are attributes (route("id")), not form/query input.

Why do I get 500 on bad JSON?
Malformed JSON throws from the parser; the default handler treats it as an unexpected error. Catch it yourself for a 400.

Can I stream a large file?
Not yet — file / download / binary load bytes in memory.

  • Streaming request or response bodies
  • Multi-value headers on Request (first wins) or Response (one value per name)
  • Automatic _method override from form bodies
  • Content negotiation beyond what you implement in handlers