// Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import * as qs from 'qs'; import * as path from 'path'; import zlib = require('zlib'); import { IRequestQueryParams, IHttpClientResponse } from './Interfaces'; /** * creates an url from a request url and optional base url (http://server:8080) * @param {string} resource - a fully qualified url or relative path * @param {string} baseUrl - an optional baseUrl (http://server:8080) * @param {IRequestOptions} options - an optional options object, could include QueryParameters e.g. * @return {string} - resultant url */ export function getUrl(resource: string, baseUrl?: string, queryParams?: IRequestQueryParams): string { const pathApi = path.posix || path; let requestUrl = ''; if (!baseUrl) { requestUrl = resource; } else if (!resource) { requestUrl = baseUrl; } else { try { let base: URL; // Parse base URL try { base = new URL(baseUrl); } catch (err) { throw new Error(`Invalid base URL: ${err.message}`); } // Check if resource is a full URL by attempting to parse it let isFullUrl = false; let resourceUrl: URL; try { resourceUrl = new URL(resource); isFullUrl = true; } catch { // Resource is not a full URL, treat as relative path isFullUrl = false; } let resultantUrl: URL; if (isFullUrl) { // Resource is a complete URL – merge missing components from base to preserve previous behavior const mergedUrl = new URL(resourceUrl.href); // Inherit auth if not specified on the resource URL if (!mergedUrl.username && base.username) { mergedUrl.username = base.username; } if (!mergedUrl.password && base.password) { mergedUrl.password = base.password; } // Inherit protocol and host if somehow absent on the resource URL if (!mergedUrl.protocol && base.protocol) { mergedUrl.protocol = base.protocol; } if (!mergedUrl.host && base.host) { mergedUrl.host = base.host; } resultantUrl = mergedUrl; } else { // Resource is a relative path - merge with base using path.resolve const protocol = base.protocol; const encodedUsername = base.username ? encodeURIComponent(base.username) : ''; const encodedPassword = base.password ? encodeURIComponent(base.password) : ''; const auth = encodedUsername && encodedPassword ? `${encodedUsername}:${encodedPassword}@` : encodedUsername ? `${encodedUsername}@` : ''; const host = base.host; // Use path.resolve for proper path merging (matches original behavior) const basePath = base.pathname || '/'; const resolvedPath = pathApi.resolve(basePath, resource); resultantUrl = new URL(`${protocol}//${auth}${host}${resolvedPath}`); // Preserve trailing slash from original resource if (!resultantUrl.pathname.endsWith('/') && resource.endsWith('/')) { resultantUrl.pathname += '/'; } } requestUrl = resultantUrl.href; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to construct URL ${message}`); } } return queryParams ? getUrlWithParsedQueryParams(requestUrl, queryParams): requestUrl; } /** * * @param {string} requestUrl * @param {IRequestQueryParams} queryParams * @return {string} - Request's URL with Query Parameters appended/parsed. */ function getUrlWithParsedQueryParams(requestUrl: string, queryParams: IRequestQueryParams): string { const url: string = requestUrl.replace(/\?$/g, ''); // Clean any extra end-of-string "?" character const parsedQueryParams: string = qs.stringify(queryParams.params, buildParamsStringifyOptions(queryParams)); return `${url}${parsedQueryParams}`; } /** * Build options for QueryParams Stringifying. * * @param {IRequestQueryParams} queryParams * @return {object} */ function buildParamsStringifyOptions(queryParams: IRequestQueryParams): any { let options: any = { addQueryPrefix: true, delimiter: (queryParams.options || {}).separator || '&', allowDots: (queryParams.options || {}).shouldAllowDots || false, arrayFormat: (queryParams.options || {}).arrayFormat || 'repeat', encodeValuesOnly: (queryParams.options || {}).shouldOnlyEncodeValues || true } return options; } /** * Decompress/Decode gzip encoded JSON * Using Node.js built-in zlib module * * @param {Buffer} buffer * @param {string} charset? - optional; defaults to 'utf-8' * @return {Promise} */ export async function decompressGzippedContent(buffer: Buffer, charset?: BufferEncoding): Promise { return new Promise(async (resolve, reject) => { zlib.gunzip(buffer, function (error, buffer) { if (error) { reject(error); } else { resolve(buffer.toString(charset || 'utf-8')); } }); }) } /** * Builds a RegExp to test urls against for deciding * wether to bypass proxy from an entry of the * environment variable setting NO_PROXY * * @param {string} bypass * @return {RegExp} */ export function buildProxyBypassRegexFromEnv(bypass : string) : RegExp { try { // We need to keep this around for back-compat purposes return new RegExp(bypass, 'i') } catch(err) { if (err instanceof SyntaxError && (bypass || "").startsWith("*")) { let wildcardEscaped = bypass.replace('*', '(.*)'); return new RegExp(wildcardEscaped, 'i'); } throw err; } } /** * Obtain Response's Content Charset. * Through inspecting `content-type` response header. * It Returns 'utf-8' if NO charset specified/matched. * * @param {IHttpClientResponse} response * @return {string} - Content Encoding Charset; Default=utf-8 */ export function obtainContentCharset (response: IHttpClientResponse) : BufferEncoding { // Find the charset, if specified. // Search for the `charset=CHARSET` string, not including `;,\r\n` // Example: content-type: 'application/json;charset=utf-8' // |__ matches would be ['charset=utf-8', 'utf-8', index: 18, input: 'application/json; charset=utf-8'] // |_____ matches[1] would have the charset :tada: , in our example it's utf-8 // However, if the matches Array was empty or no charset found, 'utf-8' would be returned by default. const nodeSupportedEncodings: BufferEncoding[] = ['ascii', 'utf8', 'utf16le', 'ucs2', 'base64', 'binary', 'hex']; const contentType: string = response.message.headers['content-type'] || ''; const matches: (RegExpMatchArray|null) = contentType.match(/charset=([^;,\r\n]+)/i); if (matches && matches[1] && nodeSupportedEncodings.indexOf(matches[1] as BufferEncoding) != -1) { return matches[1] as BufferEncoding; } return 'utf-8'; }