350 lines
14 KiB
Plaintext
350 lines
14 KiB
Plaintext
//===----------------------------------------------------------------------===//
|
|
// 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.34.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<String>
|
|
|
|
/// 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<String, Listing<String>>?
|
|
|
|
/// 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, Listing<String>>): 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<String, Listing<String>>
|