A standards-faithful URI and URL library for Kotlin and Java.
Installation · Quick start · Two models · Parsing and errors · Building and resolving · Standards · Conformance · Platforms · Versioning and stability · Building from source · Documentation · Contributing · Security · License
Note
Not yet published to a public repository. kuri is not on Maven Central yet, so it cannot be resolved from a remote repository. You can still consume it today by publishing to your local Maven repository or by wiring a composite build — see below.
The coordinates:
| Coordinate | Value |
|---|---|
| Group | org.dexpace |
| Artifact | kuri |
| Version | 0.1.0-SNAPSHOT (current dev build; 0.1.0 will be the first release) |
The artifact id is identical across Kotlin Multiplatform targets; the Gradle plugin selects the right variant for your platform automatically.
Try it today via your local Maven repository. Publish the current snapshot to ~/.m2:
./gradlew publishToMavenLocal
Then add mavenLocal() and the dependency to the consuming project:
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation("org.dexpace:kuri:0.1.0-SNAPSHOT")
}Gradle module metadata resolves the correct per-platform variant automatically.
Or wire a composite build — point the consumer's settings.gradle.kts at a local kuri checkout, with no publish
step:
includeBuild("../kuri")Requirements
| Java runtime | Java 8 or newer (the JVM artifact is compiled to 1.8 bytecode for broad compatibility) |
| Kotlin consumers | Kotlin 2.0 or newer; the public API lives in common Kotlin |
| Runtime dependencies | None beyond the Kotlin standard library |
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"From Java:
import org.dexpace.kuri.Url;
import org.dexpace.kuri.error.ParseResult;
// Happy path: unwrap the value, or throw UriSyntaxException on a bad input.
Url url = Url.parse("https://example.com/a?b=1").getOrThrow();
url.scheme(); // "https"
url.hostName(); // "example.com"
// Null-returning form, ergonomic from Java — no exception, no ParseResult branch.
Url maybe = Url.parseOrNull("https://example.com/"); // a Url, or null on failure
// Inspect the failure without throwing: read the human-readable message off the error.
ParseResult<Url> result = Url.parse("://no-scheme");
if (result instanceof ParseResult.Err) {
String reason = ((ParseResult.Err) result).getError().getMessage();
}Uri |
Url |
|
|---|---|---|
| Standard | RFC 3986 (RFC 3987-aware) | WHATWG URL |
| Posture | preserve the input; normalize on request | canonicalize eagerly |
| Scheme | any, and may be absent (relative reference) | always present; special schemes known |
| Host | reg-name / IPv4 / IPv6 / IP-future | IDNA, IPv4 shorthand, opaque hosts |
| Equality | structural, plus normalizedEquals() for RFC §6.2 |
on the canonical serialization |
Url.toUri() is near-lossless; Uri.toUrl() may fail when a generic URI is not a valid web URL.
Zone identifiers (RFC 6874). IPv6 zone identifiers are off by default and opt-in on the Uri profile
only — the Url (WHATWG) profile always rejects them. Enable them with a ParseOptions:
val options = ParseOptions.Builder().allowIpv6ZoneId(true).build()
val uri = Uri.parse("http://[fe80::1%25eth0]/", options).getOrThrow()Internationalized identifiers (RFC 3987). For the Uri profile, IRI↔URI conversion is available
through the Iri facility — Iri.toUri(iri) maps an IRI to its ASCII Uri and Iri.toUnicode(uri)
renders the Unicode form; the Url profile applies host IDNA (UTS #46) by default.
import org.dexpace.kuri.Iri
// toUri returns a ParseResult<Uri>; the mapped Uri is fully ASCII.
val uri = Iri.toUri("http://bücher.example/qué").getOrThrow()
uri.toString() // host becomes Punycode (xn--…), non-ASCII path bytes percent-encoded
Iri.toUnicode(uri) // "http://bücher.example/qué" — best-effort Unicode display formParsing returns a ParseResult<T> — errors are values, not exceptions — and you choose how to consume it:
Url.parse(input) // ParseResult<Url> (Ok / Err)
Url.parseOrThrow(input) // Url, or throws UriSyntaxException
Url.parse(input).getOrNull() // Url?
Url.parse(input).getOrThrow() // Url, or throws UriSyntaxException
Url.canParse(input) // Boolean
Url.parse(input).fold(onOk = { it.host }, onErr = { it })Err carries a structured UriParseError with the offending offset and reason. Read
error.message (Java error.getMessage()) for a human-readable rendering of the failure without
throwing.
Values are immutable; builders produce new ones, and newBuilder() copies an existing value.
val url = Url.Builder()
.scheme("https")
.host("example.com")
.addPathSegment("v1")
.addPathSegment("users")
.setQueryParameter("page", "2")
.build() // https://example.com/v1/users?page=2
val next = url.resolve("orgs").getOrThrow() // https://example.com/v1/orgsThe generic Uri preserves what you parsed and normalizes only when asked:
val uri = Uri.parse("HTTP://Example.com/a/../b").getOrThrow()
uri.toString() // "HTTP://Example.com/a/../b" — verbatim
uri.normalized().toString() // "http://example.com/b" — RFC 3986 §6.2Query strings have their own immutable, duplicate-preserving model. QueryParameters reads decoded
pairs; iterate them, look them up, or project to a map — and build a new query from a map or straight
into a Url.
import org.dexpace.kuri.query.QueryParameters
val params = QueryParameters.parse("q=kotlin&page=2&q=jvm")
params["q"] // "kotlin" — first value wins
params.has("page") // true
params.toMap() // {q=kotlin, page=2} — first value per name
for ((name, value) in params) { /* q→kotlin, page→2, q→jvm — duplicates preserved */ }
// Build a query from a map, or set it straight onto a Url.
QueryParameters.of(linkedMapOf("q" to "kotlin", "page" to "2")).toQueryString() // "q=kotlin&page=2"
Url.Builder().scheme("https").host("example.com").setQueryParameter("q", "kotlin").build()kuri implements the standards below; per-standard conformance is measured in Conformance.
Core syntax
| Standard | Governs | Compliance | Support |
|---|---|---|---|
| RFC 3986 (STD 66) | URI generic syntax; the Uri model and parsing authority |
Conformant | Default |
| RFC 3987 | Internationalized Resource Identifiers (IRIs) | Supported | Default |
| WHATWG URL Standard | the Url model — parser, special schemes, canonical serialization |
Conformant | Default |
Hosts, internationalization, and IP addresses
| Standard | Governs | Compliance | Support |
|---|---|---|---|
| UTS #46 | Unicode IDNA Compatibility Processing (host ToASCII / ToUnicode) | Ratcheting | Default |
| RFC 5891 | Internationalized Domain Names in Applications (IDNA2008) — protocol | Ratcheting | Default |
| RFC 5892 | IDNA2008 — Unicode code points and derived properties | Ratcheting | Default |
| RFC 3492 | Punycode — the Bootstring encoding of Unicode | Conformant | Default |
| UAX #15 | Unicode Normalization Forms (NFC) | Conformant | Default |
| RFC 5952 | IPv6 address text representation (canonical form) | Conformant | Default |
| RFC 6874 | IPv6 zone identifiers in URLs | Opt-in | Opt-in |
Query
| Standard | Governs | Compliance | Support |
|---|---|---|---|
application/x-www-form-urlencoded |
Form-encoded query parsing and serialization | Supported | Default |
Notation and requirement levels
| Standard | Governs | Compliance | Support |
|---|---|---|---|
| RFC 5234 (STD 68) | ABNF — the grammar notation used by the specification | Notation | — |
| RFC 2119 · RFC 8174 (BCP 14) | Requirement-level keywords (MUST / SHOULD / MAY) | Notation | — |
Compliance — Conformant: passes the standard's conformance corpus, or its controlling table, with no known failures · Ratcheting: conformant except for cases pinned in the known-failures baseline, which can only shrink ( see Conformance) · Opt-in: conformant when explicitly enabled · Supported: implemented as an input dialect, not measured by a dedicated corpus · Notation: used to author the specification, with no runtime behavior to conform to.
Support — Default: active in the default configuration of both profiles · Opt-in: available behind an explicit flag, off by default · —: not applicable.
Behavior is checked against the conformance corpora the standards ship with:
| Suite | Result |
|---|---|
WHATWG urltestdata.json — parsing |
888 / 888 |
WHATWG urltestdata.json — parse → serialize (href) |
621 / 621 |
IDNA IdnaTestV2 + toascii |
2756 / 2760 |
Unicode NormalizationTest.txt (NFC) |
20 034 / 20 034 |
| RFC 3986 §5.4 reference resolution | all rows |
Any case that does not yet pass is pinned in a checked-in known-failures baseline; the build fails if a passing case later regresses.
The entire public API lives in common Kotlin and compiles for every target below.
| Tier | Targets |
|---|---|
| JVM | jvm |
| JavaScript | js (browser, Node.js) |
| WebAssembly | wasmJs (browser, Node.js) |
| Native — Apple | macosArm64, iosArm64, iosX64, iosSimulatorArm64, watchosArm64, watchosSimulatorArm64, tvosArm64, tvosSimulatorArm64 |
| Native — Linux | linuxX64, linuxArm64 |
| Native — Windows | mingwX64 |
The java.net.URI / java.net.URL conversions are JVM-only extensions. Every target compiles on any host; executing
the native test suites requires a matching operating system or simulator.
kuri follows Semantic Versioning 2.0.0. At 0.1.0-SNAPSHOT the public API is not yet frozen and
may change before 1.0.0, and minor releases in the 0.x series may carry breaking changes, so pin to an exact
version. Every public signature is tracked in a checked-in binary-compatibility snapshot under api/, so an unintended
API change fails the build (see Building from source).
Building kuri requires a JDK 21 toolchain; the bundled Gradle wrapper provisions the rest.
./gradlew build
build compiles every target and runs the full quality gate. Each check below fails the build:
ktlint(formatting) anddetekt(static analysis)- Kotlin
allWarningsAsErrors - explicit-API strict mode
- the binary-compatibility validator (
apiCheck) - an 80% Kover line-coverage floor
After an intentional public-API change, regenerate and commit the API snapshot in the same change:
./gradlew apiDump
docs/SPEC.md— the normative behavior specification. It defines the character repertoire, the percent-encoding matrix, the host pipeline, the parsing algorithm, reference resolution, the query model, normalization and equivalence semantics, and the error model that a conforming implementation must exhibit for each profile.- API reference — generated by Dokka. Build the HTML site
with
./gradlew :kuri:dokkaGeneratePublicationHtml; the output is written underkuri/build/dokka/. CHANGELOG.md— notable changes per release, in Keep a Changelog format.SECURITY.md— supported versions and how to report a vulnerability privately.
Issues and pull requests are welcome on the project repository. ./gradlew build
must pass — the full quality gate and the conformance baselines included — 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: / test: / docs: / chore: prefix convention.
kuri parses and canonicalizes untrusted URLs, so security reports are taken seriously. See
SECURITY.md for the 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.