UNPKG

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