
Parses, builds, and normalizes URIs and URLs to standards, returning errors as values; immutable builders, structured query editing, RFC 6570 templates, IDNA host processing, annotation binding.
A standards-faithful URI and URL library for Kotlin Multiplatform and Java.
kuri parses, builds, and normalizes URIs and URLs to the letter of the standards — RFC 3986, the WHATWG URL Standard, and UTS #46 for internationalized hosts. Parsing returns a result instead of throwing, values are immutable, and the whole API reads naturally from both Kotlin and Java. It runs everywhere Kotlin does — JVM, Android, JS, Wasm, and native.
java.net.URI is specified against the superseded RFC 2396, java.net.URL
barely validates and its equals() makes a blocking DNS call to compare two values, and neither exists off
the JVM.// build.gradle.kts — Kotlin Multiplatform or Kotlin/JVM
dependencies {
implementation("org.dexpace:kuri:0.1.0")
}Published to Maven Central as org.dexpace:kuri; Gradle module metadata resolves the correct per-platform
variant automatically. For Maven or plain JVM projects, depend on the kuri-jvm artifact:
<dependency>
<groupId>org.dexpace</groupId>
<artifactId>kuri-jvm</artifactId>
<version>0.1.0</version>
</dependency>Runs on Java 8+ and Kotlin 2.0+, with no runtime dependencies beyond the Kotlin standard library.
[!NOTE] kuri is in the
0.xseries — the public API is not yet frozen and may change between minor releases, so pin to an exact version.
kuri ships as three artifacts under org.dexpace, so you pull in only what you use:
kuri — the core engine: the Url and Uri models, parsing, building, the query API, and the
standalone Percent / Idn / Schemes utilities. Kotlin Multiplatform (JVM, Android, JS, Wasm, native).
This is the only module the quick start needs.kuri-bind — maps an annotated request object onto a Url/Uri builder (@Url, @Path, @Query,
…). JVM-only, since it uses Kotlin reflection; the core stays dependency-free. See
Annotation binding.kuri-serde-kotlinx — a kotlinx.serialization
bridge: Url/Uri serializers plus a query-parameters format. Multiplatform across the same targets as
kuri, minus Android. See
kotlinx.serialization.The optional modules depend on the core, and you add them the same way:
dependencies {
implementation("org.dexpace:kuri:0.1.0")
implementation("org.dexpace:kuri-bind:0.1.0") // optional — annotation binding
implementation("org.dexpace:kuri-serde-kotlinx:0.1.0") // optional — kotlinx.serialization
}import org.dexpace.kuri.Url
val url = Url.parse("https://Example.com:443/a/../b?q=1#frag").getOrThrow()
url.scheme // "https"
url.hostName // "example.com" — lower-cased
url.port // null — the default :443 is elided
url.effectivePort // 443
url.pathSegments // ["b"] — /a/../b is resolved
url.queryParameters.get("q") // "1"
url.toString() // "https://example.com/b?q=1#frag"The same read from Java — static factories, () accessors, no Kotlin-only syntax:
import org.dexpace.kuri.Url;
Url url = Url.parseOrThrow("https://Example.com:443/a/../b?q=1#frag");
url.scheme(); // "https"
url.hostName(); // "example.com"
url.port(); // null (Integer) — the default :443 is elided
url.effectivePort(); // 443
url.pathSegments(); // ["b"]
url.queryParameters().get("q"); // "1"
url.toString(); // "https://example.com/b?q=1#frag"kuri gives you two profiles over one engine. Reach for Url for WHATWG web URLs — http, https, ws,
wss, file — the input a browser or HTTP client hands you; it canonicalizes eagerly and always carries a
scheme. Reach for Uri for RFC 3986 generic identifiers — urn:, mailto:, custom schemes, and
relative references; it preserves the input exactly and normalizes only when you ask.
Uri.parseOrThrow("HTTP://Example.com/a/../b").toString() // "HTTP://Example.com/a/../b" — verbatim
Url.parseOrThrow("HTTP://Example.com/a/../b").toString() // "http://example.com/b" — canonicalizedThe guide covers the full comparison, the accessor differences to watch for, IPv6 zone identifiers, and IRI conversion: Two models, one engine.
Immutable builders. Values are immutable. newBuilder() returns a builder pre-populated from an existing
value, so a parse → modify → build round-trip mutates nothing. build() validates and throws on an
invalid combination; buildOrNull() returns null instead.
Url.parseOrThrow("https://example.com/v1/users?page=1")
.newBuilder()
.addPathSegment("42")
.setQueryParameter("page", "2")
.build() // https://example.com/v1/users/42?page=2Non-throwing parse. parse returns a sealed ParseResult<T> (Ok/Err) — errors are values, and the
Err branch carries a structured UriParseError. parseOrNull, parseOrThrow, and canParse cover the
null, exception, and boolean cases.
when (val r = Url.parse(input)) {
is ParseResult.Ok -> r.value.hostName
is ParseResult.Err -> r.error.message // structured reason, not a bare exception
}Structured query editing. queryParameters is a duplicate-preserving, iterable view; the builder edits
follow URLSearchParams semantics, and split(name, delimiter) flattens repeated pairs and delimited lists
into one list.
val q = Url.parseOrThrow("https://h/?id=1,2&id=3").queryParameters
q.split("id", ',').map(String::toInt) // [1, 2, 3]Kotlin operator DSL. The optional org.dexpace.kuri.ktx package overloads / to append a
percent-encoded path segment and + to add a query parameter, and adds buildUrl { } / edit { } builder
lambdas. Nothing here is visible to Java.
val api = Url.parseOrThrow("https://api.example.com/v1")
api / "users" / "42" + ("page" to "2") // https://api.example.com/v1/users/42?page=2RFC 6570 URI templates. UriTemplate compiles a template once and expands it against a variable map.
All four RFC levels are supported, including the ? & # . / ; + operators and the prefix (:n)
and explode (*) modifiers.
UriTemplate.parse("https://api.example.com/users/{id}{?fields*}")
.expand(mapOf("id" to 42, "fields" to listOf("name", "email")))
// https://api.example.com/users/42?fields=name&fields=emailAnnotation binding (kuri-bind). A JVM module that maps an annotated request object onto a Url/Uri
builder by reflection: declare the mapping with @Url/@Path/@Query/@PathTemplate, then bind any
instance onto a base URL.
@Url @PathTemplate("/repos/{owner}/{repo}/issues")
data class Issues(
@Path("owner") val owner: String,
@Path("repo") val repo: String,
@Query("state") val state: String,
)
val apiBase = Url.parseOrThrow("https://api.example.com")
KuriBind.bindInto(apiBase.newBuilder(), Issues("dexpace", "kuri", "open")).build()
// https://api.example.com/repos/dexpace/kuri/issues?state=openkotlinx.serialization bridge (kuri-serde-kotlinx). QueryParametersFormat decodes a query string into
a flat @Serializable class and encodes it back; UrlSerializer / UriSerializer serialize values as their
string form in any kotlinx format.
@Serializable
data class Search(val q: String, val page: Int = 1, val tags: List<String> = emptyList())
QueryParametersFormat.decodeFromQueryString<Search>("q=kotlin&page=2&tags=a&tags=b")
// Search(q = "kotlin", page = 2, tags = ["a", "b"])Beyond these: UTS-46 IDNA host processing, redact() for credential-safe logging, resolve / relativize
against a base, and configurable parse resource limits. The user guide documents each in full.
docs/SPEC.md — the normative behavior specification (character repertoire, encoding matrix, host pipeline, parsing algorithm, resolution, query model, normalization, and error model)../gradlew :kuri:dokkaGeneratePublicationHtml.CHANGELOG.md — notable changes per release, in Keep a Changelog format.Issues and pull requests are welcome. ./gradlew build — the full quality gate and conformance baselines
included — must pass before a change can merge, and a public-API change must commit the regenerated api/
snapshot (see Building from source). New or changed behavior should be
grounded in the relevant standard and reflected in docs/SPEC.md. Commits and pull-request
titles use the feat: / fix: / docs: / chore: prefix convention.
kuri parses and canonicalizes untrusted URLs, so security reports are taken seriously. See
SECURITY.md for supported versions and how to report a vulnerability privately through
GitHub's Security tab — please don't open a public issue for a security problem.
kuri is released under the MIT License, Copyright (c) 2026 dexpace.
A standards-faithful URI and URL library for Kotlin Multiplatform and Java.
kuri parses, builds, and normalizes URIs and URLs to the letter of the standards — RFC 3986, the WHATWG URL Standard, and UTS #46 for internationalized hosts. Parsing returns a result instead of throwing, values are immutable, and the whole API reads naturally from both Kotlin and Java. It runs everywhere Kotlin does — JVM, Android, JS, Wasm, and native.
java.net.URI is specified against the superseded RFC 2396, java.net.URL
barely validates and its equals() makes a blocking DNS call to compare two values, and neither exists off
the JVM.// build.gradle.kts — Kotlin Multiplatform or Kotlin/JVM
dependencies {
implementation("org.dexpace:kuri:0.1.0")
}Published to Maven Central as org.dexpace:kuri; Gradle module metadata resolves the correct per-platform
variant automatically. For Maven or plain JVM projects, depend on the kuri-jvm artifact:
<dependency>
<groupId>org.dexpace</groupId>
<artifactId>kuri-jvm</artifactId>
<version>0.1.0</version>
</dependency>Runs on Java 8+ and Kotlin 2.0+, with no runtime dependencies beyond the Kotlin standard library.
[!NOTE] kuri is in the
0.xseries — the public API is not yet frozen and may change between minor releases, so pin to an exact version.
kuri ships as three artifacts under org.dexpace, so you pull in only what you use:
kuri — the core engine: the Url and Uri models, parsing, building, the query API, and the
standalone Percent / Idn / Schemes utilities. Kotlin Multiplatform (JVM, Android, JS, Wasm, native).
This is the only module the quick start needs.kuri-bind — maps an annotated request object onto a Url/Uri builder (@Url, @Path, @Query,
…). JVM-only, since it uses Kotlin reflection; the core stays dependency-free. See
Annotation binding.kuri-serde-kotlinx — a kotlinx.serialization
bridge: Url/Uri serializers plus a query-parameters format. Multiplatform across the same targets as
kuri, minus Android. See
kotlinx.serialization.The optional modules depend on the core, and you add them the same way:
dependencies {
implementation("org.dexpace:kuri:0.1.0")
implementation("org.dexpace:kuri-bind:0.1.0") // optional — annotation binding
implementation("org.dexpace:kuri-serde-kotlinx:0.1.0") // optional — kotlinx.serialization
}import org.dexpace.kuri.Url
val url = Url.parse("https://Example.com:443/a/../b?q=1#frag").getOrThrow()
url.scheme // "https"
url.hostName // "example.com" — lower-cased
url.port // null — the default :443 is elided
url.effectivePort // 443
url.pathSegments // ["b"] — /a/../b is resolved
url.queryParameters.get("q") // "1"
url.toString() // "https://example.com/b?q=1#frag"The same read from Java — static factories, () accessors, no Kotlin-only syntax:
import org.dexpace.kuri.Url;
Url url = Url.parseOrThrow("https://Example.com:443/a/../b?q=1#frag");
url.scheme(); // "https"
url.hostName(); // "example.com"
url.port(); // null (Integer) — the default :443 is elided
url.effectivePort(); // 443
url.pathSegments(); // ["b"]
url.queryParameters().get("q"); // "1"
url.toString(); // "https://example.com/b?q=1#frag"kuri gives you two profiles over one engine. Reach for Url for WHATWG web URLs — http, https, ws,
wss, file — the input a browser or HTTP client hands you; it canonicalizes eagerly and always carries a
scheme. Reach for Uri for RFC 3986 generic identifiers — urn:, mailto:, custom schemes, and
relative references; it preserves the input exactly and normalizes only when you ask.
Uri.parseOrThrow("HTTP://Example.com/a/../b").toString() // "HTTP://Example.com/a/../b" — verbatim
Url.parseOrThrow("HTTP://Example.com/a/../b").toString() // "http://example.com/b" — canonicalizedThe guide covers the full comparison, the accessor differences to watch for, IPv6 zone identifiers, and IRI conversion: Two models, one engine.
Immutable builders. Values are immutable. newBuilder() returns a builder pre-populated from an existing
value, so a parse → modify → build round-trip mutates nothing. build() validates and throws on an
invalid combination; buildOrNull() returns null instead.
Url.parseOrThrow("https://example.com/v1/users?page=1")
.newBuilder()
.addPathSegment("42")
.setQueryParameter("page", "2")
.build() // https://example.com/v1/users/42?page=2Non-throwing parse. parse returns a sealed ParseResult<T> (Ok/Err) — errors are values, and the
Err branch carries a structured UriParseError. parseOrNull, parseOrThrow, and canParse cover the
null, exception, and boolean cases.
when (val r = Url.parse(input)) {
is ParseResult.Ok -> r.value.hostName
is ParseResult.Err -> r.error.message // structured reason, not a bare exception
}Structured query editing. queryParameters is a duplicate-preserving, iterable view; the builder edits
follow URLSearchParams semantics, and split(name, delimiter) flattens repeated pairs and delimited lists
into one list.
val q = Url.parseOrThrow("https://h/?id=1,2&id=3").queryParameters
q.split("id", ',').map(String::toInt) // [1, 2, 3]Kotlin operator DSL. The optional org.dexpace.kuri.ktx package overloads / to append a
percent-encoded path segment and + to add a query parameter, and adds buildUrl { } / edit { } builder
lambdas. Nothing here is visible to Java.
val api = Url.parseOrThrow("https://api.example.com/v1")
api / "users" / "42" + ("page" to "2") // https://api.example.com/v1/users/42?page=2RFC 6570 URI templates. UriTemplate compiles a template once and expands it against a variable map.
All four RFC levels are supported, including the ? & # . / ; + operators and the prefix (:n)
and explode (*) modifiers.
UriTemplate.parse("https://api.example.com/users/{id}{?fields*}")
.expand(mapOf("id" to 42, "fields" to listOf("name", "email")))
// https://api.example.com/users/42?fields=name&fields=emailAnnotation binding (kuri-bind). A JVM module that maps an annotated request object onto a Url/Uri
builder by reflection: declare the mapping with @Url/@Path/@Query/@PathTemplate, then bind any
instance onto a base URL.
@Url @PathTemplate("/repos/{owner}/{repo}/issues")
data class Issues(
@Path("owner") val owner: String,
@Path("repo") val repo: String,
@Query("state") val state: String,
)
val apiBase = Url.parseOrThrow("https://api.example.com")
KuriBind.bindInto(apiBase.newBuilder(), Issues("dexpace", "kuri", "open")).build()
// https://api.example.com/repos/dexpace/kuri/issues?state=openkotlinx.serialization bridge (kuri-serde-kotlinx). QueryParametersFormat decodes a query string into
a flat @Serializable class and encodes it back; UrlSerializer / UriSerializer serialize values as their
string form in any kotlinx format.
@Serializable
data class Search(val q: String, val page: Int = 1, val tags: List<String> = emptyList())
QueryParametersFormat.decodeFromQueryString<Search>("q=kotlin&page=2&tags=a&tags=b")
// Search(q = "kotlin", page = 2, tags = ["a", "b"])Beyond these: UTS-46 IDNA host processing, redact() for credential-safe logging, resolve / relativize
against a base, and configurable parse resource limits. The user guide documents each in full.
docs/SPEC.md — the normative behavior specification (character repertoire, encoding matrix, host pipeline, parsing algorithm, resolution, query model, normalization, and error model)../gradlew :kuri:dokkaGeneratePublicationHtml.CHANGELOG.md — notable changes per release, in Keep a Changelog format.Issues and pull requests are welcome. ./gradlew build — the full quality gate and conformance baselines
included — must pass before a change can merge, and a public-API change must commit the regenerated api/
snapshot (see Building from source). New or changed behavior should be
grounded in the relevant standard and reflected in docs/SPEC.md. Commits and pull-request
titles use the feat: / fix: / docs: / chore: prefix convention.
kuri parses and canonicalizes untrusted URLs, so security reports are taken seriously. See
SECURITY.md for supported versions and how to report a vulnerability privately through
GitHub's Security tab — please don't open a public issue for a security problem.
kuri is released under the MIT License, Copyright (c) 2026 dexpace.