From 0c81e9de99961758dc56af63a2ae185384698c4e Mon Sep 17 00:00:00 2001 From: Kitson Kelly Date: Tue, 18 Dec 2018 08:07:08 +1000 Subject: [PATCH] Add URL --- BUILD.gn | 1 + js/globals.ts | 3 + js/unit_tests.ts | 1 + js/url.ts | 261 +++++++++++++++++++++++++++++++++++++++++++++++ js/url_test.ts | 131 ++++++++++++++++++++++++ 5 files changed, 397 insertions(+) create mode 100644 js/url.ts create mode 100644 js/url_test.ts diff --git a/BUILD.gn b/BUILD.gn index 921754ef18422c..1f6298ae4fcc80 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -100,6 +100,7 @@ ts_sources = [ "js/timers.ts", "js/truncate.ts", "js/types.ts", + "js/url.ts", "js/url_search_params.ts", "js/util.ts", "js/write_file.ts", diff --git a/js/globals.ts b/js/globals.ts index cf45b4239d3ef2..ac66f77e6826a0 100644 --- a/js/globals.ts +++ b/js/globals.ts @@ -16,6 +16,7 @@ import * as fetchTypes from "./fetch"; import * as headers from "./headers"; import * as textEncoding from "./text_encoding"; import * as timers from "./timers"; +import * as url from "./url"; import * as urlSearchParams from "./url_search_params"; // These imports are not exposed and therefore are fine to just import the @@ -56,6 +57,8 @@ window.Blob = blob.DenoBlob; export type Blob = blob.DenoBlob; window.File = file.DenoFile; export type File = file.DenoFile; +window.URL = url.URL; +export type URL = url.URL; window.URLSearchParams = urlSearchParams.URLSearchParams; export type URLSearchParams = urlSearchParams.URLSearchParams; diff --git a/js/unit_tests.ts b/js/unit_tests.ts index 55877091ebb59c..6ac071d3e7e495 100644 --- a/js/unit_tests.ts +++ b/js/unit_tests.ts @@ -34,5 +34,6 @@ import "./symlink_test.ts"; import "./text_encoding_test.ts"; import "./timers_test.ts"; import "./truncate_test.ts"; +import "./url_test.ts"; import "./url_search_params_test.ts"; import "./write_file_test.ts"; diff --git a/js/url.ts b/js/url.ts new file mode 100644 index 00000000000000..66fd3516188773 --- /dev/null +++ b/js/url.ts @@ -0,0 +1,261 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import * as urlSearchParams from "./url_search_params"; + +interface URLParts { + protocol: string; + username: string; + password: string; + hostname: string; + port: string; + path: string; + query: string; + hash: string; +} + +const patterns = { + protocol: "(?:([^:/?#]+):)", + authority: "(?://([^/?#]*))", + path: "([^?#]*)", + query: "(\\?[^#]*)", + hash: "(#.*)", + + authentication: "(?:([^:]*)(?::([^@]*))?@)", + hostname: "([^:]+)", + port: "(?::(\\d+))" +}; + +const urlRegExp = new RegExp( + `^${patterns.protocol}?${patterns.authority}?${patterns.path}${ + patterns.query + }?${patterns.hash}?` +); + +const authorityRegExp = new RegExp( + `^${patterns.authentication}?${patterns.hostname}${patterns.port}?$` +); + +const searchParamsMethods: Array = [ + "append", + "delete", + "set" +]; + +function parse(url: string): URLParts | undefined { + const urlMatch = urlRegExp.exec(url); + if (urlMatch) { + const [, , authority] = urlMatch; + const authorityMatch = authority + ? authorityRegExp.exec(authority) + : [null, null, null, null, null]; + if (authorityMatch) { + return { + protocol: urlMatch[1] || "", + username: authorityMatch[1] || "", + password: authorityMatch[2] || "", + hostname: authorityMatch[3] || "", + port: authorityMatch[4] || "", + path: urlMatch[3] || "", + query: urlMatch[4] || "", + hash: urlMatch[5] || "" + }; + } + } + return undefined; +} + +export class URL { + private _parts: URLParts; + private _searchParams!: urlSearchParams.URLSearchParams; + + private _updateSearchParams() { + const searchParams = new urlSearchParams.URLSearchParams(this.search); + + for (const methodName of searchParamsMethods) { + // tslint:disable:no-any + const method: (...args: any[]) => any = searchParams[methodName]; + searchParams[methodName] = (...args: any[]) => { + method.apply(searchParams, args); + this.search = searchParams.toString(); + }; + // tslint:enable + } + this._searchParams = searchParams; + } + + get hash(): string { + return this._parts.hash; + } + + set hash(value: string) { + value = unescape(String(value)); + if (!value) { + this._parts.hash = ""; + } else { + if (value.charAt(0) !== "#") { + value = `#${value}`; + } + // hashes can contain % and # unescaped + this._parts.hash = escape(value) + .replace("%25", "%") + .replace("%23", "#"); + } + } + + get host(): string { + return `${this.hostname}${this.port ? `:${this.port}` : ""}`; + } + + set host(value: string) { + value = String(value); + const url = new URL(`http://${value}`); + this._parts.hostname = url.hostname; + this._parts.port = url.port; + } + + get hostname(): string { + return this._parts.hostname; + } + + set hostname(value: string) { + value = String(value); + this._parts.hostname = encodeURIComponent(value); + } + + get href(): string { + const authentication = + this.username || this.password + ? `${this.username}${this.password ? ":" + this.password : ""}@` + : ""; + + return `${this.protocol}//${authentication}${this.host}${this.pathname}${ + this.search + }${this.hash}`; + } + + set href(value: string) { + value = String(value); + if (value !== this.href) { + const url = new URL(value); + this._parts = { ...url._parts }; + this._updateSearchParams(); + } + } + + get origin(): string { + return `${this.protocol}//${this.host}`; + } + + get password(): string { + return this._parts.password; + } + + set password(value: string) { + value = String(value); + this._parts.password = encodeURIComponent(value); + } + + get pathname(): string { + return this._parts.path ? this._parts.path : "/"; + } + + set pathname(value: string) { + value = unescape(String(value)); + if (!value || value.charAt(0) !== "/") { + value = `/${value}`; + } + // paths can contain % unescaped + this._parts.path = escape(value).replace("%25", "%"); + } + + get port(): string { + return this._parts.port; + } + + set port(value: string) { + const port = parseInt(String(value), 10); + this._parts.port = isNaN(port) + ? "" + : Math.max(0, port % 2 ** 16).toString(); + } + + get protocol(): string { + return `${this._parts.protocol}:`; + } + + set protocol(value: string) { + value = String(value); + if (value) { + if (value.charAt(value.length - 1) === ":") { + value = value.slice(0, -1); + } + this._parts.protocol = encodeURIComponent(value); + } + } + + get search(): string { + return this._parts.query; + } + + set search(value: string) { + value = String(value); + if (value.charAt(0) !== "?") { + value = `?${value}`; + } + this._parts.query = value; + this._updateSearchParams(); + } + + get username(): string { + return this._parts.username; + } + + set username(value: string) { + value = String(value); + this._parts.username = encodeURIComponent(value); + } + + get searchParams(): urlSearchParams.URLSearchParams { + return this._searchParams; + } + + constructor(url: string, base?: string | URL) { + let baseParts: URLParts | undefined; + if (base) { + baseParts = typeof base === "string" ? parse(base) : base._parts; + if (!baseParts) { + throw new TypeError("Invalid base URL."); + } + } + + const urlParts = parse(url); + if (!urlParts) { + throw new TypeError("Invalid URL."); + } + + if (urlParts.protocol) { + this._parts = urlParts; + } else if (baseParts) { + this._parts = { + protocol: baseParts.protocol, + username: baseParts.username, + password: baseParts.password, + hostname: baseParts.hostname, + port: baseParts.port, + path: urlParts.path || baseParts.path, + query: urlParts.query || baseParts.query, + hash: urlParts.hash + }; + } else { + throw new TypeError("URL requires a base URL."); + } + this._updateSearchParams(); + } + + toString(): string { + return this.href; + } + + toJSON(): string { + return this.href; + } +} diff --git a/js/url_test.ts b/js/url_test.ts new file mode 100644 index 00000000000000..f333e7240e7555 --- /dev/null +++ b/js/url_test.ts @@ -0,0 +1,131 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import { test, assert, assertEqual } from "./test_util.ts"; + +test(function urlParsing() { + const url = new URL( + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + assertEqual(url.hash, "#qat"); + assertEqual(url.host, "baz.qat:8000"); + assertEqual(url.hostname, "baz.qat"); + assertEqual( + url.href, + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + assertEqual(url.origin, "https://baz.qat:8000"); + assertEqual(url.password, "bar"); + assertEqual(url.pathname, "/qux/quux"); + assertEqual(url.port, "8000"); + assertEqual(url.protocol, "https:"); + assertEqual(url.search, "?foo=bar&baz=12"); + assertEqual(url.searchParams.getAll("foo"), ["bar"]); + assertEqual(url.searchParams.getAll("baz"), ["12"]); + assertEqual(url.username, "foo"); + assertEqual( + String(url), + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + assertEqual( + JSON.stringify({ key: url }), + `{"key":"https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat"}` + ); +}); + +test(function urlModifications() { + const url = new URL( + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + url.hash = ""; + assertEqual(url.href, "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12"); + url.host = "qat.baz:8080"; + assertEqual(url.href, "https://foo:bar@qat.baz:8080/qux/quux?foo=bar&baz=12"); + url.hostname = "foo.bar"; + assertEqual(url.href, "https://foo:bar@foo.bar:8080/qux/quux?foo=bar&baz=12"); + url.password = "qux"; + assertEqual(url.href, "https://foo:qux@foo.bar:8080/qux/quux?foo=bar&baz=12"); + url.pathname = "/foo/bar%qat"; + assertEqual( + url.href, + "https://foo:qux@foo.bar:8080/foo/bar%qat?foo=bar&baz=12" + ); + url.port = ""; + assertEqual(url.href, "https://foo:qux@foo.bar/foo/bar%qat?foo=bar&baz=12"); + url.protocol = "http:"; + assertEqual(url.href, "http://foo:qux@foo.bar/foo/bar%qat?foo=bar&baz=12"); + url.search = "?foo=bar&foo=baz"; + assertEqual(url.href, "http://foo:qux@foo.bar/foo/bar%qat?foo=bar&foo=baz"); + assertEqual(url.searchParams.getAll("foo"), ["bar", "baz"]); + url.username = "foo@bar"; + assertEqual( + url.href, + "http://foo%40bar:qux@foo.bar/foo/bar%qat?foo=bar&foo=baz" + ); + url.searchParams.set("bar", "qat"); + assertEqual( + url.href, + "http://foo%40bar:qux@foo.bar/foo/bar%qat?foo=bar&foo=baz&bar=qat" + ); + url.searchParams.delete("foo"); + assertEqual(url.href, "http://foo%40bar:qux@foo.bar/foo/bar%qat?bar=qat"); + url.searchParams.append("foo", "bar"); + assertEqual( + url.href, + "http://foo%40bar:qux@foo.bar/foo/bar%qat?bar=qat&foo=bar" + ); +}); + +test(function urlModifyHref() { + const url = new URL("http://example.com/"); + url.href = "https://foo:bar@example.com:8080/baz/qat#qux"; + assertEqual(url.protocol, "https:"); + assertEqual(url.username, "foo"); + assertEqual(url.password, "bar"); + assertEqual(url.host, "example.com:8080"); + assertEqual(url.hostname, "example.com"); + assertEqual(url.pathname, "/baz/qat"); + assertEqual(url.hash, "#qux"); +}); + +test(function urlModifyPathname() { + const url = new URL("http://foo.bar/baz%qat/qux"); + assertEqual(url.pathname, "/baz%qat/qux"); + url.pathname = url.pathname; + assertEqual(url.pathname, "/baz%qat/qux"); + url.pathname = "baz#qat qux"; + assertEqual(url.pathname, "/baz%23qat%20qux"); + url.pathname = url.pathname; + assertEqual(url.pathname, "/baz%23qat%20qux"); +}); + +test(function urlModifyHash() { + const url = new URL("http://foo.bar"); + url.hash = "%foo bar/qat"; + assertEqual(url.hash, "#%foo%20bar/qat"); + url.hash = url.hash; + assertEqual(url.hash, "#%foo%20bar/qat"); +}); + +test(function urlSearchParamsReuse() { + const url = new URL( + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + const sp = url.searchParams; + url.host = "baz.qat"; + assert(sp === url.searchParams, "Search params should be reused."); +}); + +test(function urlBaseURL() { + const base = new URL( + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + const url = new URL("/foo/bar?baz=foo#qux", base); + assertEqual(url.href, "https://foo:bar@baz.qat:8000/foo/bar?baz=foo#qux"); +}); + +test(function urlBaseString() { + const url = new URL( + "/foo/bar?baz=foo#qux", + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); + assertEqual(url.href, "https://foo:bar@baz.qat:8000/foo/bar?baz=foo#qux"); +});