//===----------------------------------------------------------------------===// // Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //===----------------------------------------------------------------------===// /// Utilities for networking, like URL parsing. @ModuleInfo { minPklVersion = "0.33.0" } @Since { version = "0.33.0" } module pkl.net /// Constructs a Url as described by [input], following /// [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). /// Accepts a URL reference (one without a scheme). /// /// This is lenient: whatever a component cannot hold literally is percent-encoded, and whatever is /// already percent-encoded is kept as is. /// /// Throws if [input] is not a valid URL. /// /// To test if [input] is a valid URL, use [String.isValidUrl]. /// /// Facts: /// ``` /// net.Url("https://example.com/foo?a=1").host == "example.com" /// net.Url("https://example.com/foo?a=1").query == "a=1" /// net.Url("https://example.com/a b").rawPath == "/a%20b" /// net.Url("/foo").scheme == null /// ``` external const function Url(input: String): Url /// A URL, as defined by [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). /// [RFC 6874](https://www.rfc-editor.org/rfc/rfc6874) IPv6 zone literals are also supported. /// /// Every component is held percent-encoded, in its `raw` property, such as [rawPath]: /// [Url()] encodes whatever a component cannot hold literally, and a component that is set by hand, /// or amended, has to be encoded already. /// The percent-decoded form of each component is available without the `raw` prefix, such as /// [path]. /// /// Facts: /// ``` /// net.Url("https://example.com/a b").rawPath == "/a%20b" /// net.Url("https://example.com/a b").path == "/a b" /// net.Url("https://example.com/a%20b").rawPath == "/a%20b" /// ``` class Url { /// The scheme, without the trailing `:`, or [null] if this is a relative reference. scheme: String(isValidScheme(this))? /// The percent-encoded userinfo, without the trailing `@`, or [null] if this URL has none. /// /// Only a URL with a [rawHost] can have userinfo. rawUserInfo: String(rawHost != null, isValidUserInfo(this))? /// The percent-decoded [rawUserInfo]. hidden fixed userInfo: String? = if (rawUserInfo != null) decodeUrlComponent(rawUserInfo) else null /// The percent-encoded host, or [null] if this URL has no authority. /// /// A [null] host is not the same as an empty one: `file:/tmp` has no host, `file:///tmp` has an /// empty one. /// /// An IPv6 address must be enclosed in brackets, as in `[::1]`. /// It may carry a zone identifier, as described in RFC 6874. /// Anything else is a registered name, which cannot contain `[`, `]` or `:` literally. rawHost: String(isValidHost(this))? /// The percent-decoded [rawHost]. hidden fixed host: String? = if (rawHost != null) decodeUrlComponent(rawHost) else null /// The port, without the leading `:`, or [null] if this URL states none. /// /// Only a URL with a [rawHost] can have a port. rawPort: String(rawHost != null, matches(Regex(#"\d*"#)))? /// The number that [rawPort] states. /// /// This is [null] if [rawPort] is [null], or if [rawPort] is not parseable as a [UInt16]. /// /// Facts: /// ``` /// net.Url("https://example.com:8080/").port == 8080 /// net.Url("https://example.com:/").port == null /// ``` hidden fixed port: UInt16? = let (parsed = rawPort?.toIntOrNull()) if (parsed is UInt16) parsed else null /// The percent-encoded path, or the empty string if this URL has none. /// /// With a [rawHost], the path is empty or starts with `/`. /// Without one, it may be *rootless*, as in `mailto:user@example.com`. /// Without a [scheme], its first segment cannot contain `:`. /// /// To change the path without encoding it first, use [resolve()], which is as lenient as [Url()]. /// Unlike amending, this drops [rawQuery] and [rawFragment]. rawPath: String(isValidPath(this, scheme != null, rawHost != null)) = "" /// The percent-decoded [rawPath]. /// /// An encoded `/` is decoded like any other octet, so it cannot be distinguished from a `/` that /// separates segments. /// [pathSegments] disambiguates between separator and literal `/` characters. /// /// Facts: /// ``` /// net.Url("https://example.com/a%2Fb/c").path == "/a/b/c" /// net.Url("https://example.com/a%2Fb/c").pathSegments == List("a/b", "c") /// ``` hidden fixed path: String = decodeUrlComponent(rawPath) /// The percent-encoded query, without the leading `?`, or [null] if this URL has none. /// /// The empty string is a URL ending in a bare `?`. rawQuery: String(isValidQuery(this))? /// The percent-decoded [rawQuery]. /// /// Unlike [queryParameters], this does not read [rawQuery] as /// `application/x-www-form-urlencoded`, so a `+` is kept as is. hidden fixed query: String? = if (rawQuery != null) decodeUrlComponent(rawQuery) else null /// The percent-encoded fragment, without the leading `#`, or [null] if this URL has none. /// /// The empty string is a URL ending in a bare `#`. rawFragment: String(isValidFragment(this))? /// The percent-decoded [rawFragment]. hidden fixed fragment: String? = if (rawFragment != null) decodeUrlComponent(rawFragment) else null /// The serialization of this URL. fixed stringValue: String = toString() /// Tells if this URL is absolute, that is, has a non-null scheme. hidden fixed isAbsolute: Boolean = scheme != null /// Tells if this URL's path is absolute, that is, starts with `/`. hidden fixed hasAbsolutePath: Boolean = rawPath.startsWith("/") /// The authority, without the leading `//`, or [null] if this URL has none. /// /// A URL has an authority exactly when it has a [rawHost]. /// This is [rawUserInfo], [rawHost] and [rawPort] joined back together. external fixed authority: String? /// What [rawHost] is, or [null] if this URL has no authority. /// /// - `"ipv6"`: an IPv6 address, such as `[::1]` /// - `"ipvFuture"`: an IP address of a version that RFC 3986 leaves for the future /// - `"ipv4"`: an IPv4 address in dotted-decimal form, such as `192.0.2.1` /// - `"name"`: any other host, a *registered name* /// /// Facts: /// ``` /// net.Url("https://example.com/").hostKind == "name" /// net.Url("https://192.0.2.1/").hostKind == "ipv4" /// net.Url("https://[::1]/").hostKind == "ipv6" /// net.Url("/foo").hostKind == null /// ``` external fixed hostKind: ("name" | "ipv4" | "ipv6" | "ipvFuture")? /// The percent-decoded zone identifier of an IPv6 [rawHost], as described in RFC 6874, or [null] /// if it states none. /// /// Facts: /// ``` /// net.Url("http://[fe80::1%25eth0]/").zoneId == "eth0" /// net.Url("http://[fe80::1]/").zoneId == null /// ``` external fixed zoneId: String? /// The percent-decoded segments of [rawPath]. /// /// [pathSegments] does not tell if the path is absolute or not. /// A relative URL reference `"a/b"` also has path segments `List("a", "b")`. /// To disambiguate between the two, use [hasAbsolutePath]. /// /// Facts: /// ``` /// net.Url("https://example.com/a/b%20c").pathSegments == List("a", "b c") /// net.Url("https://example.com/a/b%2Fc").pathSegments == List("a", "b/c") /// ``` external fixed pathSegments: List /// The percent-decoded parameters of [rawQuery], or [null] if this URL has no query. /// /// [rawQuery] is read as `application/x-www-form-urlencoded`. /// A name that repeats holds the values of all its occurrences, in the order they are written. /// A parameter that states no `=` holds no value at all, and is left out; this is not the same as /// a bare `=`, which gives it the empty string. /// /// Facts: /// ``` /// net.Url("https://example.com/?q=a+b&q=c").queryParameters["q"] == new Listing { "a b"; "c" } /// net.Url("https://example.com/?").queryParameters.isEmpty /// net.Url("https://example.com/").queryParameters == null /// ``` external fixed queryParameters: Mapping>? /// Resolves [ref] against this URL. /// /// [ref] is either a relative reference, which is resolved against this URL, or an absolute URL, /// which replaces it entirely. /// Dot segments (`./` and `../`) are removed from the result's path, normalizing it. /// /// Throws if [ref] is a string that cannot be parsed, or if this URL is itself a relative /// reference. /// /// Facts: /// ``` /// net.Url("https://example.com/a/b").resolve("../c").toString() == "https://example.com/c" /// net.Url("https://example.com/a/b").resolve("#top").toString() == "https://example.com/a/b#top" /// net.Url("foo:/a").resolve("..//x").toString() == "foo:/.//x" /// ``` external function resolve(ref: String | Url): Url /// The normalized version of this URL, following *syntax-based normalization* as described in /// [section 6.2.2](https://www.rfc-editor.org/info/rfc3986/#section-6.2.2) of RFC 3986. /// /// [scheme] and [rawHost] are lowercased, as they are case-insensitive. /// Percent-encoding is normalized: an octet standing for an unreserved character is decoded, and /// any other octet is kept, with its hex digits uppercased. /// /// Dot segments (`./` and `../`) are only removed from an absolute [rawPath]. /// A URL with a [rawHost] and no path is given the path `/`, which names the same resource. /// /// Does not perform normalization that requires knowing the scheme, such as removing a default /// port (see [section 6.2.3](https://www.rfc-editor.org/info/rfc3986/#section-6.2.3)). /// /// Facts: /// ``` /// net.Url("HTTPS://Example.com/%7Euser").normalized == net.Url("https://example.com/~user") /// net.Url("https://example.com/a/b/../c").normalized.rawPath == "/a/c" /// net.Url("https://example.com").normalized.rawPath == "/" /// ``` external hidden fixed normalized: Url /// Tells if this URL is equal to [other] according to *syntax-based normalization* as /// described in [section 6.2.2](https://www.rfc-editor.org/info/rfc3986/#section-6.2.2) of /// RFC 3986. /// /// Returns [true] if the [normalized] versions of [this] and [other] are the /// same, and [false] otherwise. /// /// Facts: /// ``` /// net.Url("HTTPS://Example.com/%7Euser").equals(net.Url("https://example.com/~user")) /// !(net.Url("https://example.com/a%2Fb").equals(net.Url("https://example.com/a/b"))) /// !(net.Url("https://example.com:443/").equals(net.Url("https://example.com/"))) /// ``` external function equals(other: Url): Boolean /// The serialization of this URL. /// /// Facts: /// ``` /// net.Url("https://example.com/a b").toString() == "https://example.com/a%20b" /// net.Url("https://example.com/é").toString() == "https://example.com/%C3%A9" /// net.Url("HTTPS://example.com").toString() == "HTTPS://example.com" /// ``` external function toString(): String /// Tells if [value] is a percent-encoded `userinfo`. external function isValidUserInfo(value: String): Boolean /// Tells if [value] is a `scheme`. external function isValidScheme(value: String): Boolean /// Tells if [value] is a percent-encoded `host`. external function isValidHost(value: String): Boolean /// Tells if [value] is a percent-encoded path that can sit next to a scheme and an authority, or, /// when [hasScheme] or [hasAuthority] is [false], next to no scheme or no authority. external function isValidPath(value: String, hasScheme: Boolean, hasAuthority: Boolean): Boolean /// Tells if [value] is a percent-encoded `query`. external function isValidQuery(value: String): Boolean /// Tells if [value] is a percent-encoded `fragment`. external function isValidFragment(value: String): Boolean } /// Percent-encodes [value] so that it can be interpolated into any URL component. /// /// Every character other than an ASCII alphanumeric or one of `-._~`, the *unreserved* characters /// of RFC 3986, is percent-encoded as its UTF-8 bytes. /// /// Facts: /// ``` /// net.encodeUrlComponent("a b&c") == "a%20b%26c" /// net.encodeUrlComponent("50%") == "50%25" /// ``` external const function encodeUrlComponent(value: String): String /// Percent-decodes [value], interpreting the decoded bytes as UTF-8. /// /// Throws if any `%` in [value] does not begin a percent-encoded octet. /// To write a literal `%`, encode it as `%25`. /// /// Facts: /// ``` /// net.decodeUrlComponent("a%20b%26c") == "a b&c" /// net.decodeUrlComponent("50%25") == "50%" /// ``` external const function decodeUrlComponent(value: String): String /// Builds a query from [parameters], for use as a [Url.rawQuery]. /// /// The result is `application/x-www-form-urlencoded`. /// A name is written once per value it holds; one that holds no values is left out entirely. /// /// [Url.queryParameters] reads back exactly the parameters given here, less any name that holds no /// values. /// /// Facts: /// ``` /// net.buildQuery(new Mapping { ["q"] = new Listing { "a b" } }) == "q=a+b" /// net.buildQuery(new Mapping { ["a"] = new Listing { "1"; "2" } }) == "a=1&a=2" /// net.buildQuery(new Mapping { ["a"] = new Listing { "1" }; ["b"] = new Listing {} }) == "a=1" /// ``` external const function buildQuery(parameters: Mapping>): String /// Reads [query] back into its parameters. /// /// [query] is read as `application/x-www-form-urlencoded`. /// A name that repeats holds the values of all its occurrences, in the order they are written. /// A parameter that states no `=` holds no value at all, and is left out. /// /// Throws if any `%` in [query] does not begin a percent-encoded octet. /// /// Facts: /// ``` /// net.parseQuery("a=1&a=2") == new Mapping { ["a"] = new Listing { "1"; "2" } } /// net.parseQuery("q=a+b&bare") == new Mapping { ["q"] = new Listing { "a b" } } /// ``` external const function parseQuery(query: String): Mapping>