A standards-faithful URI and URL library for Kotlin Multiplatform and Java.
Installation · Quick start · Two models · Parsing and errors · Building and resolving · Utilities · Recipes · Standards · Conformance · Platforms · Versioning and stability · Building from source · Documentation · Contributing · Security · License
kuri is published to Maven Central. The artifact id is identical across Kotlin Multiplatform targets; the Gradle module metadata selects the right per-platform variant automatically.
| Coordinate | Value |
|---|---|
| Group | org.dexpace |
| Artifact | kuri |
| Version | 0.1.0-alpha.1 |
Note
kuri is in an early alpha series: the public API is not yet frozen and may change between releases, so pin to an exact version.
Gradle (Kotlin Multiplatform or Kotlin/JVM) — add the dependency to your common (or JVM) source set:
repositories {
mavenCentral()
}
dependencies {
implementation("org.dexpace:kuri:0.1.0-alpha.1")
}Gradle module metadata resolves the correct per-platform variant automatically.
Maven (Java / JVM) — reference the JVM artifact explicitly:
<dependency>
<groupId>org.dexpace</groupId>
<artifactId>kuri-jvm</artifactId>
<version>0.1.0-alpha.1</version>
</dependency>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 |
Consuming from source — to try an unreleased build, publish to your local Maven repository (./gradlew publishToMavenLocal, then add mavenLocal()), or wire a composite build with includeBuild("../kuri") in the
consumer's settings.gradle.kts.
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"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.
Which do I use?
Urlfor 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 (and, for a special scheme, a host).Urifor RFC 3986 generic identifiers —urn:,mailto:, custom schemes, and relative references. It preserves the input exactly and normalizes only when you ask, viauri.normalized().
A few accessors differ between the profiles by design; getting them wrong is a common trap:
| Reading | Uri (RFC 3986) |
Url (WHATWG) |
|---|---|---|
port |
preserved verbatim | null when elided or equal to the scheme default |
| port with no scheme default | effectivePort() → null |
effectivePort → -1 |
| opaque origin | — | origin is the literal string "null"; test hasOpaqueOrigin() |
Across both, host is the structured Host ADT and hostName is its serialized text; to render a
Host you hold, call host.asText().
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 never throws: every parse returns a ParseResult<T> — errors are values — and you choose
how to consume it.
Url.parse(input) // ParseResult<Url> (Ok or Err)
Url.parseOrNull(input) // Url?
Url.parseOrThrow(input) // Url, or throws UriSyntaxException
Url.canParse(input) // BooleanParseResult is a sealed type, so a when over it is exhaustive without an else, and the Err
branch hands you the structured UriParseError:
import org.dexpace.kuri.error.ParseResult
when (val result = Url.parse("https://example.com/")) {
is ParseResult.Ok -> result.value.hostName // "example.com"
is ParseResult.Err -> result.error.message // human-readable reason
}From Java, let the throwing factory raise UriSyntaxException and read the structured error off it
(or branch on result instanceof ParseResult.Err):
import org.dexpace.kuri.Url;
import org.dexpace.kuri.error.UriSyntaxException;
try {
Url.parseOrThrow("://no-scheme"); // throws on a malformed input
} catch (UriSyntaxException e) {
e.getError(); // the structured UriParseError
e.getMessage(); // its human-readable rendering
}getOrNull() punts a failure to null from either language when you don't need the reason.
Separately from fatal errors, Url.validationErrors() lists the non-fatal WHATWG anomalies a lenient
parse silently repaired — a \ read as /, a stripped tab — for linting or telemetry, never for
control flow (a validation error never downgrades a successful parse).
Values are immutable. A Builder produces new ones, and newBuilder() returns a builder pre-filled
from an existing value, so a parse → modify → build round-trip is clean:
val url = Url.parseOrThrow("https://example.com/v1/users?page=1")
.newBuilder()
.addPathSegment("42")
.setQueryParameter("page", "2")
.build() // https://example.com/v1/users/42?page=2build() throws (UriSyntaxException or IllegalArgumentException) when the assembled components
can't form a valid value — a special-scheme Url with no host, say. buildOrNull() is the
non-throwing sibling for untrusted input:
Url.Builder().scheme("https").host("example.com").buildOrNull() // https://example.com/ (a Url)
Url.Builder().scheme("https").buildOrNull() // null — a special scheme needs a hostThe same from Java — construct the builder with new, chain setters, and call build():
Url url = new Url.Builder()
.scheme("https")
.host("example.com")
.addPathSegment("v1")
.addPathSegment("users")
.setQueryParameter("page", "2")
.build(); // https://example.com/v1/users?page=2resolve applies a reference to a base (RFC 3986 §5.2 / WHATWG); resolveOrThrow and resolveOrNull
are the throwing and punning variants. Uri.relativize is the inverse (see Recipes).
val base = Url.parseOrThrow("https://example.com/a/b")
base.resolveOrThrow("../c") // https://example.com/cFor a single-component edit without a builder, both profiles offer copy-with helpers — withPort,
withFragment, and withoutFragment — and predicates round out the surface: Uri.isAbsolute() /
Uri.isOpaquePath() and Url.isSpecial().
The generic Uri preserves what you parsed and normalizes only when asked:
val uri = Uri.parseOrThrow("HTTP://Example.com/a/../b")
uri.toString() // "HTTP://Example.com/a/../b" — verbatim
uri.normalized().toString() // "http://example.com/b" — RFC 3986 §6.2The parsing engine's building blocks are public as small facades, for when you need one component
rather than a whole reference. All are object/static methods that read the same from Kotlin and
Java.
Percent-coding. Percent.encode escapes for a chosen component; Percent.decode is lenient (a
malformed % is left verbatim) and total. Component.COMPONENT is the strict encodeURIComponent
set; PATH_SEGMENT leaves / unescaped.
import org.dexpace.kuri.percent.Percent
Percent.encode("a b/c", Percent.Component.COMPONENT) // "a%20b%2Fc"
Percent.encode("/", Percent.Component.PATH_SEGMENT) // "/" — a slash is data in one segment
Percent.decode("a%2Fb") // "a/b"IDNA (UTS-46). Idn.toAscii is fallible — it returns a ParseResult — while Idn.toUnicode is
best-effort and total.
import org.dexpace.kuri.idna.Idn
Idn.toAscii("bücher.example").getOrNull() // "xn--bcher-kva.example"
Idn.toUnicode("xn--bcher-kva.example") // "bücher.example"Scheme facts. Profile-independent, case-insensitive, and total.
import org.dexpace.kuri.scheme.Schemes
Schemes.defaultPort("https") // 443
Schemes.defaultPort("file") // null — special, but portless
Schemes.isSpecial("http2") // false
Schemes.isValid("mailto") // trueThe same three facades from Java are plain statics; Kuri.VERSION reports the running release (kuri
has no facade type — start from Url or Uri):
Percent.encode("a b/c", Percent.Component.COMPONENT); // "a%20b%2Fc"
Idn.toAscii("bücher.example").getOrNull(); // "xn--bcher-kva.example"
Schemes.defaultPort("https"); // 443 (Integer)
String version = Kuri.VERSION; // e.g. "0.1.0-alpha.1"Edit query parameters. The builder edits follow URLSearchParams: setQueryParameter replaces,
addQueryParameter appends (keeping duplicates), removeAllQueryParameters drops every match.
val url = Url.parseOrThrow("https://example.com/?a=1&a=2&b=3")
.newBuilder()
.setQueryParameter("a", "9") // a=1, a=2 -> a=9
.removeAllQueryParameters("b")
.build() // https://example.com/?a=9Read the decoded pairs off a parsed value. queryParameters is duplicate-preserving; get returns
the first value, and null for both an absent name and a present name with no = — use has or
getAll to tell those apart.
val params = Url.parseOrThrow("https://h/?q=kotlin&q=jvm&flag").queryParameters
params["q"] // "kotlin" — first value wins
params.getAll("q") // ["kotlin", "jvm"]
params["flag"] // null — present, but has no '='...
params.has("flag") // true — ...so check has()The Uri profile computes its query on demand, so there it is a method — uri.queryParameters() —
rather than a property.
Form encoding. toQueryString() emits the generic %20 dialect; toFormUrlEncoded() emits the
HTML form dialect (space as +). parse reads a URL query; parseForm reads a form body. of(Map)
collapses duplicate names, while of(vararg QueryParameter) preserves them.
import org.dexpace.kuri.query.QueryParameter
import org.dexpace.kuri.query.QueryParameters
val q = QueryParameters.of(QueryParameter("full name", "Ada Lovelace"))
q.toQueryString() // "full%20name=Ada%20Lovelace"
q.toFormUrlEncoded() // "full+name=Ada+Lovelace"
QueryParameters.parseForm("a=b+c&a=d").getAll("a") // ["b c", "d"] — '+' decodes to spaceEdit the path. Add or replace decoded segments — each is percent-encoded for you. path is the
decoded path; encodedPath is the raw one.
val uri = Uri.parseOrThrow("http://h/a/b/c")
.newBuilder()
.setPathSegment(1, "x y") // the space is encoded
.build()
uri.encodedPath // "/a/x%20y/c"
uri.path // "/a/x y/c" — decoded
uri.fileName() // "c"Relativize. Uri.relativize inverts resolve: it returns a reference that resolves back to the
target against the same base, or null when there is no relative form (a differing scheme or
authority, or an opaque path on either side). Url.relativize is the same, returning a String?.
val base = Uri.parseOrThrow("http://h/a/b/")
val rel = base.relativize(Uri.parseOrThrow("http://h/a/b/c/d"))
?: error("no relative form") // a relative Uri, or null when none resolves back
rel.uriString // "c/d"
base.resolveOrThrow(rel.uriString) // http://h/a/b/c/d — round-trips to the targetThe optional kuri-bind module maps an annotated object onto a Url/Uri builder — declare the
mapping once, then turn any request object into a URL. It is a JVM add-on (it uses Kotlin reflection);
the core kuri artifact stays dependency-free.
@Url // bind this class as a URL
@PathTemplate("/search/{category}/{tags...}") // {name} = one segment, {name...} = catch-all tail
data class SearchRequest(
@Path("category") val category: String,
@Path("tags") val tags: List<String>,
@Query("q") val term: String,
@QueryMap val extra: Map<String, String>,
)
val base = Url.parseOrThrow("https://api.example.com")
val url = KuriBind.bindInto(
base.newBuilder(),
SearchRequest("shoes", listOf("a", "b"), "x y", mapOf("page" to "2")),
).build()
// https://api.example.com/search/shoes/a/b?q=x%20y&page=2Annotations (on properties, fields, getters, or constructor parameters): @Scheme, @Host,
@Port, @Username/@Password/@UserInfo, @Path, @Query, @QueryMap, @Fragment, plus the
class-level @Url/@Uri (profile selector) and @PathTemplate. Unannotated members are ignored.
Values are the decoded form — the builder percent-encodes them.
Entry points — the KuriBind facade, ergonomic from Kotlin and Java:
val builder: Url.Builder = KuriBind.toUrlBuilder(request) // populated builder — edit before build()
val url: Url = KuriBind.toUrl(request) // build() convenience
KuriBind.bindInto(clientBase.newBuilder(), request) // bind onto a client base URL
KuriBind.toUrlOrNull(request) // null instead of throwing
// …and toUri / toUriBuilder / *OrNull for the RFC 3986 profile.bindInto targets the common SDK shape: a base URL that already carries a scheme and host, onto which a
request object appends its path and query. Single-valued components the object carries (scheme, host,
port, userinfo, fragment) override the base; a component the object leaves out keeps the base's value.
BindOptions.strict governs conflicts within the bound object graph — for example a merged @Url
sub-object that disagrees with its parent — rather than the object against the base. Nested objects are
supported: mark a complex member @Url/@Uri to merge all of its components into the parent, or
@Query/@Path to fold just that component. Binding is bounded (BindOptions.maxDepth, cycle-detected)
and fails fast on misconfiguration with a KuriBindException; pass BindOptions(strict = true) to reject
a conflicting single-valued write within the object graph.
Members bind in Kotlin primary-constructor order (or Java record component order). Other shapes — plain
Java beans and body-declared properties — have no reliable order through reflection and bind in a stable
order sorted by name, so prefer a data class, a record, or a @PathTemplate when positional path order
matters. Within the object graph, single-valued components are first-writer-wins in that declaration
order: a parent's own leaf overrides the same component from an @Url/@Uri-merged child only when it
is declared before the merge member, so declare the component first when the parent's value must win. A
leading / in a template is decorative for an authority-less URI, where a segment path roots only under
an authority.
kuri-bind uses Kotlin reflection (kotlin-reflect). Kotlin classes are first-class; Java classes
(POJOs, records) are supported through Kotlin reflection's interop views (getters and fields). A
dedicated java.lang.reflect-native backend is not shipped — if you need one, please open an issue.
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-alpha.1 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 — once published, the full KDoc reference will be hosted at
javadoc.io/doc/org.dexpace/kuri. To browse it locally, build the
HTML site with
./gradlew :kuri:dokkaGeneratePublicationHtml(output 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.