From cf7b25464abe62d5508b275a9fac61d0cdce4576 Mon Sep 17 00:00:00 2001 From: Dan Schulte Date: Fri, 11 May 2018 15:35:15 -0700 Subject: [PATCH 1/2] Add the ability to read, modify, and substitute URL queries --- lib/url.ts | 147 +++++++++++++++++++++++++++++----------- test/shared/urlTests.ts | 76 +++++++++++++++++++++ 2 files changed, 184 insertions(+), 39 deletions(-) diff --git a/lib/url.ts b/lib/url.ts index c07caa0..6843f8a 100644 --- a/lib/url.ts +++ b/lib/url.ts @@ -1,8 +1,56 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. -export interface URLQuery { - [queryParameterName: string]: string; +export class URLQuery { + private readonly _rawQuery: { [queryParameterName: string]: string } = {}; + + public any(): boolean { + return Object.keys(this._rawQuery).length > 0; + } + + public set(parameterName: string, parameterValue: string | undefined): void { + if (parameterName) { + if (parameterValue) { + this._rawQuery[parameterName] = parameterValue; + } else { + delete this._rawQuery[parameterName]; + } + } + } + + public get(parameterName: string): string | undefined { + return parameterName ? this._rawQuery[parameterName] : undefined; + } + + public toString(): string { + let result = ""; + for (const parameterName in this._rawQuery) { + if (result) { + result += "&"; + } + result += `${parameterName}=${this._rawQuery[parameterName]}`; + } + return result; + } + + public static parse(text: string): URLQuery { + const result = new URLQuery(); + + if (text) { + if (text.startsWith("?")) { + text = text.substring(1); + } + const queryParameters: string[] = text.split("&"); + for (const queryParameter of queryParameters) { + const queryParameterParts: string[] = queryParameter.split("="); + if (queryParameterParts.length === 2) { + result.set(queryParameterParts[0], queryParameterParts[1]); + } + } + } + + return result; + } } /** @@ -13,7 +61,7 @@ export class URLBuilder { private _host: string | undefined; private _port: string | undefined; private _path: string | undefined; - private _query: URLQuery = {}; + private _query: URLQuery | undefined; /** * Set the scheme/protocol for this URL. If the provided scheme contains other parts of a URL @@ -96,25 +144,62 @@ export class URLBuilder { } /** - * Set the query parameter in this URL with the provided queryParameterName to be the provided - * queryParameterEncodedValue. + * If the provided searchValue is found in this URLBuilder's path, then replace it with the + * provided replaceValue. */ - public setQueryParameter(queryParameterName: string, queryParameterEncodedValue: string): void { - if (queryParameterName) { - this._query[queryParameterName] = queryParameterEncodedValue; + public pathSubstitution(searchValue: string, replaceValue: string): void { + if (this._path && searchValue) { + this._path = replaceAll(this._path, searchValue, replaceValue || ""); } } /** * Set the query in this URL. */ - public setQuery(query: string | URLQuery | undefined): void { + public setQuery(query: string | undefined): void { if (!query) { - this._query = {}; - } else if (typeof query !== "string") { - this._query = query; + this._query = undefined; } else { - this.set(query, URLTokenizerState.QUERY); + this._query = URLQuery.parse(query); + } + } + + /** + * Set a query parameter with the provided name and value in this URL's query. If the provided + * query parameter value is undefined or empty, then the query parameter will be removed if it + * existed. + */ + public setQueryParameter(queryParameterName: string, queryParameterValue: string | undefined): void { + if (queryParameterName) { + if (!this._query) { + this._query = new URLQuery(); + } + this._query.set(queryParameterName, queryParameterValue); + } + } + + /** + * Get the value of the query parameter with the provided query parameter name. If no query + * parameter exists with the provided name, then undefined will be returned. + */ + public getQueryParameterValue(queryParameterName: string): string | undefined { + return this._query ? this._query.get(queryParameterName) : undefined; + } + + /** + * Get the query in this URL. + */ + public getQuery(): string | undefined { + return this._query ? this._query.toString() : undefined; + } + + /** + * If the provided searchValue is found in this URLBuilder's query, then replace it with the + * provided replaceValue. + */ + public querySubstitution(searchValue: string, replaceValue: string): void { + if (this._query && searchValue) { + this._query = URLQuery.parse(replaceAll(this._query.toString(), searchValue, replaceValue)); } } @@ -148,21 +233,7 @@ export class URLBuilder { break; case URLTokenType.QUERY: - let queryString: string | undefined = token.text; - if (queryString) { - if (queryString.startsWith("?")) { - queryString = queryString.substring(1); - } - - for (const queryParameterString of queryString.split("&")) { - const queryParameterParts: string[] = queryParameterString.split("="); - if (queryParameterParts.length === 2) { - this.setQueryParameter(queryParameterParts[0], queryParameterParts[1]); - } else { - throw new Error("Malformed query parameter: " + queryParameterString); - } - } - } + this._query = URLQuery.parse(token.text); break; default: @@ -194,17 +265,8 @@ export class URLBuilder { result += this._path; } - if (this._query) { - let queryString = ""; - for (const queryParameterName in this._query) { - if (queryString) { - queryString += "&"; - } - queryString += `${queryParameterName}=${this._query[queryParameterName]}`; - } - if (queryString) { - result += `?${queryString}`; - } + if (this._query && this._query.any()) { + result += `?${this._query.toString()}`; } return result; @@ -271,6 +333,13 @@ export function isAlphaNumericCharacter(character: string): boolean { (97 /* 'a' */ <= characterCode && characterCode <= 122 /* 'z' */); } +/** + * Replace all of the instances of searchValue in value with the provided replaceValue. + */ +export function replaceAll(value: string, searchValue: string, replaceValue: string): string { + return !value || !searchValue ? value : value.split(searchValue).join(replaceValue || ""); +} + /** * A class that tokenizes URL strings. */ diff --git a/test/shared/urlTests.ts b/test/shared/urlTests.ts index 5006258..3028396 100644 --- a/test/shared/urlTests.ts +++ b/test/shared/urlTests.ts @@ -588,6 +588,82 @@ describe("URLBuilder", () => { assert.strictEqual(URLBuilder.parse("https://www.bing.com/my:/path").toString(), "https://www.bing.com/my:/path"); }); }); + + describe("pathSubstitution()", () => { + it(`with undefined path, "{arg}" searchValue, and "cats" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setPath(undefined); + urlBuilder.pathSubstitution("{arg}", "cats"); + assert.strictEqual(urlBuilder.getPath(), undefined); + assert.strictEqual(urlBuilder.toString(), ""); + }); + + it(`with "" path, "{arg}" searchValue, and "cats" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setPath(""); + urlBuilder.pathSubstitution("{arg}", "cats"); + assert.strictEqual(urlBuilder.getPath(), undefined); + assert.strictEqual(urlBuilder.toString(), ""); + }); + + it(`with "my/really/cool/path" path, "" searchValue, and "cats" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setPath("my/really/cool/path"); + urlBuilder.pathSubstitution("", "cats"); + assert.strictEqual(urlBuilder.getPath(), "my/really/cool/path"); + assert.strictEqual(urlBuilder.toString(), "/my/really/cool/path"); + }); + + it(`with "my/really/cool/path" path, "y" searchValue, and "z" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setPath("my/really/cool/path"); + urlBuilder.pathSubstitution("y", "z"); + assert.strictEqual(urlBuilder.getPath(), "mz/reallz/cool/path"); + assert.strictEqual(urlBuilder.toString(), "/mz/reallz/cool/path"); + }); + + it(`with "my/really/cool/path" path, "y" searchValue, and "" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setPath("my/really/cool/path"); + urlBuilder.pathSubstitution("y", ""); + assert.strictEqual(urlBuilder.getPath(), "m/reall/cool/path"); + assert.strictEqual(urlBuilder.toString(), "/m/reall/cool/path"); + }); + }); + + describe("querySubstitution()", () => { + it(`with undefined query, "A" searchValue, and "Z" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setQuery(undefined); + urlBuilder.querySubstitution("A", "Z"); + assert.strictEqual(urlBuilder.getQuery(), undefined); + assert.strictEqual(urlBuilder.toString(), ""); + }); + + it(`with "A=B&C=D&E=A" query, "" searchValue, and "Z" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setQuery("A=B&C=D&E=A"); + urlBuilder.querySubstitution("", "Z"); + assert.strictEqual(urlBuilder.getQuery(), "A=B&C=D&E=A"); + assert.strictEqual(urlBuilder.toString(), "?A=B&C=D&E=A"); + }); + + it(`with "A=B&C=D&E=A" query, "A" searchValue, and "" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setQuery("A=B&C=D&E=A"); + urlBuilder.querySubstitution("A", ""); + assert.strictEqual(urlBuilder.getQuery(), "C=D"); + assert.strictEqual(urlBuilder.toString(), "?C=D"); + }); + + it(`with "A=B&C=D&E=A" query, "A" searchValue, and "Z" replaceValue`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setQuery("A=B&C=D&E=A"); + urlBuilder.querySubstitution("A", "Z"); + assert.strictEqual(urlBuilder.getQuery(), "Z=B&C=D&E=Z"); + assert.strictEqual(urlBuilder.toString(), "?Z=B&C=D&E=Z"); + }); + }); }); describe("URLTokenizer", () => { From eb4f9a84b1f333328cad43bfba8569fcb4a91c5a Mon Sep 17 00:00:00 2001 From: Dan Schulte Date: Mon, 14 May 2018 09:20:39 -0700 Subject: [PATCH 2/2] URLQuery now supports empty parameter values --- lib/url.ts | 95 +++++++++++++++++++++++++++--- test/shared/urlTests.ts | 124 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 12 deletions(-) diff --git a/lib/url.ts b/lib/url.ts index 6843f8a..68189cc 100644 --- a/lib/url.ts +++ b/lib/url.ts @@ -1,27 +1,45 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +/** + * A class that handles the query portion of a URLBuilder. + */ export class URLQuery { private readonly _rawQuery: { [queryParameterName: string]: string } = {}; + /** + * Get whether or not there any query parameters in this URLQuery. + */ public any(): boolean { return Object.keys(this._rawQuery).length > 0; } - public set(parameterName: string, parameterValue: string | undefined): void { + /** + * Set a query parameter with the provided name and value. If the parameterValue is undefined or + * empty, then this will attempt to remove an existing query parameter with the provided + * parameterName. + */ + public set(parameterName: string, parameterValue: any): void { if (parameterName) { - if (parameterValue) { - this._rawQuery[parameterName] = parameterValue; + if (parameterValue != undefined) { + this._rawQuery[parameterName] = parameterValue.toString(); } else { delete this._rawQuery[parameterName]; } } } + /** + * Get the value of the query parameter with the provided name. If no parameter exists with the + * provided parameter name, then undefined will be returned. + */ public get(parameterName: string): string | undefined { return parameterName ? this._rawQuery[parameterName] : undefined; } + /** + * Get the string representation of this query. The return value will not start with a "?". + */ public toString(): string { let result = ""; for (const parameterName in this._rawQuery) { @@ -33,6 +51,9 @@ export class URLQuery { return result; } + /** + * Parse a URLQuery from the provided text. + */ public static parse(text: string): URLQuery { const result = new URLQuery(); @@ -40,13 +61,69 @@ export class URLQuery { if (text.startsWith("?")) { text = text.substring(1); } - const queryParameters: string[] = text.split("&"); - for (const queryParameter of queryParameters) { - const queryParameterParts: string[] = queryParameter.split("="); - if (queryParameterParts.length === 2) { - result.set(queryParameterParts[0], queryParameterParts[1]); + + const parameterNameState = "parameterName"; + const parameterValueState = "parameterValue"; + const invalidateParameterState = "invalidParameter"; + + let currentState = parameterNameState; + + let parameterName = ""; + let parameterValue = ""; + for (let i = 0; i < text.length; ++i) { + const currentCharacter: string = text[i]; + switch (currentState) { + case parameterNameState: + switch (currentCharacter) { + case "=": + currentState = parameterValueState; + break; + + case "&": + parameterName = ""; + parameterValue = ""; + break; + + default: + parameterName += currentCharacter; + break; + } + break; + + case parameterValueState: + switch (currentCharacter) { + case "=": + parameterName = ""; + parameterValue = ""; + currentState = invalidateParameterState; + break; + + case "&": + result.set(parameterName, parameterValue); + parameterName = ""; + parameterValue = ""; + currentState = parameterNameState; + break; + + default: + parameterValue += currentCharacter; + break; + } + break; + + case invalidateParameterState: + if (currentCharacter === "&") { + currentState = parameterNameState; + } + break; + + default: + throw new Error("Unrecognized URLQuery parse state: " + currentState); } } + if (currentState === parameterValueState) { + result.set(parameterName, parameterValue); + } } return result; @@ -169,7 +246,7 @@ export class URLBuilder { * query parameter value is undefined or empty, then the query parameter will be removed if it * existed. */ - public setQueryParameter(queryParameterName: string, queryParameterValue: string | undefined): void { + public setQueryParameter(queryParameterName: string, queryParameterValue: any): void { if (queryParameterName) { if (!this._query) { this._query = new URLQuery(); diff --git a/test/shared/urlTests.ts b/test/shared/urlTests.ts index 3028396..1b333ad 100644 --- a/test/shared/urlTests.ts +++ b/test/shared/urlTests.ts @@ -2,7 +2,109 @@ // Licensed under the MIT License. See License.txt in the project root for license information. import * as assert from "assert"; -import { URLTokenizer, URLToken, URLBuilder } from "../../lib/url"; +import { URLTokenizer, URLToken, URLBuilder, URLQuery } from "../../lib/url"; + +describe("URLQuery", () => { + it(`constructor()`, () => { + const urlQuery = new URLQuery(); + assert.strictEqual(urlQuery.any(), false); + assert.strictEqual(urlQuery.toString(), ""); + }); + + describe("set(string,string)", () => { + it(`with undefined parameter name`, () => { + const urlQuery = new URLQuery(); + urlQuery.set(undefined as any, "tasty"); + assert.strictEqual(urlQuery.get(undefined as any), undefined); + assert.strictEqual(urlQuery.any(), false); + assert.strictEqual(urlQuery.toString(), ""); + }); + + it(`with empty parameter name`, () => { + const urlQuery = new URLQuery(); + urlQuery.set("", "tasty"); + assert.strictEqual(urlQuery.get(""), undefined); + assert.strictEqual(urlQuery.any(), false); + assert.strictEqual(urlQuery.toString(), ""); + }); + + it(`with undefined parameter value`, () => { + const urlQuery = new URLQuery(); + urlQuery.set("apples", undefined); + assert.strictEqual(urlQuery.get("apples"), undefined); + assert.strictEqual(urlQuery.any(), false); + assert.strictEqual(urlQuery.toString(), ""); + }); + + it(`with empty parameter value`, () => { + const urlQuery = new URLQuery(); + urlQuery.set("apples", ""); + assert.strictEqual(urlQuery.get("apples"), ""); + assert.strictEqual(urlQuery.any(), true); + assert.strictEqual(urlQuery.toString(), "apples="); + }); + + it(`with non-empty parameter value`, () => { + const urlQuery = new URLQuery(); + urlQuery.set("apples", "grapes"); + assert.strictEqual(urlQuery.get("apples"), "grapes"); + assert.strictEqual(urlQuery.any(), true); + assert.strictEqual(urlQuery.toString(), "apples=grapes"); + }); + + it(`with existing parameter value and undefined parameter value`, () => { + const urlQuery = new URLQuery(); + urlQuery.set("apples", "grapes"); + urlQuery.set("apples", undefined); + assert.strictEqual(urlQuery.get("apples"), undefined); + assert.strictEqual(urlQuery.any(), false); + assert.strictEqual(urlQuery.toString(), ""); + }); + + it(`with existing parameter value and empty parameter value`, () => { + const urlQuery = new URLQuery(); + urlQuery.set("apples", "grapes"); + urlQuery.set("apples", ""); + assert.strictEqual(urlQuery.get("apples"), ""); + assert.strictEqual(urlQuery.any(), true); + assert.strictEqual(urlQuery.toString(), "apples="); + }); + }); + + describe("parse(string)", () => { + it(`with undefined`, () => { + assert.strictEqual(URLQuery.parse(undefined as any).toString(), ""); + }); + + it(`with ""`, () => { + assert.strictEqual(URLQuery.parse("").toString(), ""); + }); + + it(`with "A"`, () => { + assert.strictEqual(URLQuery.parse("A").toString(), ""); + }); + + it(`with "A="`, () => { + assert.strictEqual(URLQuery.parse("A=").toString(), "A="); + }); + + it(`with "A=B"`, () => { + assert.strictEqual(URLQuery.parse("A=B").toString(), "A=B"); + }); + + it(`with "A=&"`, () => { + assert.strictEqual(URLQuery.parse("A=").toString(), "A="); + }); + + it(`with "A=="`, () => { + assert.strictEqual(URLQuery.parse("A==").toString(), ""); + }); + + it(`with "A=&B=C"`, () => { + assert.strictEqual(URLQuery.parse("A=&B=C").toString(), "A=&B=C"); + }); + }); +}); describe("URLBuilder", () => { describe("setScheme()", () => { @@ -487,6 +589,22 @@ describe("URLBuilder", () => { }); }); + describe("setQueryParameter()", () => { + it(`with "a" and undefined`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setQueryParameter("a", undefined); + assert.strictEqual(urlBuilder.getQueryParameterValue("a"), undefined); + assert.strictEqual(urlBuilder.toString(), ""); + }); + + it(`with "a" and ""`, () => { + const urlBuilder = new URLBuilder(); + urlBuilder.setQueryParameter("a", ""); + assert.strictEqual(urlBuilder.getQueryParameterValue("a"), ""); + assert.strictEqual(urlBuilder.toString(), "?a="); + }); + }); + describe("parse()", () => { it(`with ""`, () => { assert.strictEqual(URLBuilder.parse("").toString(), ""); @@ -652,8 +770,8 @@ describe("URLBuilder", () => { const urlBuilder = new URLBuilder(); urlBuilder.setQuery("A=B&C=D&E=A"); urlBuilder.querySubstitution("A", ""); - assert.strictEqual(urlBuilder.getQuery(), "C=D"); - assert.strictEqual(urlBuilder.toString(), "?C=D"); + assert.strictEqual(urlBuilder.getQuery(), "C=D&E="); + assert.strictEqual(urlBuilder.toString(), "?C=D&E="); }); it(`with "A=B&C=D&E=A" query, "A" searchValue, and "Z" replaceValue`, () => {