url.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. // Copyright (c) Microsoft Corporation. All rights reserved.
  2. // Licensed under the MIT License. See License.txt in the project root for license information.
  3. import { replaceAll } from "./util/utils";
  4. type URLQueryParseState = "ParameterName" | "ParameterValue" | "Invalid";
  5. /**
  6. * A class that handles the query portion of a URLBuilder.
  7. */
  8. export class URLQuery {
  9. private readonly _rawQuery: { [queryParameterName: string]: string | string[] } = {};
  10. /**
  11. * Get whether or not there any query parameters in this URLQuery.
  12. */
  13. public any(): boolean {
  14. return Object.keys(this._rawQuery).length > 0;
  15. }
  16. /**
  17. * Set a query parameter with the provided name and value. If the parameterValue is undefined or
  18. * empty, then this will attempt to remove an existing query parameter with the provided
  19. * parameterName.
  20. */
  21. public set(parameterName: string, parameterValue: any): void {
  22. if (parameterName) {
  23. if (parameterValue != undefined) {
  24. const newValue = Array.isArray(parameterValue) ? parameterValue : parameterValue.toString();
  25. this._rawQuery[parameterName] = newValue;
  26. } else {
  27. delete this._rawQuery[parameterName];
  28. }
  29. }
  30. }
  31. /**
  32. * Get the value of the query parameter with the provided name. If no parameter exists with the
  33. * provided parameter name, then undefined will be returned.
  34. */
  35. public get(parameterName: string): string | string[] | undefined {
  36. return parameterName ? this._rawQuery[parameterName] : undefined;
  37. }
  38. /**
  39. * Get the string representation of this query. The return value will not start with a "?".
  40. */
  41. public toString(): string {
  42. let result = "";
  43. for (const parameterName in this._rawQuery) {
  44. if (result) {
  45. result += "&";
  46. }
  47. const parameterValue = this._rawQuery[parameterName];
  48. if (Array.isArray(parameterValue)) {
  49. const parameterStrings = [];
  50. for (const parameterValueElement of parameterValue) {
  51. parameterStrings.push(`${parameterName}=${parameterValueElement}`);
  52. }
  53. result += parameterStrings.join("&");
  54. } else {
  55. result += `${parameterName}=${parameterValue}`;
  56. }
  57. }
  58. return result;
  59. }
  60. /**
  61. * Parse a URLQuery from the provided text.
  62. */
  63. public static parse(text: string): URLQuery {
  64. const result = new URLQuery();
  65. if (text) {
  66. if (text.startsWith("?")) {
  67. text = text.substring(1);
  68. }
  69. let currentState: URLQueryParseState = "ParameterName";
  70. let parameterName = "";
  71. let parameterValue = "";
  72. for (let i = 0; i < text.length; ++i) {
  73. const currentCharacter: string = text[i];
  74. switch (currentState) {
  75. case "ParameterName":
  76. switch (currentCharacter) {
  77. case "=":
  78. currentState = "ParameterValue";
  79. break;
  80. case "&":
  81. parameterName = "";
  82. parameterValue = "";
  83. break;
  84. default:
  85. parameterName += currentCharacter;
  86. break;
  87. }
  88. break;
  89. case "ParameterValue":
  90. switch (currentCharacter) {
  91. case "=":
  92. parameterName = "";
  93. parameterValue = "";
  94. currentState = "Invalid";
  95. break;
  96. case "&":
  97. result.set(parameterName, parameterValue);
  98. parameterName = "";
  99. parameterValue = "";
  100. currentState = "ParameterName";
  101. break;
  102. default:
  103. parameterValue += currentCharacter;
  104. break;
  105. }
  106. break;
  107. case "Invalid":
  108. if (currentCharacter === "&") {
  109. currentState = "ParameterName";
  110. }
  111. break;
  112. default:
  113. throw new Error("Unrecognized URLQuery parse state: " + currentState);
  114. }
  115. }
  116. if (currentState === "ParameterValue") {
  117. result.set(parameterName, parameterValue);
  118. }
  119. }
  120. return result;
  121. }
  122. }
  123. /**
  124. * A class that handles creating, modifying, and parsing URLs.
  125. */
  126. export class URLBuilder {
  127. private _scheme: string | undefined;
  128. private _host: string | undefined;
  129. private _port: string | undefined;
  130. private _path: string | undefined;
  131. private _query: URLQuery | undefined;
  132. /**
  133. * Set the scheme/protocol for this URL. If the provided scheme contains other parts of a URL
  134. * (such as a host, port, path, or query), those parts will be added to this URL as well.
  135. */
  136. public setScheme(scheme: string | undefined): void {
  137. if (!scheme) {
  138. this._scheme = undefined;
  139. } else {
  140. this.set(scheme, "SCHEME");
  141. }
  142. }
  143. /**
  144. * Get the scheme that has been set in this URL.
  145. */
  146. public getScheme(): string | undefined {
  147. return this._scheme;
  148. }
  149. /**
  150. * Set the host for this URL. If the provided host contains other parts of a URL (such as a
  151. * port, path, or query), those parts will be added to this URL as well.
  152. */
  153. public setHost(host: string | undefined): void {
  154. if (!host) {
  155. this._host = undefined;
  156. } else {
  157. this.set(host, "SCHEME_OR_HOST");
  158. }
  159. }
  160. /**
  161. * Get the host that has been set in this URL.
  162. */
  163. public getHost(): string | undefined {
  164. return this._host;
  165. }
  166. /**
  167. * Set the port for this URL. If the provided port contains other parts of a URL (such as a
  168. * path or query), those parts will be added to this URL as well.
  169. */
  170. public setPort(port: number | string | undefined): void {
  171. if (port == undefined || port === "") {
  172. this._port = undefined;
  173. } else {
  174. this.set(port.toString(), "PORT");
  175. }
  176. }
  177. /**
  178. * Get the port that has been set in this URL.
  179. */
  180. public getPort(): string | undefined {
  181. return this._port;
  182. }
  183. /**
  184. * Set the path for this URL. If the provided path contains a query, then it will be added to
  185. * this URL as well.
  186. */
  187. public setPath(path: string | undefined): void {
  188. if (!path) {
  189. this._path = undefined;
  190. } else {
  191. if (path.indexOf("://") !== -1) {
  192. this.set(path, "SCHEME");
  193. } else {
  194. this.set(path, "PATH");
  195. }
  196. }
  197. }
  198. /**
  199. * Append the provided path to this URL's existing path. If the provided path contains a query,
  200. * then it will be added to this URL as well.
  201. */
  202. public appendPath(path: string | undefined): void {
  203. if (path) {
  204. let currentPath: string | undefined = this.getPath();
  205. if (currentPath) {
  206. if (!currentPath.endsWith("/")) {
  207. currentPath += "/";
  208. }
  209. if (path.startsWith("/")) {
  210. path = path.substring(1);
  211. }
  212. path = currentPath + path;
  213. }
  214. this.set(path, "PATH");
  215. }
  216. }
  217. /**
  218. * Get the path that has been set in this URL.
  219. */
  220. public getPath(): string | undefined {
  221. return this._path;
  222. }
  223. /**
  224. * Set the query in this URL.
  225. */
  226. public setQuery(query: string | undefined): void {
  227. if (!query) {
  228. this._query = undefined;
  229. } else {
  230. this._query = URLQuery.parse(query);
  231. }
  232. }
  233. /**
  234. * Set a query parameter with the provided name and value in this URL's query. If the provided
  235. * query parameter value is undefined or empty, then the query parameter will be removed if it
  236. * existed.
  237. */
  238. public setQueryParameter(queryParameterName: string, queryParameterValue: any): void {
  239. if (queryParameterName) {
  240. if (!this._query) {
  241. this._query = new URLQuery();
  242. }
  243. this._query.set(queryParameterName, queryParameterValue);
  244. }
  245. }
  246. /**
  247. * Get the value of the query parameter with the provided query parameter name. If no query
  248. * parameter exists with the provided name, then undefined will be returned.
  249. */
  250. public getQueryParameterValue(queryParameterName: string): string | string[] | undefined {
  251. return this._query ? this._query.get(queryParameterName) : undefined;
  252. }
  253. /**
  254. * Get the query in this URL.
  255. */
  256. public getQuery(): string | undefined {
  257. return this._query ? this._query.toString() : undefined;
  258. }
  259. /**
  260. * Set the parts of this URL by parsing the provided text using the provided startState.
  261. */
  262. private set(text: string, startState: URLTokenizerState): void {
  263. const tokenizer = new URLTokenizer(text, startState);
  264. while (tokenizer.next()) {
  265. const token: URLToken | undefined = tokenizer.current();
  266. if (token) {
  267. switch (token.type) {
  268. case "SCHEME":
  269. this._scheme = token.text || undefined;
  270. break;
  271. case "HOST":
  272. this._host = token.text || undefined;
  273. break;
  274. case "PORT":
  275. this._port = token.text || undefined;
  276. break;
  277. case "PATH":
  278. const tokenPath: string | undefined = token.text || undefined;
  279. if (!this._path || this._path === "/" || tokenPath !== "/") {
  280. this._path = tokenPath;
  281. }
  282. break;
  283. case "QUERY":
  284. this._query = URLQuery.parse(token.text);
  285. break;
  286. default:
  287. throw new Error(`Unrecognized URLTokenType: ${token.type}`);
  288. }
  289. }
  290. }
  291. }
  292. public toString(): string {
  293. let result = "";
  294. if (this._scheme) {
  295. result += `${this._scheme}://`;
  296. }
  297. if (this._host) {
  298. result += this._host;
  299. }
  300. if (this._port) {
  301. result += `:${this._port}`;
  302. }
  303. if (this._path) {
  304. if (!this._path.startsWith("/")) {
  305. result += "/";
  306. }
  307. result += this._path;
  308. }
  309. if (this._query && this._query.any()) {
  310. result += `?${this._query.toString()}`;
  311. }
  312. return result;
  313. }
  314. /**
  315. * If the provided searchValue is found in this URLBuilder, then replace it with the provided
  316. * replaceValue.
  317. */
  318. public replaceAll(searchValue: string, replaceValue: string): void {
  319. if (searchValue) {
  320. this.setScheme(replaceAll(this.getScheme(), searchValue, replaceValue));
  321. this.setHost(replaceAll(this.getHost(), searchValue, replaceValue));
  322. this.setPort(replaceAll(this.getPort(), searchValue, replaceValue));
  323. this.setPath(replaceAll(this.getPath(), searchValue, replaceValue));
  324. this.setQuery(replaceAll(this.getQuery(), searchValue, replaceValue));
  325. }
  326. }
  327. public static parse(text: string): URLBuilder {
  328. const result = new URLBuilder();
  329. result.set(text, "SCHEME_OR_HOST");
  330. return result;
  331. }
  332. }
  333. type URLTokenizerState = "SCHEME" | "SCHEME_OR_HOST" | "HOST" | "PORT" | "PATH" | "QUERY" | "DONE";
  334. type URLTokenType = "SCHEME" | "HOST" | "PORT" | "PATH" | "QUERY";
  335. export class URLToken {
  336. public constructor(public readonly text: string, public readonly type: URLTokenType) {
  337. }
  338. public static scheme(text: string): URLToken {
  339. return new URLToken(text, "SCHEME");
  340. }
  341. public static host(text: string): URLToken {
  342. return new URLToken(text, "HOST");
  343. }
  344. public static port(text: string): URLToken {
  345. return new URLToken(text, "PORT");
  346. }
  347. public static path(text: string): URLToken {
  348. return new URLToken(text, "PATH");
  349. }
  350. public static query(text: string): URLToken {
  351. return new URLToken(text, "QUERY");
  352. }
  353. }
  354. /**
  355. * Get whether or not the provided character (single character string) is an alphanumeric (letter or
  356. * digit) character.
  357. */
  358. export function isAlphaNumericCharacter(character: string): boolean {
  359. const characterCode: number = character.charCodeAt(0);
  360. return (48 /* '0' */ <= characterCode && characterCode <= 57 /* '9' */) ||
  361. (65 /* 'A' */ <= characterCode && characterCode <= 90 /* 'Z' */) ||
  362. (97 /* 'a' */ <= characterCode && characterCode <= 122 /* 'z' */);
  363. }
  364. /**
  365. * A class that tokenizes URL strings.
  366. */
  367. export class URLTokenizer {
  368. readonly _textLength: number;
  369. _currentState: URLTokenizerState;
  370. _currentIndex: number;
  371. _currentToken: URLToken | undefined;
  372. public constructor(readonly _text: string, state?: URLTokenizerState) {
  373. this._textLength = _text ? _text.length : 0;
  374. this._currentState = state != undefined ? state : "SCHEME_OR_HOST";
  375. this._currentIndex = 0;
  376. }
  377. /**
  378. * Get the current URLToken this URLTokenizer is pointing at, or undefined if the URLTokenizer
  379. * hasn't started or has finished tokenizing.
  380. */
  381. public current(): URLToken | undefined {
  382. return this._currentToken;
  383. }
  384. /**
  385. * Advance to the next URLToken and return whether or not a URLToken was found.
  386. */
  387. public next(): boolean {
  388. if (!hasCurrentCharacter(this)) {
  389. this._currentToken = undefined;
  390. } else {
  391. switch (this._currentState) {
  392. case "SCHEME":
  393. nextScheme(this);
  394. break;
  395. case "SCHEME_OR_HOST":
  396. nextSchemeOrHost(this);
  397. break;
  398. case "HOST":
  399. nextHost(this);
  400. break;
  401. case "PORT":
  402. nextPort(this);
  403. break;
  404. case "PATH":
  405. nextPath(this);
  406. break;
  407. case "QUERY":
  408. nextQuery(this);
  409. break;
  410. default:
  411. throw new Error(`Unrecognized URLTokenizerState: ${this._currentState}`);
  412. }
  413. }
  414. return !!this._currentToken;
  415. }
  416. }
  417. /**
  418. * Read the remaining characters from this Tokenizer's character stream.
  419. */
  420. function readRemaining(tokenizer: URLTokenizer): string {
  421. let result = "";
  422. if (tokenizer._currentIndex < tokenizer._textLength) {
  423. result = tokenizer._text.substring(tokenizer._currentIndex);
  424. tokenizer._currentIndex = tokenizer._textLength;
  425. }
  426. return result;
  427. }
  428. /**
  429. * Whether or not this URLTokenizer has a current character.
  430. */
  431. function hasCurrentCharacter(tokenizer: URLTokenizer): boolean {
  432. return tokenizer._currentIndex < tokenizer._textLength;
  433. }
  434. /**
  435. * Get the character in the text string at the current index.
  436. */
  437. function getCurrentCharacter(tokenizer: URLTokenizer): string {
  438. return tokenizer._text[tokenizer._currentIndex];
  439. }
  440. /**
  441. * Advance to the character in text that is "step" characters ahead. If no step value is provided,
  442. * then step will default to 1.
  443. */
  444. function nextCharacter(tokenizer: URLTokenizer, step?: number): void {
  445. if (hasCurrentCharacter(tokenizer)) {
  446. if (!step) {
  447. step = 1;
  448. }
  449. tokenizer._currentIndex += step;
  450. }
  451. }
  452. /**
  453. * Starting with the current character, peek "charactersToPeek" number of characters ahead in this
  454. * Tokenizer's stream of characters.
  455. */
  456. function peekCharacters(tokenizer: URLTokenizer, charactersToPeek: number): string {
  457. let endIndex: number = tokenizer._currentIndex + charactersToPeek;
  458. if (tokenizer._textLength < endIndex) {
  459. endIndex = tokenizer._textLength;
  460. }
  461. return tokenizer._text.substring(tokenizer._currentIndex, endIndex);
  462. }
  463. /**
  464. * Read characters from this Tokenizer until the end of the stream or until the provided condition
  465. * is false when provided the current character.
  466. */
  467. function readWhile(tokenizer: URLTokenizer, condition: (character: string) => boolean): string {
  468. let result = "";
  469. while (hasCurrentCharacter(tokenizer)) {
  470. const currentCharacter: string = getCurrentCharacter(tokenizer);
  471. if (!condition(currentCharacter)) {
  472. break;
  473. } else {
  474. result += currentCharacter;
  475. nextCharacter(tokenizer);
  476. }
  477. }
  478. return result;
  479. }
  480. /**
  481. * Read characters from this Tokenizer until a non-alphanumeric character or the end of the
  482. * character stream is reached.
  483. */
  484. function readWhileLetterOrDigit(tokenizer: URLTokenizer): string {
  485. return readWhile(tokenizer, (character: string) => isAlphaNumericCharacter(character));
  486. }
  487. /**
  488. * Read characters from this Tokenizer until one of the provided terminating characters is read or
  489. * the end of the character stream is reached.
  490. */
  491. function readUntilCharacter(tokenizer: URLTokenizer, ...terminatingCharacters: string[]): string {
  492. return readWhile(tokenizer, (character: string) => terminatingCharacters.indexOf(character) === -1);
  493. }
  494. function nextScheme(tokenizer: URLTokenizer): void {
  495. const scheme: string = readWhileLetterOrDigit(tokenizer);
  496. tokenizer._currentToken = URLToken.scheme(scheme);
  497. if (!hasCurrentCharacter(tokenizer)) {
  498. tokenizer._currentState = "DONE";
  499. } else {
  500. tokenizer._currentState = "HOST";
  501. }
  502. }
  503. function nextSchemeOrHost(tokenizer: URLTokenizer): void {
  504. const schemeOrHost: string = readUntilCharacter(tokenizer, ":", "/", "?");
  505. if (!hasCurrentCharacter(tokenizer)) {
  506. tokenizer._currentToken = URLToken.host(schemeOrHost);
  507. tokenizer._currentState = "DONE";
  508. } else if (getCurrentCharacter(tokenizer) === ":") {
  509. if (peekCharacters(tokenizer, 3) === "://") {
  510. tokenizer._currentToken = URLToken.scheme(schemeOrHost);
  511. tokenizer._currentState = "HOST";
  512. } else {
  513. tokenizer._currentToken = URLToken.host(schemeOrHost);
  514. tokenizer._currentState = "PORT";
  515. }
  516. } else {
  517. tokenizer._currentToken = URLToken.host(schemeOrHost);
  518. if (getCurrentCharacter(tokenizer) === "/") {
  519. tokenizer._currentState = "PATH";
  520. } else {
  521. tokenizer._currentState = "QUERY";
  522. }
  523. }
  524. }
  525. function nextHost(tokenizer: URLTokenizer): void {
  526. if (peekCharacters(tokenizer, 3) === "://") {
  527. nextCharacter(tokenizer, 3);
  528. }
  529. const host: string = readUntilCharacter(tokenizer, ":", "/", "?");
  530. tokenizer._currentToken = URLToken.host(host);
  531. if (!hasCurrentCharacter(tokenizer)) {
  532. tokenizer._currentState = "DONE";
  533. } else if (getCurrentCharacter(tokenizer) === ":") {
  534. tokenizer._currentState = "PORT";
  535. } else if (getCurrentCharacter(tokenizer) === "/") {
  536. tokenizer._currentState = "PATH";
  537. } else {
  538. tokenizer._currentState = "QUERY";
  539. }
  540. }
  541. function nextPort(tokenizer: URLTokenizer): void {
  542. if (getCurrentCharacter(tokenizer) === ":") {
  543. nextCharacter(tokenizer);
  544. }
  545. const port: string = readUntilCharacter(tokenizer, "/", "?");
  546. tokenizer._currentToken = URLToken.port(port);
  547. if (!hasCurrentCharacter(tokenizer)) {
  548. tokenizer._currentState = "DONE";
  549. } else if (getCurrentCharacter(tokenizer) === "/") {
  550. tokenizer._currentState = "PATH";
  551. } else {
  552. tokenizer._currentState = "QUERY";
  553. }
  554. }
  555. function nextPath(tokenizer: URLTokenizer): void {
  556. const path: string = readUntilCharacter(tokenizer, "?");
  557. tokenizer._currentToken = URLToken.path(path);
  558. if (!hasCurrentCharacter(tokenizer)) {
  559. tokenizer._currentState = "DONE";
  560. } else {
  561. tokenizer._currentState = "QUERY";
  562. }
  563. }
  564. function nextQuery(tokenizer: URLTokenizer): void {
  565. if (getCurrentCharacter(tokenizer) === "?") {
  566. nextCharacter(tokenizer);
  567. }
  568. const query: string = readRemaining(tokenizer);
  569. tokenizer._currentToken = URLToken.query(query);
  570. tokenizer._currentState = "DONE";
  571. }