@typespec/ts-http-runtime
Version:
Isomorphic client library for making HTTP requests in node.js and browser.
250 lines • 10.7 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
function isQueryParameterWithOptions(x) {
if (typeof x !== "object" || x === null || !Object.hasOwn(x, "value")) {
return false;
}
const value = x.value;
return typeof value?.toString === "function";
}
/**
* Builds the request url, filling in query and path parameters
* @param endpoint - base url which can be a template url
* @param routePath - path to append to the endpoint
* @param pathParameters - values of the path parameters
* @param options - request parameters including query parameters
* @returns a full url with path and query parameters
*/
export function buildRequestUrl(endpoint, routePath, pathParameters, options = {}) {
if (routePath.startsWith("https://") || routePath.startsWith("http://")) {
return routePath;
}
endpoint = buildBaseUrl(endpoint, options);
// the route could be
// 1. a path: "container123/blob456"
// 2. a component string from template which starts with "?" and may contain more "?" after template is expanded,
// e.g., "?restype=container&comp=blobs?where=key177196556777405927%3D%27val1177196556777407626%27"
const updatedRoutePath = buildRoutePath(routePath, pathParameters, options);
const requestUrl = appendQueryParams(appendPath(endpoint, updatedRoutePath), options);
const url = new URL(requestUrl);
return url.toString();
}
function appendPath(endpoint, pathToAppend) {
const endpointSearchStart = endpoint.indexOf("?");
const pathSearchStart = pathToAppend.indexOf("?");
const endpointParts = endpointSearchStart !== -1
? [endpoint.substring(0, endpointSearchStart), endpoint.substring(endpointSearchStart + 1)]
: [endpoint, ""];
const pathParts = pathSearchStart !== -1
? [pathToAppend.substring(0, pathSearchStart), pathToAppend.substring(pathSearchStart + 1)]
: [pathToAppend, ""];
const combinedSearch = [endpointParts[1], pathParts[1].replaceAll("?", "&")]
.filter(Boolean)
.join("&");
// Replace consecutive forward slashes with a single forward slash, but only for the part right after the host in the endpoint.
// This is to maintain compatibility with old behavior for cases where the endpoint has been provided with extra forward slashes,
// while still allowing for intentional consecutive forward slashes in the path to be preserved.
const baseEndpoint = endpointParts[0].replace(/(^[^:]+:\/\/[^/]+)\/\/+/, "$1/");
const basePathToAppend = pathParts[0];
let combinedUrl = baseEndpoint;
if (!baseEndpoint.endsWith("/") && !basePathToAppend.startsWith("/") && basePathToAppend !== "") {
combinedUrl += `/${basePathToAppend}`;
}
else if (baseEndpoint.endsWith("/") && basePathToAppend.startsWith("/")) {
combinedUrl += basePathToAppend.substring(1);
}
else {
combinedUrl += basePathToAppend;
}
if (combinedSearch) {
combinedUrl += `?${combinedSearch}`;
}
return combinedUrl;
}
function getQueryParamValue(key, allowReserved, style, param) {
let separator;
if (style === "pipeDelimited") {
separator = "|";
}
else if (style === "spaceDelimited") {
separator = "%20";
}
else {
separator = ",";
}
let paramValues;
if (Array.isArray(param)) {
paramValues = param;
}
else if (typeof param === "object" && param.toString === Object.prototype.toString) {
// If the parameter is an object without a custom toString implementation (e.g. a Date),
// then we should deconstruct the object into an array [key1, value1, key2, value2, ...].
paramValues = Object.entries(param).flat();
}
else {
paramValues = [param];
}
const value = paramValues
.map((p) => {
if (p === null || p === undefined) {
return "";
}
if (!p.toString || typeof p.toString !== "function") {
throw new Error(`Query parameters must be able to be represented as string, ${key} can't`);
}
const rawValue = p.toISOString !== undefined ? p.toISOString() : p.toString();
return allowReserved ? rawValue : encodeURIComponent(rawValue);
})
.join(separator);
return `${allowReserved ? key : encodeURIComponent(key)}=${value}`;
}
/**
* Parses a query string into a map of key/value pairs without decoding the values.
* This avoids the issue where `URL.searchParams` would decode values, potentially
* corrupting already-encoded values such as SAS signatures.
*/
function simpleParseQueryParams(queryString) {
const result = new Map();
if (!queryString || queryString[0] !== "?") {
return result;
}
// remove the leading ?
queryString = queryString.slice(1);
const pairs = queryString.split("&");
for (const pair of pairs) {
const eqIndex = pair.indexOf("=");
const name = eqIndex === -1 ? pair : pair.substring(0, eqIndex);
const value = eqIndex === -1 ? "" : pair.substring(eqIndex + 1);
const existingValue = result.get(name);
if (existingValue !== undefined) {
if (Array.isArray(existingValue)) {
existingValue.push(value);
}
else {
result.set(name, [existingValue, value]);
}
}
else {
result.set(name, value);
}
}
return result;
}
/** @internal */
export function appendQueryParams(url, options = {}) {
if (!options.queryParameters) {
return url;
}
const parsedUrl = new URL(url);
const queryParams = options.queryParameters;
// Parse existing query params from the URL manually to avoid re-encoding issues
const existingParams = simpleParseQueryParams(parsedUrl.search);
const newParamStrings = [];
for (const key of Object.keys(queryParams)) {
const param = queryParams[key];
if (param === undefined || param === null) {
continue;
}
const hasMetadata = isQueryParameterWithOptions(param);
const rawValue = hasMetadata ? param.value : param;
const explode = hasMetadata ? (param.explode ?? false) : false;
const style = hasMetadata && param.style ? param.style : "form";
if (explode) {
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
newParamStrings.push(getQueryParamValue(key, options.skipUrlEncoding ?? false, style, item));
}
}
else if (rawValue !== null && typeof rawValue === "object") {
// For object explode, the name of the query parameter is ignored and we use the object key instead
for (const [actualKey, value] of Object.entries(rawValue)) {
newParamStrings.push(getQueryParamValue(actualKey, options.skipUrlEncoding ?? false, style, value));
}
}
else {
// Explode doesn't really make sense for primitives
throw new Error("explode can only be set to true for objects and arrays");
}
}
else {
newParamStrings.push(getQueryParamValue(key, options.skipUrlEncoding ?? false, style, rawValue));
}
}
// Merge new params into existing params, deduplicating values for the same key
for (const paramString of newParamStrings) {
const eqIndex = paramString.indexOf("=");
const name = paramString.substring(0, eqIndex);
const value = paramString.substring(eqIndex + 1);
const existingValue = existingParams.get(name);
if (existingValue !== undefined) {
if (Array.isArray(existingValue)) {
if (!existingValue.includes(value)) {
existingValue.push(value);
}
}
else if (existingValue !== value) {
existingParams.set(name, [existingValue, value]);
}
// if existingValue === value (single string match), no change needed
}
else {
existingParams.set(name, value);
}
}
// Reconstruct the search string manually to avoid URL re-encoding
const searchPieces = [];
for (const [name, value] of existingParams) {
if (Array.isArray(value)) {
for (const subValue of value) {
searchPieces.push(`${name}=${subValue}`);
}
}
else {
searchPieces.push(`${name}=${value}`);
}
}
parsedUrl.search = searchPieces.length ? `?${searchPieces.join("&")}` : "";
return parsedUrl.toString();
}
export function buildBaseUrl(endpoint, options) {
if (!options.pathParameters) {
return endpoint;
}
const pathParams = options.pathParameters;
for (const [key, param] of Object.entries(pathParams)) {
if (param === undefined || param === null) {
throw new Error(`Path parameters ${key} must not be undefined or null`);
}
if (!param.toString || typeof param.toString !== "function") {
throw new Error(`Path parameters must be able to be represented as string, ${key} can't`);
}
let value = param.toISOString !== undefined ? param.toISOString() : String(param);
if (!options.skipUrlEncoding) {
value = encodeURIComponent(param);
}
endpoint = replaceAll(endpoint, `{${key}}`, value) ?? "";
}
return endpoint;
}
function buildRoutePath(routePath, pathParameters, options = {}) {
for (const pathParam of pathParameters) {
const allowReserved = typeof pathParam === "object" && (pathParam.allowReserved ?? false);
let value = typeof pathParam === "object" ? pathParam.value : pathParam;
if (!options.skipUrlEncoding && !allowReserved) {
value = encodeURIComponent(value);
}
routePath = routePath.replace(/\{[\w-]+\}/, String(value));
}
return routePath;
}
/**
* Replace all of the instances of searchValue in value with the provided replaceValue.
* @param value - The value to search and replace in.
* @param searchValue - The value to search for in the value argument.
* @param replaceValue - The value to replace searchValue with in the value argument.
* @returns The value where each instance of searchValue was replaced with replacedValue.
*/
export function replaceAll(value, searchValue, replaceValue) {
return !value || !searchValue ? value : value.split(searchValue).join(replaceValue || "");
}
//# sourceMappingURL=urlHelpers.js.map