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

Contents

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

Installation

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.

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, one engine

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?

  • 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 (and, for a special scheme, a host).
  • Uri for RFC 3986 generic identifiers — urn:, mailto:, custom schemes, and relative references. It preserves the input exactly and normalizes only when you ask, via uri.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 form

Parsing and errors

Parsing 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)              // Boolean

ParseResult 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).

Building and resolving

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

build() 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 host

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

resolve 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/c

For 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.2

Utilities

The 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")      // true

The 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"

Recipes

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

Read 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 space

Edit 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 target

Annotation binding (kuri-bind)

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

Annotations (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.

Reflection and Java support

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.

Standards

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

ComplianceConformant: 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.

SupportDefault: active in the default configuration of both profiles · Opt-in: available behind an explicit flag, off by default · : not applicable.

Conformance

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.

Platforms

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.

Versioning and stability

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 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) and detekt (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

Documentation

  • 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 under kuri/build/dokka/).
  • CHANGELOG.md — notable changes per release, in Keep a Changelog format.
  • SECURITY.md — supported versions and how to report a vulnerability privately.

Contributing

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.

Security

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.

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

5 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors