diff --git a/README.md b/README.md index a64ff3f..20667d2 100644 --- a/README.md +++ b/README.md @@ -16,55 +16,22 @@ Coverage

-## Contents - -[Installation](#installation) · -[Quick start](#quick-start) · -[Two models](#two-models-one-engine) · -[Parsing and errors](#parsing-and-errors) · -[Building and resolving](#building-and-resolving) · -[Utilities](#utilities) · -[Recipes](#recipes) · -[Standards](#standards) · -[Conformance](#conformance) · -[Platforms](#platforms) · -[Versioning and stability](#versioning-and-stability) · -[Building from source](#building-from-source) · -[Documentation](#documentation) · -[Contributing](#contributing) · -[Security](#security) · -[License](#license) +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. ## 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` | - -> [!NOTE] -> kuri is in the `0.x` 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: - ```kotlin -repositories { - mavenCentral() -} - +// build.gradle.kts — Kotlin Multiplatform or Kotlin/JVM dependencies { implementation("org.dexpace:kuri:0.1.0") } ``` -Gradle module metadata resolves the correct per-platform variant automatically. - -**Maven (Java / JVM)** — reference the JVM artifact explicitly: +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: ```xml @@ -74,17 +41,11 @@ Gradle module metadata resolves the correct per-platform variant automatically. ``` -**Requirements** +Runs on **Java 8+** and **Kotlin 2.0+**, with no runtime dependencies beyond the Kotlin standard library. -| | | -|----------------------|------------------------------------------------------------------------------------------| -| 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`. +> [!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. ## Quick start @@ -118,513 +79,53 @@ 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 | -| 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`: - -```kotlin -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. - -```kotlin -import org.dexpace.kuri.Iri - -// toUri returns a ParseResult; 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` — errors are values — and you choose -how to consume it. - -```kotlin -Url.parse(input) // ParseResult (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`: - -```kotlin -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`): - -```java -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: - -```kotlin -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: - -```kotlin -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()`: - -```java -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](#recipes)). - -```kotlin -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: - -```kotlin -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. - -```kotlin -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. - -```kotlin -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. - -```kotlin -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`): +Parsing never throws: `parse` returns a `ParseResult`, with `parseOrNull` / `parseOrThrow` / `canParse` when +you want a `null`, an exception, or a boolean instead. -```java -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" -``` - -## Recipes - -**Edit query parameters.** The builder edits follow `URLSearchParams`: `setQueryParameter` replaces, -`addQueryParameter` appends (keeping duplicates), `removeAllQueryParameters` drops every match. - -```kotlin -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. - -```kotlin -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() -``` - -**Multi-value query params.** `split(name, delimiter)` reads every pair for a name and splits each -value on a delimiter, so a delimited list in one pair *and* repeated pairs both flatten into a single -list. Splitting is literal and lossless — no trimming, empty tokens kept — and typing is just a `map`. - -```kotlin -val q = Url.parseOrThrow("https://h/?roles=admin,user&perm=read|write&id=1,2&id=3").queryParameters -q.split("roles", ',') // ["admin", "user"] -q.split("perm", '|') // ["read", "write"] -q.split("id", ',') // ["1", "2", "3"] — pairs and delimiters both flatten -q.split("id", ',').map(String::toInt) // [1, 2, 3] — conversion is just map() -``` - -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. - -```kotlin -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. +## Two models -```kotlin -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?`. - -```kotlin -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. - -```kotlin -@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, - @Query("q") val term: String, - @QueryMap val extra: Map, -) - -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: +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. ```kotlin -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. +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 ``` -`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](#conformance). - -**Core syntax** - -| Standard | Governs | Compliance | Support | -|-----------------------------------|--------------------------------------------------------------------|------------|---------| -| [RFC 3986][rfc3986] (STD 66) | URI generic syntax; the `Uri` model and parsing authority | Conformant | Default | -| [RFC 3987][rfc3987] | IRIs — one-way `Iri` mapping, not a validating parser* | Supported | Default | -| [WHATWG URL Standard][whatwg-url] | the `Url` model — parser, special schemes, canonical serialization | Conformant | Default | - -\* kuri maps IRIs to URIs one-way (RFC 3987 §3.1/§3.2), rejecting a §2.2 `ucschar`/`iprivate` -repertoire violation and a §4.1 bidi formatting character; it does not enforce §4.2's per-component -directionality restriction, which the RFC itself states as a SHOULD, not a MUST. - -**Hosts, internationalization, and IP addresses** - -| Standard | Governs | Compliance | Support | -|---------------------|----------------------------------------------------------------------|------------|---------| -| [UTS #46][uts46] | Unicode IDNA Compatibility Processing (host ToASCII / ToUnicode) | Ratcheting | Default | -| [RFC 5891][rfc5891] | Internationalized Domain Names in Applications (IDNA2008) — protocol | Ratcheting | Default | -| [RFC 5892][rfc5892] | IDNA2008 — Unicode code points and derived properties | Ratcheting | Default | -| [RFC 3492][rfc3492] | Punycode — the Bootstring encoding of Unicode | Conformant | Default | -| [UAX #15][uax15] | Unicode Normalization Forms (NFC) | Conformant | Default | -| [RFC 5952][rfc5952] | IPv6 address text representation (canonical form) | Conformant | Default | -| [RFC 6874][rfc6874] | IPv6 zone identifiers in URLs | Opt-in | Opt-in | - -**Query** - -| Standard | Governs | Compliance | Support | -|------------------------------------------------|----------------------------------------------|------------|---------| -| [`application/x-www-form-urlencoded`][formenc] | Form-encoded query parsing and serialization | Conformant | Default | - -**Notation and requirement levels** - -| Standard | Governs | Compliance | Support | -|----------------------------------------------------|-------------------------------------------------------|------------|---------| -| [RFC 5234][rfc5234] (STD 68) | ABNF — the grammar notation used by the specification | Notation | — | -| [RFC 2119][rfc2119] · [RFC 8174][rfc8174] (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](#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. - -[rfc2119]: https://www.rfc-editor.org/rfc/rfc2119 - -[rfc8174]: https://www.rfc-editor.org/rfc/rfc8174 - -[rfc3986]: https://www.rfc-editor.org/rfc/rfc3986 - -[rfc3987]: https://www.rfc-editor.org/rfc/rfc3987 - -[rfc6874]: https://www.rfc-editor.org/rfc/rfc6874 - -[rfc5952]: https://www.rfc-editor.org/rfc/rfc5952 - -[rfc5891]: https://www.rfc-editor.org/rfc/rfc5891 - -[rfc5892]: https://www.rfc-editor.org/rfc/rfc5892 - -[rfc3492]: https://www.rfc-editor.org/rfc/rfc3492 - -[rfc5234]: https://www.rfc-editor.org/rfc/rfc5234 - -[uts46]: https://www.unicode.org/reports/tr46/ - -[uax15]: https://www.unicode.org/reports/tr15/ - -[whatwg-url]: https://url.spec.whatwg.org/ - -[formenc]: https://url.spec.whatwg.org/#application/x-www-form-urlencoded - -## 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 | -| WHATWG `urlencoded-parser.any.js` — form parsing | 35 / 35 | - -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](https://semver.org/). At `0.1.0` 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 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`) -- per-module Kover line/branch floors: `kuri` and `kuri-bind` at 99% line / 85% and 88% branch - respectively, `kuri-serde-kotlinx` at 90% line / 80% branch - -After an intentional public-API change, regenerate and commit the API snapshot in the same change: - -``` -./gradlew apiDump -``` +The guide covers the full comparison, the accessor differences to watch for, IPv6 zone identifiers, and IRI +conversion: [Two models, one engine](docs/GUIDE.md#two-models-one-engine). ## Documentation -- [`docs/SPEC.md`](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](https://javadoc.io/doc/org.dexpace/kuri). To browse it locally, build the - HTML site with `./gradlew :kuri:dokkaGeneratePublicationHtml` (output under `kuri/build/dokka/`). +- **[User guide](docs/GUIDE.md)** — the complete usage reference: + [parsing & errors](docs/GUIDE.md#parsing-and-errors), + [building & resolving](docs/GUIDE.md#building-and-resolving), + [utilities](docs/GUIDE.md#utilities), + [recipes](docs/GUIDE.md#recipes), + [URI templates](docs/GUIDE.md#uri-templates), + [Kotlin DSL](docs/GUIDE.md#kotlin-dsl-and-operators), + [annotation binding](docs/GUIDE.md#annotation-binding-kuri-bind), + [kotlinx.serialization](docs/GUIDE.md#kotlinxserialization-kuri-serde-kotlinx), + and [standards & conformance](docs/GUIDE.md#standards). +- [`docs/SPEC.md`](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](https://javadoc.io/doc/org.dexpace/kuri); build it locally with `./gradlew :kuri:dokkaGeneratePublicationHtml`. - [`CHANGELOG.md`](CHANGELOG.md) — notable changes per release, in Keep a Changelog format. -- [`SECURITY.md`](SECURITY.md) — supported versions and how to report a vulnerability privately. ## Contributing -Issues and pull requests are welcome on the [project repository](https://github.com/dexpace/kuri). `./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](#building-from-source)). New or changed -behavior should be grounded in the relevant standard and reflected in [`docs/SPEC.md`](docs/SPEC.md). Commits and -pull-request titles use the `feat:` / `fix:` / `test:` / `docs:` / `chore:` prefix convention. +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](docs/GUIDE.md#building-from-source)). New or changed behavior should be +grounded in the relevant standard and reflected in [`docs/SPEC.md`](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`](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. +[`SECURITY.md`](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 diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 0000000..cb199a0 --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,678 @@ +# kuri — User Guide + +The complete usage reference for [kuri](../README.md). The [README](../README.md) covers installation and a +quick start; this page goes deep on every part of the API. For the normative behavior specification, see +[`SPEC.md`](SPEC.md). + +## Contents + +[Two models](#two-models-one-engine) · +[Parsing and errors](#parsing-and-errors) · +[Building and resolving](#building-and-resolving) · +[Utilities](#utilities) · +[Recipes](#recipes) · +[URI templates](#uri-templates) · +[Kotlin DSL and operators](#kotlin-dsl-and-operators) · +[Annotation binding (kuri-bind)](#annotation-binding-kuri-bind) · +[kotlinx.serialization (kuri-serde-kotlinx)](#kotlinxserialization-kuri-serde-kotlinx) · +[Standards](#standards) · +[Conformance](#conformance) · +[Platforms](#platforms) · +[Building from source](#building-from-source) + +## Two models, one engine + +kuri exposes two profiles over one parsing engine. The [README](../README.md#two-models) has the short +version — reach for `Url` for web URLs and `Uri` for generic identifiers; this is the full comparison. + +| | `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 — it +returns a `ParseResult`, with `toUrlOrNull()` / `toUrlOrThrow()` siblings. + +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 | +| opaque origin | — | `origin` is the literal string `"null"`; test `hasOpaqueOrigin()` | + +Across both, `host` is the structured `Host` ADT — a sealed hierarchy of `RegName`, `Ipv4`, `Ipv6`, +`IpFuture`, `Opaque`, and `Empty` — 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`: + +```kotlin +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. + +```kotlin +import org.dexpace.kuri.Iri + +// toUri returns a ParseResult; 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` — errors are values — and you choose +how to consume it. + +```kotlin +Url.parse(input) // ParseResult (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`: + +```kotlin +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 +} +``` + +For a pipeline that doesn't want to branch, `map` transforms a success and `fold` collapses both +branches to a single value: + +```kotlin +import org.dexpace.kuri.error.fold +import org.dexpace.kuri.error.map + +Url.parse(input).map { it.hostName } // ParseResult +Url.parse(input).fold({ it.hostName }, { "invalid" }) // String, either way +``` + +From Java, let the throwing factory raise `UriSyntaxException` and read the structured `error` off it +(or branch on `result instanceof ParseResult.Err`): + +```java +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). Each is a `kind` + +`at` (offset) record over the `ValidationErrorKind` enum. + +**Resource limits.** kuri caps the work any single parse can do, so hostile input can't blow up memory or +recursion. Every limit is a field on `ParseOptions` with a documented default, tunable through the +builder: + +```kotlin +val options = ParseOptions.Builder() + .inputLength(8_192) // max input characters + .expandedLength(65_536) // max length after percent-decoding / IDNA expansion + .pathSegments(1_024) // max path segments + .resolutionDepth(64) // max reference-resolution recursion + .build() + +Url.parse(untrustedInput, options) +``` + +When a bound is exceeded the parse fails with a `UriParseError.LimitExceeded`, which reports the +offending `limit` (a `ResourceLimit` — `InputLength`, `ExpandedLength`, `PathSegments`, +`ResolutionDepth`, `HostLabelLength`, `HostTotalLength`, `PortMax`), the `observed` value, and the `max` +it crossed — enough to log precisely why a request was rejected. + +## 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: + +```kotlin +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: + +```kotlin +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()`: + +```java +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](#recipes)). + +```kotlin +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`. `Url` adds WHATWG-named aliases (`withProtocol`, `withHost`, +`withHostname`, `withPort(String)`, `withPathname`, `withSearch`, `withHash`, `withUsername`, +`withPassword`) for callers used to the JS property names. Predicates round out the surface: +`Uri.isAbsolute()` / `Uri.isOpaquePath()`, `Url.isSpecial()`, and — on both — `isDirectory()` / +`hasTrailingSlash()`. + +```kotlin +Url.parseOrThrow("https://example.com/a/b/").isDirectory() // true — path ends in '/' +``` + +**Redaction.** `redact()` returns a copy with the three components RFC 3986 treats as sensitive or +context-dependent — userinfo, query, and fragment — stripped, leaving scheme, host, port, and path intact. +Safe to log: + +```kotlin +Url.parseOrThrow("https://user:secret@example.com/p?token=abc#frag").redact().toString() +// "https://example.com/p" — credentials, query, and fragment gone; the rest verbatim +``` + +The generic `Uri` preserves what you parsed and normalizes only when asked: + +```kotlin +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. (`QUERY`, `FRAGMENT`, and `USER_INFO` round out the set.) + +```kotlin +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. + +```kotlin +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. + +```kotlin +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`): + +```java +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" +``` + +## Recipes + +**Edit query parameters.** The builder edits follow `URLSearchParams`: `setQueryParameter` replaces, +`addQueryParameter` appends (keeping duplicates), `removeAllQueryParameters` drops every match. + +```kotlin +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. + +```kotlin +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() +``` + +`QueryParameters` is also iterable and index-addressable: it implements `Iterable` and +exposes `names()`, `size`, `nameAt` / `valueAt`, and `toMap()`, plus `newBuilder()` for a mutable copy. + +**Multi-value query params.** `split(name, delimiter)` reads every pair for a name and splits each +value on a delimiter, so a delimited list in one pair *and* repeated pairs both flatten into a single +list. Splitting is literal and lossless — no trimming, empty tokens kept — and typing is just a `map`. + +```kotlin +val q = Url.parseOrThrow("https://h/?roles=admin,user&perm=read|write&id=1,2&id=3").queryParameters +q.split("roles", ',') // ["admin", "user"] +q.split("perm", '|') // ["read", "write"] +q.split("id", ',') // ["1", "2", "3"] — pairs and delimiters both flatten +q.split("id", ',').map(String::toInt) // [1, 2, 3] — conversion is just map() +``` + +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. + +```kotlin +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. + +```kotlin +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?`. + +```kotlin +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 +``` + +## URI templates + +`UriTemplate` is the standards-based, string-first companion to `kuri-bind` — a full +[RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) implementation. Parse a template once, then `expand` +it against a set of variables. All four RFC levels are supported, including the operators (`+`, `#`, +`.`, `/`, `;`, `?`, `&`) and the prefix (`{var:3}`) and explode (`{list*}`) modifiers over string, +list, and map values. + +```kotlin +import org.dexpace.kuri.template.UriTemplate + +val t = UriTemplate.parse("https://api.example.com/users/{id}{?fields*,page}") +t.expand(mapOf("id" to 42, "fields" to listOf("a", "b"), "page" to 2)) +// "https://api.example.com/users/42?fields=a&fields=b&page=2" +``` + +Expansion never throws — an undefined variable (absent, `null`, or an empty list/map) contributes +nothing, per RFC 6570 §3.2.1. `expand` also has a `vararg Pair` overload, and `expandToUri` / +`expandToUrl` expand and parse in one step, returning a `ParseResult`. `variableNames` lists the +template's variables, and the `match` extension runs a template in reverse, extracting variables from a +string: + +```kotlin +import org.dexpace.kuri.template.match + +t.variableNames // ["id", "fields", "page"] +UriTemplate.parse("/users/{id}").match("/users/42") // {id=42}, or null on no match +``` + +`UriTemplate.parseOrNull` is the non-throwing parse; a malformed template otherwise raises a +`UriTemplateException` carrying the offending `index`. + +## Kotlin DSL and operators + +The `org.dexpace.kuri.ktx` package adds optional Kotlin-only sugar over the core API — import it when you +want it, ignore it otherwise. Nothing here is visible to Java. + +**Conversions.** String extensions that read like the companion factories: + +```kotlin +import org.dexpace.kuri.ktx.toUrl +import org.dexpace.kuri.ktx.toUrlOrThrow + +"https://example.com".toUrl() // Url? — null on failure +"https://example.com".toUrlOrThrow() // Url — throws on failure +"https://example.com".toUrlResult() // ParseResult +``` + +(`toUri` / `toUriOrThrow` / `toUriResult` mirror them for the RFC 3986 profile.) + +**Operators.** `/` appends a path segment, `+` adds a query parameter, `[]` reads the first value for a +name, and `in` tests presence: + +```kotlin +import org.dexpace.kuri.ktx.div +import org.dexpace.kuri.ktx.plus + +val url = Url.parseOrThrow("https://api.example.com/v1") +val next = url / "users" / "42" + ("page" to "2") // https://api.example.com/v1/users/42?page=2 +next["page"] // "2" +"page" in next // true +``` + +**Builder DSL.** `buildUrl` / `buildUri` open a builder in a lambda, and `edit` opens one pre-filled +from an existing value: + +```kotlin +import org.dexpace.kuri.ktx.buildUrl +import org.dexpace.kuri.ktx.edit + +val url = buildUrl { + scheme("https") + host("example.com") + pathSegments("v1", "users") // vararg helper + params("page" to "2", "sort" to "name") +} +val bumped = url.edit { setQueryParameter("page", "3") } +``` + +## 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. + +```kotlin +@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, + @Query("q") val term: String, + @QueryMap val extra: Map, +) + +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: + +```kotlin +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. + +## kotlinx.serialization (kuri-serde-kotlinx) + +The optional **`kuri-serde-kotlinx`** module bridges kuri and +[kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization). It ships across the same +multiplatform targets as `:kuri` (no Android variant) and gives you two things. + +**`Url` / `Uri` serializers.** Serialize a value as its string form in any kotlinx format. Reference the +serializer per property, or register it contextually: + +```kotlin +import kotlinx.serialization.Serializable +import org.dexpace.kuri.Url +import org.dexpace.kuri.serde.UrlSerializer + +@Serializable +data class Link( + @Serializable(with = UrlSerializer::class) val href: Url, +) + +Json.encodeToString(Link(Url.parseOrThrow("https://example.com/"))) +// {"href":"https://example.com/"} +``` + +`UriSerializer` does the same for the `Uri` profile. + +**`QueryParametersFormat`.** A serialization *format* that maps a flat `@Serializable` class to and from +`QueryParameters` — the typed **decode** direction that `kuri-bind` (object → URL only) does not provide. + +```kotlin +import org.dexpace.kuri.serde.QueryParametersFormat + +@Serializable +data class Search(val q: String, val page: Int = 1, val tags: List = emptyList()) + +QueryParametersFormat.decodeFromQueryString("q=kotlin&page=2&tags=a&tags=b") +// Search(q = "kotlin", page = 2, tags = ["a", "b"]) +QueryParametersFormat.encodeToQueryString(Search("kotlin", 2, listOf("a", "b"))) +// "q=kotlin&page=2&tags=a&tags=b" +``` + +`encodeToQueryParameters` / `decodeFromQueryParameters` are the `QueryParameters`-typed variants. The +scope is a single flat class: each scalar property is one parameter, a list property repeats it, and a +`Map` property repeats a `.key` / `.value` pair per entry. Encoding omits a property still at +its declared default, so the output stays minimal, and decoding fills defaults back in symmetrically. +Nested `@Serializable` objects are rejected — model them at the call site or bind them separately. + +## Standards + +kuri implements the standards below; per-standard conformance is measured in [Conformance](#conformance). + +**Core syntax** + +| Standard | Governs | Compliance | Support | +|-----------------------------------|--------------------------------------------------------------------|------------|---------| +| [RFC 3986][rfc3986] (STD 66) | URI generic syntax; the `Uri` model and parsing authority | Conformant | Default | +| [RFC 3987][rfc3987] | IRIs — one-way `Iri` mapping, not a validating parser* | Supported | Default | +| [RFC 6570][rfc6570] | URI Templates — the `UriTemplate` facility | Conformant | Default | +| [WHATWG URL Standard][whatwg-url] | the `Url` model — parser, special schemes, canonical serialization | Conformant | Default | + +\* kuri maps IRIs to URIs one-way (RFC 3987 §3.1/§3.2), rejecting a §2.2 `ucschar`/`iprivate` +repertoire violation and a §4.1 bidi formatting character; it does not enforce §4.2's per-component +directionality restriction, which the RFC itself states as a SHOULD, not a MUST. + +**Hosts, internationalization, and IP addresses** + +| Standard | Governs | Compliance | Support | +|---------------------|----------------------------------------------------------------------|------------|---------| +| [UTS #46][uts46] | Unicode IDNA Compatibility Processing (host ToASCII / ToUnicode) | Ratcheting | Default | +| [RFC 5891][rfc5891] | Internationalized Domain Names in Applications (IDNA2008) — protocol | Ratcheting | Default | +| [RFC 5892][rfc5892] | IDNA2008 — Unicode code points and derived properties | Ratcheting | Default | +| [RFC 3492][rfc3492] | Punycode — the Bootstring encoding of Unicode | Conformant | Default | +| [UAX #15][uax15] | Unicode Normalization Forms (NFC) | Conformant | Default | +| [RFC 5952][rfc5952] | IPv6 address text representation (canonical form) | Conformant | Default | +| [RFC 6874][rfc6874] | IPv6 zone identifiers in URLs | Opt-in | Opt-in | + +**Query** + +| Standard | Governs | Compliance | Support | +|------------------------------------------------|----------------------------------------------|------------|---------| +| [`application/x-www-form-urlencoded`][formenc] | Form-encoded query parsing and serialization | Conformant | Default | + +**Notation and requirement levels** + +| Standard | Governs | Compliance | Support | +|----------------------------------------------------|-------------------------------------------------------|------------|---------| +| [RFC 5234][rfc5234] (STD 68) | ABNF — the grammar notation used by the specification | Notation | — | +| [RFC 2119][rfc2119] · [RFC 8174][rfc8174] (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](#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. + +## 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 | +| WHATWG `urlencoded-parser.any.js` — form parsing | 35 / 35 | + +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` | +| Android | `android` (`minSdk 21`, `compileSdk 35`) | +| 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` | + +`:kuri` publishes to every target above; `:kuri-serde-kotlinx` publishes to the same set minus Android, and +`:kuri-bind` is JVM-only. 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. + +## 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`) +- per-module Kover line/branch floors: `kuri` and `kuri-bind` at 99% line / 85% and 88% branch + respectively, `kuri-serde-kotlinx` at 90% line / 80% branch + +After an intentional public-API change, regenerate and commit the API snapshot in the same change: + +``` +./gradlew apiDump +``` + +To try an unreleased build in another project, 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`. + +[rfc2119]: https://www.rfc-editor.org/rfc/rfc2119 + +[rfc8174]: https://www.rfc-editor.org/rfc/rfc8174 + +[rfc3986]: https://www.rfc-editor.org/rfc/rfc3986 + +[rfc3987]: https://www.rfc-editor.org/rfc/rfc3987 + +[rfc6570]: https://www.rfc-editor.org/rfc/rfc6570 + +[rfc6874]: https://www.rfc-editor.org/rfc/rfc6874 + +[rfc5952]: https://www.rfc-editor.org/rfc/rfc5952 + +[rfc5891]: https://www.rfc-editor.org/rfc/rfc5891 + +[rfc5892]: https://www.rfc-editor.org/rfc/rfc5892 + +[rfc3492]: https://www.rfc-editor.org/rfc/rfc3492 + +[rfc5234]: https://www.rfc-editor.org/rfc/rfc5234 + +[uts46]: https://www.unicode.org/reports/tr46/ + +[uax15]: https://www.unicode.org/reports/tr15/ + +[whatwg-url]: https://url.spec.whatwg.org/ + +[formenc]: https://url.spec.whatwg.org/#application/x-www-form-urlencoded