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.
- URL parsing looks trivial and almost never is. The parts most parsers skip (percent-encoding, IDNA hosts, dot-segment resolution, port elision) are where the bugs hide.
- RFC 3986 and the WHATWG URL Standard are different specs with different rules. When two parts of a system read the same URL differently, that gap becomes an SSRF filter bypass, an origin mix-up, a poisoned cache key.
- The JVM's built-ins don't help:
java.net.URIis specified against the superseded RFC 2396,java.net.URLbarely validates and itsequals()makes a blocking DNS call to compare two values, and neither exists off the JVM. - That leaves a gap on Kotlin Multiplatform: the standard library has no URL type, so shared code either drops to a JVM-only parser and loses the other targets, or reimplements parsing per platform and drifts.
- kuri closes it: one parser in common Kotlin, checked against the standards' own conformance suites, so you get the same result on JVM, Android, JS, Wasm, and native, with errors as values and your choice of spec.
// 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.x series — 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: theUrlandUrimodels, parsing, building, the query API, and the standalonePercent/Idn/Schemesutilities. Kotlin Multiplatform (JVM, Android, JS, Wasm, native). This is the only module the quick start needs.kuri-bind— maps an annotated request object onto aUrl/Uribuilder (@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/Uriserializers plus a query-parameters format. Multiplatform across the same targets askuri, 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.
- User guide — the complete usage reference: parsing & errors, building & resolving, utilities, recipes, URI templates, Kotlin DSL, annotation binding, kotlinx.serialization, and standards & conformance.
docs/SPEC.md— the normative behavior specification (character repertoire, encoding matrix, host pipeline, parsing algorithm, resolution, query model, normalization, and error model).- API reference — once published, the KDoc reference will be hosted at javadoc.io/doc/org.dexpace/kuri; build it locally with
./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.