Skip to content

dexpace/kuri

Repository files navigation

kuri

A standards-faithful URI and URL library for Kotlin Multiplatform and Java.

License Maven Central Kotlin Kotlin Multiplatform JDK Coverage

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.

Why kuri?

  • 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.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.
  • 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.

Installation

// 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.

Modules

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
}

Quick start

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"

Two models

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"       — canonicalized

The guide covers the full comparison, the accessor differences to watch for, IPv6 zone identifiers, and IRI conversion: Two models, one engine.

Features

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=2

Non-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=2

RFC 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=email

Annotation 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=open

kotlinx.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.

Documentation

Contributing

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.

Security

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.

License

kuri is released under the MIT License, Copyright (c) 2026 dexpace.

About

A standards-faithful URI and URL library for Kotlin Multiplatform and Java

Topics

Resources

License

Security policy

Stars

7 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors