Skip to content

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:

PriorityConditionSource
1The parameter name matches a {placeholder} in the route pathPath
2The method is GET and the parameter is a simple type (String, Int, Long, Boolean, …)Query
3The method is POST / PUT / PATCH and the parameter is a complex type (a @Serializable data class)Body
4The parameter type is HttpContext, HttpRequest, HttpResponse or CtxContext 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:

kotlin
@Controller("/api/binding")
class ParameterBindingController {

    @Get("/users/{userId}")
    fun pathParam(userId: Int) =
        "path parameter userId: $userId"
}
bash
curl http://localhost:8080/api/binding/users/42
# path parameter userId: 42

Path values are converted automatically from String to Int, Long, Boolean and so on.

When the parameter name differs from the placeholder, name it explicitly:

kotlin
@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:

kotlin
@Controller("/api/binding")
class ParameterBindingController {

    @Get("/search")
    fun search(keyword: String, page: Int = 1, size: Int = 10) =
        "keyword: '$keyword', page: $page, size: $size"
}
bash
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: 10

Repeated values

Query parameters support multiple values, as in ?tags=a&tags=b:

kotlin
@Controller("/api/binding")
class ParameterBindingController {

    @Get("/filters")
    fun filters(tags: List<String>, ids: List<Int>?) =
        "tags: ${tags.joinToString(", ")}, ids: ${ids?.joinToString(", ") ?: "null"}"
}
bash
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: null

Request bodies

On POST / PUT / PATCH requests, a complex type binds to the body:

kotlin
@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}"
}
bash
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: 28

Serialization

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:

kotlin
@Post("/user")
fun createUser(@Body user: CreateUserRequest): String {
    return "Created: ${user.name}, ${user.email}"
}

Form fields

Form fields require @FormParam:

kotlin
@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"
    }
}
bash
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: 28

File uploads (multipart)

Parameters typed UploadFile, List<UploadFile> or UploadFiles bind multipart files, matching the form field name against the parameter name.

A single file

kotlin
@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
        )
    }
}
bash
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

kotlin
@Post("/photos")
suspend fun uploadPhotos(photos: List<UploadFile>): Map<String, Any> {
    return mapOf("count" to photos.size, "names" to photos.map { it.filename })
}
bash
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:

kotlin
@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

DeclarationBehaviour
avatar: UploadFileMatches 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: UploadFilesThe full structured view of all files

Headers

kotlin
@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'"
    }
}
bash
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

kotlin
@Controller("/api/binding")
class ParameterBindingController {

    @Get("/cookies")
    fun cookieParam(
        @Cookie("sessionId") sessionId: String?,
        @Cookie("theme") theme: String = "light"
    ): String {
        return "sessionId: '$sessionId', theme: '$theme'"
    }
}
bash
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:

TypeWhat it is
HttpContextThe full context: request, response, session
HttpRequestThe request: method, path, headers
HttpResponseThe response: status, headers
HttpSessionThe session
CtxA type alias for HttpContext
IdentityThe authenticated user, injected automatically (equivalent to @CurrentUser)
kotlin
@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

kotlin
@Get("/optional")
fun optional(
    required: String,
    optional: String?,
    @Header("X-Optional") header: String? = null
) = "required: '$required', optional: '$optional', header: '${header ?: "default"}'"

Default values

kotlin
@Get("/search")
fun search(keyword: String, page: Int = 1, size: Int = 10) =
    "keyword: '$keyword', page: $page, size: $size"

Summary

DeclarationBehaviour when absent
param: StringRequired; the request fails with 400
param: String?Optional; bound as null
param: String = "default"The default is used
param: String? = nullOptional, with an explicit default

Annotation reference

AnnotationSourceRequired?Example
(none)Inferred: path / query / bodyfun get(id: Int)
@PathVariable("name")A path segmentWhen the parameter name differs from the placeholder@PathVariable("id") userId: Int
@BodyThe JSON request bodyOnly to be explicit@Body user: UserRequest
@FormParam("name")A form fieldYes@FormParam("username") name: String
@Header("name")A request headerYes@Header("User-Agent") ua: String
@Cookie("name")A cookieYes@Cookie("sessionId") sid: String?
UploadFileA multipart file, matched by field nameNo — recognised by typeavatar: UploadFile
List<UploadFile>Every multipart file under one fieldNo — recognised by typephotos: List<UploadFile>
UploadFilesThe structured view of all filesNo — recognised by typefiles: UploadFiles
@CurrentUserThe authenticated userOptional — Identity is injected by type@CurrentUser identity: Identity?

Further reading

Neton Framework Documentation