
Map URL query strings, path templates and full URLs to serializable data classes, with adapters for existing parsers, strict percent-encoding, array modes, and deterministic encoding.
URL payloads as Kotlin data classes.
kourl is a Kotlin Multiplatform library that implements custom kotlinx.serialization formats for URLs: query strings, path templates, and whole URLs serialize to and from plain @Serializable classes.
kourl is not a URL parser, it's the handy adapter on top of one. Bring the parser your environment already has, and kourl maps its output onto data classes: java.net.URI on the JVM, android.net.Uri on Android, UriComponents in Spring, io.ktor.http.Url everywhere Ktor runs (including Kotlin/Native servers), the WHATWG URL class on Kotlin/JS and Kotlin/Wasm, and the Ada parser's Url. The dependency-free core keeps the same payload class working against all of them.
kourl is published to Maven Central under the io.github.cometkim.kourl group:
dependencies {
implementation("io.github.cometkim.kourl:kourl-core:<version>")
// pick the adapters for your environment:
implementation("io.github.cometkim.kourl:kourl-ktor:<version>")
implementation("io.github.cometkim.kourl:kourl-spring:<version>")
implementation("io.github.cometkim.kourl:kourl-android:<version>")
implementation("io.github.cometkim.kourl:kourl-ada:<version>")
}| Module | Targets | What it adds |
|---|---|---|
kourl-core |
JVM, JS, wasmJs, wasmWasi, iOS, macOS, Linux, Windows |
UrlQuery, UrlPath, UrlFormat + java.net.URI interop (JVM) + WHATWG URL interop (JS/wasmJs) |
kourl-ktor |
same as core except wasmWasi |
io.ktor.http.Url / Parameters interop |
kourl-spring |
JVM |
UriComponents / MultiValueMap interop |
kourl-android |
Android (plain JAR) |
android.net.Uri interop |
kourl-ada |
Kotlin/Native (see note) |
ada-url/kotlin com.adaurl.Url interop |
The contract, explicitly:
\ → / folding or dot-segment removal, validation of untrusted input — is the job of the URL type you already have (URI, Ktor Url, WHATWG URL, Ada, UriComponents). Parse there first, then hand the result to kourl through an adapter.@Serializable classes: keys, defaults, nullability, enums, @SerialName, collections, path template variables.There are two integration primitives, and every adapter is a thin wrapper over one of them:
decodeFromPairs / encodeToPairs. For frameworks that hand you already-decoded parameters (Ktor Parameters, Spring MultiValueMap, or anything else that yields List<Pair<String, String>>). No percent-decoding happens here, so there is no double-decoding risk.decodeFromString / decodeFromUrl / decodeFromPath. For still-percent-encoded input (URI.rawQuery, url.pathname, a WHATWG href, or a hand-written relative reference). kourl splits on &, =, and / per RFC 3986 and percent-decodes and nothing more.The string entry points also make kourl usable standalone where there is no framework at all (Kotlin/Native binaries, wasmWasi), and give encodeToUrl deterministic, strictly-encoded output that is identical on every target.
Rule of thumb: framework-decoded values -> pairs API; encoded strings and URL objects -> string API and adapters.
Passing already-decoded values to decodeFromString would decode them twice.
Properties matching a {variable} in the path template are rendered into the path; everything else becomes the query string.
@Serializable
data class RepoIssues(
val owner: String,
val repo: String,
val state: IssueState = IssueState.Open,
val page: Int = 1,
val labels: List<String> = emptyList(),
)
val format = UrlFormat("/repos/{owner}/{repo}/issues") {
baseUrl = "https://api.github.com" // optional
}
format.encodeToUrl(RepoIssues("cometkim", "kourl", page = 2, labels = listOf("bug")))
// "https://api.github.com/repos/cometkim/kourl/issues?page=2&labels=bug"
format.decodeFromUrl<RepoIssues>("/repos/cometkim/kourl/issues?state=closed")
// RepoIssues(owner=cometkim, repo=kourl, state=Closed, page=1, labels=[])Decoding accepts relative references or absolute URLs; scheme, authority, and fragment are ignored.
A regular StringFormat, used like Json:
@Serializable
data class SearchQuery(val q: String, val page: Int = 1, val size: Int = 20)
UrlQuery.encodeToString(SearchQuery("kotlin", page = 2))
// "q=kotlin&page=2"
UrlQuery.decodeFromString<SearchQuery>("q=hello+world&size=50")
// SearchQuery(q=hello world, page=1, size=50)Configure with the builder, like Json { ... }:
val format = UrlQuery {
encodeDefaults = true // keep properties equal to their default
ignoreUnknownKeys = true // tolerate extra query keys (recommended for inbound URLs)
arrayMode = ArrayMode.Repeat // Repeat: tag=a&tag=b (default)
// Brackets: tag[]=a&tag[]=b
// CommaSeparated: tag=a,b
}@Serializable
data class UserRepo(val owner: String, val repo: String)
val path = UrlPath("/repos/{owner}/{repo}")
path.encodeToPath(UserRepo("cometkim", "kourl")) // "/repos/cometkim/kourl"
path.decodeFromPath<UserRepo>("/repos/cometkim/kourl")Segments are strictly percent-encoded: UserRepo with repo = "c++/stl" renders /repos/cometkim/c%2B%2B%2Fstl and round-trips.
The same @Serializable class works against each environment's URL type.
For absolute or untrusted URLs these adapters are the recommended path, the platform type does the parsing and normalization, kourl reads its components.
For an environment without a dedicated module, integrate via decodeFromPairs/encodeToPairs (decoded values) or the component strings (encoded values).
Basic interops are included in kotlin-core.
For JVM's java.net.URI:
import java.net.URI
format.decodeFromUri<RepoIssues>(URI("https://api.github.com/repos/cometkim/kourl/issues?page=2"))
format.encodeToUri(RepoIssues("cometkim", "kourl")) // java.net.URIFor Browser or Node.js, org.w3c.dom.url.URL from the standard library:
import org.w3c.dom.url.URL
val issues = format.decodeFromUrl<RepoIssues>(URL(window.location.href))
val url: URL = github.encodeToW3cUrl(RepoIssues("cometkim", "kourl"))On wasmJs, kourl declares its own minimal URL external.
Use kourl-ktor for io.ktor.http.Uri:
// Server: decode query parameters of a call
val search = UrlQuery.decodeFromParameters<SearchQuery>(call.request.queryParameters)
// Client: build parameters or whole URLs
val params: Parameters = UrlQuery.encodeToParameters(SearchQuery("kotlin"))
val url: Url = format.encodeToKtorUrl(RepoIssues("cometkim", "kourl"))Use kourl-spring for org.springframework.web.util.UriComponentsBuilder:
@GetMapping("/search")
fun search(@RequestParam params: MultiValueMap<String, String>): List<Result> {
val query = UrlQuery.decodeFromQueryParams<SearchQuery>(params)
// ...
}
val components: UriComponents = format.encodeToUriComponents(RepoIssues("cometkim", "kourl"))Use kourl-android for android.net.Uri
// Decode a deep link straight from the Intent
val issues = format.decodeFromUri<RepoIssues>(intent.data!!)
val uri: Uri = format.encodeToUri(RepoIssues("cometkim", "kourl"))Use kourl-ada for Ada (the WHATWG-compliant URL parser) on Kotlin/Native:
AdaUrl.parse("https://api.github.com/repos/cometkim/kourl/issues?page=2")!!.use { adaUrl ->
val issues = format.decodeFromAdaUrl<RepoIssues>(adaUrl)
}
// Encode + validate/normalize through Ada (caller closes the returned Url)
github.encodeToAdaUrl(RepoIssues("cometkim", "kourl")).use { adaUrl -> adaUrl.href }String, numbers, Boolean, Char), enums (via @SerialName), inline value classesnull is omitted from the URL, and a missing key decodes to null
Lists, represented per ArrayMode
@SerialName renames keys; defaults apply for missing keys; missing required keys failNested classes and maps are intentionally rejected with a descriptive UrlSerializationException.
All failures (malformed percent-encoding, path/template mismatch, unknown keys in strict mode, missing required properties, unparseable values) throw UrlSerializationException, a subclass of SerializationException.
./gradlew build # everything buildable on the current host
./gradlew :kourl-core:allTestsRequires JDK 17+ for compilation (a mise.toml pins Temurin 21 for the Gradle daemon).
MIT
URL payloads as Kotlin data classes.
kourl is a Kotlin Multiplatform library that implements custom kotlinx.serialization formats for URLs: query strings, path templates, and whole URLs serialize to and from plain @Serializable classes.
kourl is not a URL parser, it's the handy adapter on top of one. Bring the parser your environment already has, and kourl maps its output onto data classes: java.net.URI on the JVM, android.net.Uri on Android, UriComponents in Spring, io.ktor.http.Url everywhere Ktor runs (including Kotlin/Native servers), the WHATWG URL class on Kotlin/JS and Kotlin/Wasm, and the Ada parser's Url. The dependency-free core keeps the same payload class working against all of them.
kourl is published to Maven Central under the io.github.cometkim.kourl group:
dependencies {
implementation("io.github.cometkim.kourl:kourl-core:<version>")
// pick the adapters for your environment:
implementation("io.github.cometkim.kourl:kourl-ktor:<version>")
implementation("io.github.cometkim.kourl:kourl-spring:<version>")
implementation("io.github.cometkim.kourl:kourl-android:<version>")
implementation("io.github.cometkim.kourl:kourl-ada:<version>")
}| Module | Targets | What it adds |
|---|---|---|
kourl-core |
JVM, JS, wasmJs, wasmWasi, iOS, macOS, Linux, Windows |
UrlQuery, UrlPath, UrlFormat + java.net.URI interop (JVM) + WHATWG URL interop (JS/wasmJs) |
kourl-ktor |
same as core except wasmWasi |
io.ktor.http.Url / Parameters interop |
kourl-spring |
JVM |
UriComponents / MultiValueMap interop |
kourl-android |
Android (plain JAR) |
android.net.Uri interop |
kourl-ada |
Kotlin/Native (see note) |
ada-url/kotlin com.adaurl.Url interop |
The contract, explicitly:
\ → / folding or dot-segment removal, validation of untrusted input — is the job of the URL type you already have (URI, Ktor Url, WHATWG URL, Ada, UriComponents). Parse there first, then hand the result to kourl through an adapter.@Serializable classes: keys, defaults, nullability, enums, @SerialName, collections, path template variables.There are two integration primitives, and every adapter is a thin wrapper over one of them:
decodeFromPairs / encodeToPairs. For frameworks that hand you already-decoded parameters (Ktor Parameters, Spring MultiValueMap, or anything else that yields List<Pair<String, String>>). No percent-decoding happens here, so there is no double-decoding risk.decodeFromString / decodeFromUrl / decodeFromPath. For still-percent-encoded input (URI.rawQuery, url.pathname, a WHATWG href, or a hand-written relative reference). kourl splits on &, =, and / per RFC 3986 and percent-decodes and nothing more.The string entry points also make kourl usable standalone where there is no framework at all (Kotlin/Native binaries, wasmWasi), and give encodeToUrl deterministic, strictly-encoded output that is identical on every target.
Rule of thumb: framework-decoded values -> pairs API; encoded strings and URL objects -> string API and adapters.
Passing already-decoded values to decodeFromString would decode them twice.
Properties matching a {variable} in the path template are rendered into the path; everything else becomes the query string.
@Serializable
data class RepoIssues(
val owner: String,
val repo: String,
val state: IssueState = IssueState.Open,
val page: Int = 1,
val labels: List<String> = emptyList(),
)
val format = UrlFormat("/repos/{owner}/{repo}/issues") {
baseUrl = "https://api.github.com" // optional
}
format.encodeToUrl(RepoIssues("cometkim", "kourl", page = 2, labels = listOf("bug")))
// "https://api.github.com/repos/cometkim/kourl/issues?page=2&labels=bug"
format.decodeFromUrl<RepoIssues>("/repos/cometkim/kourl/issues?state=closed")
// RepoIssues(owner=cometkim, repo=kourl, state=Closed, page=1, labels=[])Decoding accepts relative references or absolute URLs; scheme, authority, and fragment are ignored.
A regular StringFormat, used like Json:
@Serializable
data class SearchQuery(val q: String, val page: Int = 1, val size: Int = 20)
UrlQuery.encodeToString(SearchQuery("kotlin", page = 2))
// "q=kotlin&page=2"
UrlQuery.decodeFromString<SearchQuery>("q=hello+world&size=50")
// SearchQuery(q=hello world, page=1, size=50)Configure with the builder, like Json { ... }:
val format = UrlQuery {
encodeDefaults = true // keep properties equal to their default
ignoreUnknownKeys = true // tolerate extra query keys (recommended for inbound URLs)
arrayMode = ArrayMode.Repeat // Repeat: tag=a&tag=b (default)
// Brackets: tag[]=a&tag[]=b
// CommaSeparated: tag=a,b
}@Serializable
data class UserRepo(val owner: String, val repo: String)
val path = UrlPath("/repos/{owner}/{repo}")
path.encodeToPath(UserRepo("cometkim", "kourl")) // "/repos/cometkim/kourl"
path.decodeFromPath<UserRepo>("/repos/cometkim/kourl")Segments are strictly percent-encoded: UserRepo with repo = "c++/stl" renders /repos/cometkim/c%2B%2B%2Fstl and round-trips.
The same @Serializable class works against each environment's URL type.
For absolute or untrusted URLs these adapters are the recommended path, the platform type does the parsing and normalization, kourl reads its components.
For an environment without a dedicated module, integrate via decodeFromPairs/encodeToPairs (decoded values) or the component strings (encoded values).
Basic interops are included in kotlin-core.
For JVM's java.net.URI:
import java.net.URI
format.decodeFromUri<RepoIssues>(URI("https://api.github.com/repos/cometkim/kourl/issues?page=2"))
format.encodeToUri(RepoIssues("cometkim", "kourl")) // java.net.URIFor Browser or Node.js, org.w3c.dom.url.URL from the standard library:
import org.w3c.dom.url.URL
val issues = format.decodeFromUrl<RepoIssues>(URL(window.location.href))
val url: URL = github.encodeToW3cUrl(RepoIssues("cometkim", "kourl"))On wasmJs, kourl declares its own minimal URL external.
Use kourl-ktor for io.ktor.http.Uri:
// Server: decode query parameters of a call
val search = UrlQuery.decodeFromParameters<SearchQuery>(call.request.queryParameters)
// Client: build parameters or whole URLs
val params: Parameters = UrlQuery.encodeToParameters(SearchQuery("kotlin"))
val url: Url = format.encodeToKtorUrl(RepoIssues("cometkim", "kourl"))Use kourl-spring for org.springframework.web.util.UriComponentsBuilder:
@GetMapping("/search")
fun search(@RequestParam params: MultiValueMap<String, String>): List<Result> {
val query = UrlQuery.decodeFromQueryParams<SearchQuery>(params)
// ...
}
val components: UriComponents = format.encodeToUriComponents(RepoIssues("cometkim", "kourl"))Use kourl-android for android.net.Uri
// Decode a deep link straight from the Intent
val issues = format.decodeFromUri<RepoIssues>(intent.data!!)
val uri: Uri = format.encodeToUri(RepoIssues("cometkim", "kourl"))Use kourl-ada for Ada (the WHATWG-compliant URL parser) on Kotlin/Native:
AdaUrl.parse("https://api.github.com/repos/cometkim/kourl/issues?page=2")!!.use { adaUrl ->
val issues = format.decodeFromAdaUrl<RepoIssues>(adaUrl)
}
// Encode + validate/normalize through Ada (caller closes the returned Url)
github.encodeToAdaUrl(RepoIssues("cometkim", "kourl")).use { adaUrl -> adaUrl.href }String, numbers, Boolean, Char), enums (via @SerialName), inline value classesnull is omitted from the URL, and a missing key decodes to null
Lists, represented per ArrayMode
@SerialName renames keys; defaults apply for missing keys; missing required keys failNested classes and maps are intentionally rejected with a descriptive UrlSerializationException.
All failures (malformed percent-encoding, path/template mismatch, unknown keys in strict mode, missing required properties, unparseable values) throw UrlSerializationException, a subclass of SerializationException.
./gradlew build # everything buildable on the current host
./gradlew :kourl-core:allTestsRequires JDK 17+ for compilation (a mise.toml pins Temurin 21 for the Gradle daemon).
MIT