Parameter binding
Neton follows convention over configuration: in roughly 90% of handlers, arguments bind with no annotations at all.
Design principles
- Convention over configuration — path, query and body arguments are inferred in most cases
- Explicit where it matters — headers, cookies and form fields are unusual sources, so they need an annotation
- Type safe — all binding code is generated by KSP at compile time, with no reflection at runtime
- Optionality is Kotlin's — nullable types and default values express optional arguments naturally
Inference rules
KSP resolves each parameter's source in this order:
| Priority | Condition | Source |
|---|---|---|
| 1 | The parameter name matches a {placeholder} in the route path | Path |
| 2 | The method is GET and the parameter is a simple type (String, Int, Long, Boolean, …) | Query |
| 3 | The method is POST / PUT / PATCH and the parameter is a complex type (a @Serializable data class) | Body |
| 4 | The parameter type is HttpContext, HttpRequest, HttpResponse or Ctx | Context injection |
Anything else needs an explicit annotation such as @Header, @Cookie or @FormParam.
Path parameters
A parameter whose name matches a {...} placeholder binds to it automatically:
@Controller("/api/binding")
class ParameterBindingController {
@Get("/users/{userId}")
fun pathParam(userId: Int) =
"path parameter userId: $userId"
}curl http://localhost:8080/api/binding/users/42
# path parameter userId: 42Path values are converted automatically from String to Int, Long, Boolean and so on.
When the parameter name differs from the placeholder, name it explicitly:
@Get("/user/{id}")
fun getUser(@PathVariable("id") userId: Int): String {
return "User ID: $userId"
}This alias matters elsewhere
The alias — id above — is the name the value is stored under at runtime. If you also use @Cacheable or @Lock on the handler, the key template must reference {id}, not {userId}. Getting this wrong is a compile error.
Query parameters
On GET requests, simple types bind to query parameters:
@Controller("/api/binding")
class ParameterBindingController {
@Get("/search")
fun search(keyword: String, page: Int = 1, size: Int = 10) =
"keyword: '$keyword', page: $page, size: $size"
}curl "http://localhost:8080/api/binding/search?keyword=neton&page=2&size=20"
# keyword: 'neton', page: 2, size: 20
# falling back to defaults
curl "http://localhost:8080/api/binding/search?keyword=neton"
# keyword: 'neton', page: 1, size: 10Repeated values
Query parameters support multiple values, as in ?tags=a&tags=b:
@Controller("/api/binding")
class ParameterBindingController {
@Get("/filters")
fun filters(tags: List<String>, ids: List<Int>?) =
"tags: ${tags.joinToString(", ")}, ids: ${ids?.joinToString(", ") ?: "null"}"
}curl "http://localhost:8080/api/binding/filters?tags=kotlin&tags=native&ids=1&ids=2"
# tags: kotlin, native, ids: 1, 2
curl "http://localhost:8080/api/binding/filters?tags=kotlin"
# tags: kotlin, ids: nullRequest bodies
On POST / PUT / PATCH requests, a complex type binds to the body:
@Serializable
data class BindingUserRequest(
val name: String,
val email: String,
val age: Int? = null
)
@Controller("/api/binding")
class ParameterBindingController {
@Post("/json")
fun create(req: BindingUserRequest) =
"name: '${req.name}', email: '${req.email}', age: ${req.age}"
}curl -X POST http://localhost:8080/api/binding/json \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com","age":28}'
# name: 'Alice', email: 'alice@example.com', age: 28Serialization
Body classes must carry @Serializable from kotlinx.serialization. Neton deserializes JSON, and a request without application/json is rejected with 415.
To be explicit, use @Body:
@Post("/user")
fun createUser(@Body user: CreateUserRequest): String {
return "Created: ${user.name}, ${user.email}"
}Form fields
Form fields require @FormParam:
@Controller("/api/binding")
class ParameterBindingController {
@Post("/form")
fun formParam(
@FormParam("username") username: String,
@FormParam("email") email: String,
@FormParam("age") age: Int?
): String {
return "username: '$username', email: '$email', age: $age"
}
}curl -X POST http://localhost:8080/api/binding/form \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=alice&email=alice@example.com&age=28"
# username: 'alice', email: 'alice@example.com', age: 28File uploads (multipart)
Parameters typed UploadFile, List<UploadFile> or UploadFiles bind multipart files, matching the form field name against the parameter name.
A single file
@Controller("/api/files")
class FileController {
@Post("/avatar")
suspend fun uploadAvatar(avatar: UploadFile): Map<String, Any> {
val bytes = avatar.bytes()
return mapOf(
"filename" to avatar.filename,
"contentType" to (avatar.contentType ?: "unknown"),
"size" to avatar.size
)
}
}curl -X POST http://localhost:8080/api/files/avatar \
-F "avatar=@photo.jpg"
# {"filename":"photo.jpg","contentType":"image/jpeg","size":12345}Several files under one field
@Post("/photos")
suspend fun uploadPhotos(photos: List<UploadFile>): Map<String, Any> {
return mapOf("count" to photos.size, "names" to photos.map { it.filename })
}curl -X POST http://localhost:8080/api/files/photos \
-F "photos=@a.jpg" -F "photos=@b.jpg"The structured view
When several distinct field names are involved, take UploadFiles:
@Post("/mixed")
suspend fun mixedUpload(files: UploadFiles): Map<String, Any> {
val avatar = files.require("avatar") // required; 400 when missing
val gallery = files.get("gallery") // filtered by field name
val all = files.all() // everything
return mapOf(
"avatar" to avatar.filename,
"galleryCount" to gallery.size,
"totalCount" to all.size
)
}Binding rules
| Declaration | Behaviour |
|---|---|
avatar: UploadFile | Matches field avatar; 400 when missing |
avatar: UploadFile? | Matches field avatar; null when missing |
photos: List<UploadFile> | Every file under field photos; an empty list when there are none |
files: UploadFiles | The full structured view of all files |
Headers
@Controller("/api/binding")
class ParameterBindingController {
@Get("/headers")
fun headerParam(
@Header("User-Agent") userAgent: String,
@Header("Accept-Language") language: String = "en",
@Header("X-Custom-Header") customHeader: String?
): String {
return "User-Agent: '$userAgent', Language: '$language', Custom: '$customHeader'"
}
}curl http://localhost:8080/api/binding/headers \
-H "Accept-Language: zh-CN" \
-H "X-Custom-Header: my-value"
# User-Agent: 'curl/8.x', Language: 'zh-CN', Custom: 'my-value'Cookies
@Controller("/api/binding")
class ParameterBindingController {
@Get("/cookies")
fun cookieParam(
@Cookie("sessionId") sessionId: String?,
@Cookie("theme") theme: String = "light"
): String {
return "sessionId: '$sessionId', theme: '$theme'"
}
}curl http://localhost:8080/api/binding/cookies \
-b "sessionId=abc123;theme=dark"
# sessionId: 'abc123', theme: 'dark'Context injection
Declare any of these types and the framework supplies them:
| Type | What it is |
|---|---|
HttpContext | The full context: request, response, session |
HttpRequest | The request: method, path, headers |
HttpResponse | The response: status, headers |
HttpSession | The session |
Ctx | A type alias for HttpContext |
Identity | The authenticated user, injected automatically (equivalent to @CurrentUser) |
@Put("/complex/{resourceId}")
fun complex(
resourceId: String,
version: Int = 1,
@Header("Authorization") auth: String?,
@FormParam("action") action: String,
ctx: Ctx
) = "resourceId: $resourceId, version: $version, ctx: ${ctx::class.simpleName}"Sources in that one signature:
resourceId— path (inferred)version— query (inferred, with a default)auth— header (explicit)action— form field (explicit)ctx— context injection
Optional arguments and defaults
Nullable types
@Get("/optional")
fun optional(
required: String,
optional: String?,
@Header("X-Optional") header: String? = null
) = "required: '$required', optional: '$optional', header: '${header ?: "default"}'"Default values
@Get("/search")
fun search(keyword: String, page: Int = 1, size: Int = 10) =
"keyword: '$keyword', page: $page, size: $size"Summary
| Declaration | Behaviour when absent |
|---|---|
param: String | Required; the request fails with 400 |
param: String? | Optional; bound as null |
param: String = "default" | The default is used |
param: String? = null | Optional, with an explicit default |
Annotation reference
| Annotation | Source | Required? | Example |
|---|---|---|---|
| (none) | Inferred: path / query / body | — | fun get(id: Int) |
@PathVariable("name") | A path segment | When the parameter name differs from the placeholder | @PathVariable("id") userId: Int |
@Body | The JSON request body | Only to be explicit | @Body user: UserRequest |
@FormParam("name") | A form field | Yes | @FormParam("username") name: String |
@Header("name") | A request header | Yes | @Header("User-Agent") ua: String |
@Cookie("name") | A cookie | Yes | @Cookie("sessionId") sid: String? |
UploadFile | A multipart file, matched by field name | No — recognised by type | avatar: UploadFile |
List<UploadFile> | Every multipart file under one field | No — recognised by type | photos: List<UploadFile> |
UploadFiles | The structured view of all files | No — recognised by type | files: UploadFiles |
@CurrentUser | The authenticated user | Optional — Identity is injected by type | @CurrentUser identity: Identity? |
Further reading
- Routing and controllers — controllers and route groups
- Security guide —
@CurrentUserand the identity model