UNPKG

nock

Version:

HTTP server mocking and expectations library for Node.js

538 lines (465 loc) • 14.2 kB
import { common as debug } from './debug.js' import timers from 'node:timers' import util from 'node:util' import zlib from 'node:zlib' function normalizeRequestOptions(options ) { options.proto = options.proto || 'http' options.port = options.port || (options.proto === 'http' ? 80 : 443) if (options.host) { debug('options.host:', options.host) if (!options.hostname) { if (options.host.split(':').length === 2) { options.hostname = options.host.split(':')[0] } else { options.hostname = options.host } } } debug('options.hostname in the end: %j', options.hostname) options.host = `${options.hostname || 'localhost'}:${options.port}` debug('options.host in the end: %j', options.host) /// lowercase host names ;['hostname', 'host'].forEach(function (attr) { if (options[attr]) { options[attr] = options[attr].toLowerCase() } }) return options } function isUtf8Representable(buffer ) { try { new TextDecoder('utf8', { fatal: true }).decode(buffer) return true } catch { return false } } function normalizeOrigin(url ) { // Remove brackets from hostname if IPV6 const normalizedOrigin = url.hostname.startsWith('[') ? `${url.protocol}//${url.hostname.slice(1, -1)}${url.port ? `:${url.port}` : ''}` : url.origin if (url.port) { return normalizedOrigin } else { return normalizedOrigin + (url.protocol === 'http:' ? ':80' : ':443') } } function stringifyRequest(request , body ) { const url = new URL(request.url) const log = { method: request.method, url: `${url.origin}${url.pathname}`, headers: Object.fromEntries(request.headers.entries()), } if (body) { log.body = body } return JSON.stringify(log, null, 2) } function isContentEncoded(headers ) { const contentEncoding = headers['content-encoding'] return typeof contentEncoding === 'string' && contentEncoding !== '' } function contentEncoding(headers , encoder ) { const contentEncoding = headers.get('content-encoding') return contentEncoding?.toString() === encoder } function isJSONContent(headers ) { // https://tools.ietf.org/html/rfc8259 const contentType = String(headers.get('content-type') || '').toLowerCase() return contentType.startsWith('application/json') } function headersFieldNamesToLowerCase( headers , throwOnDuplicate , ) { if (!isPlainObject(headers)) { throw Error('Headers must be provided as an object') } const lowerCaseHeaders = {} Object.entries(headers).forEach(([fieldName, fieldValue]) => { const key = fieldName.toLowerCase() if (lowerCaseHeaders[key] !== undefined) { if (throwOnDuplicate) { throw Error( `Failed to convert header keys to lower case due to field name conflict: ${key}`, ) } else { debug( `Duplicate header provided in request: ${key}. Only the last value can be matched.`, ) } } lowerCaseHeaders[key] = fieldValue }) return lowerCaseHeaders } const headersFieldsArrayToLowerCase = (headers ) => [ ...new Set(headers.map(fieldName => fieldName.toLowerCase())), ] function headersInputToRawArray( headers , ) { if (headers === undefined) { return [] } if (Array.isArray(headers)) { // If the input is an array, assume it's already in the raw format and simply return a copy // but throw an error if there aren't an even number of items in the array if (headers.length % 2) { throw new Error( `Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]`, ) } return [...headers] } // [].concat(...) is used instead of Array.flat until v11 is the minimum Node version if (util.types.isMap(headers)) { return ([] ).concat( ...Array.from(headers , ([k, v]) => [k.toString(), v]), ) } if (isPlainObject(headers)) { return ([] ).concat(...Object.entries(headers)) } throw new Error( `Headers must be provided as an array of raw values, a Map, or a plain Object. ${headers}`, ) } function headersArrayToObject(rawHeaders ) { if (!Array.isArray(rawHeaders)) { throw Error('Expected a header array') } const accumulator = {} forEachHeader(rawHeaders, (value, fieldName) => { addHeaderLine(accumulator, fieldName, value) }) return accumulator } const noDuplicatesHeaders = new Set([ 'age', 'authorization', 'content-length', 'content-type', 'etag', 'expires', 'from', 'host', 'if-modified-since', 'if-unmodified-since', 'last-modified', 'location', 'max-forwards', 'proxy-authorization', 'referer', 'retry-after', 'user-agent', ]) function addHeaderLine( headers , name , value , ) { let values // code below expects `values` to be an array of strings if (typeof value === 'function') { // Function values are evaluated towards the end of the response, before that we use a placeholder // string just to designate that the header exists. Useful when `Content-Type` is set with a function. values = [value.name] } else if (Array.isArray(value)) { values = value.map(String) } else { values = [String(value)] } const key = name.toLowerCase() if (key === 'set-cookie') { // Array header -- only Set-Cookie at the moment if (headers['set-cookie'] === undefined) { headers['set-cookie'] = values } else { headers['set-cookie'].push(...values) } } else if (noDuplicatesHeaders.has(key)) { if (headers[key] === undefined) { // Drop duplicates headers[key] = values[0] } } else { if (headers[key] !== undefined) { values = [headers[key], ...values] } const separator = key === 'cookie' ? '; ' : ', ' headers[key] = values.join(separator) } } function deleteHeadersField( headers , fieldNameToDelete , ) { if (!isPlainObject(headers)) { throw Error('headers must be an object') } if (typeof fieldNameToDelete !== 'string') { throw Error('field name must be a string') } const lowerCaseFieldNameToDelete = fieldNameToDelete.toLowerCase() // Search through the headers and delete all values whose field name matches the given field name. Object.keys(headers) .filter(fieldName => fieldName.toLowerCase() === lowerCaseFieldNameToDelete) .forEach(fieldName => delete headers[fieldName]) } function forEachHeader( rawHeaders , callback , ) { for (let i = 0; i < rawHeaders.length; i += 2) { callback(rawHeaders[i + 1], rawHeaders[i], i) } } function percentDecode(str ) { try { return decodeURIComponent(str.replace(/\+/g, ' ')) } catch { return str } } function percentEncode(str ) { return encodeURIComponent(str).replace(/[!'()*]/g, function (c) { return `%${c.charCodeAt(0).toString(16).toUpperCase()}` }) } function matchStringOrRegexp( target , pattern , ) { const targetStr = target === undefined || target === null ? '' : String(target) if (pattern instanceof RegExp) { // if the regexp happens to have a global flag, we want to ensure we test the entire target pattern.lastIndex = 0 return pattern.test(targetStr) } return targetStr === String(pattern) } function formatQueryValue( key , value , stringFormattingFn , ) { // TODO: Probably refactor code to replace `switch(true)` with `if`/`else`. switch (true) { case typeof value === 'number': // fall-through case typeof value === 'boolean': value = value.toString() break case value === null: case value === undefined: value = '' break case typeof value === 'string': if (stringFormattingFn) { value = stringFormattingFn(value) } break case value instanceof RegExp: break case Array.isArray(value): { value = value.map(function (val , idx ) { return formatQueryValue(String(idx), val, stringFormattingFn)[1] }) break } case typeof value === 'object': { value = Object.entries(value).reduce(function ( acc , [subKey, subVal], ) { const subPair = formatQueryValue(subKey, subVal, stringFormattingFn) acc[subPair[0]] = subPair[1] return acc }, {}) break } } if (stringFormattingFn) key = stringFormattingFn(key) return [key, value] } function isStream(obj ) { return ( obj && typeof obj !== 'string' && !Buffer.isBuffer(obj) && typeof obj.setEncoding === 'function' ) } const dataEqual = (expected , actual ) => { if (isPlainObject(expected)) { expected = expand(expected) } if (isPlainObject(actual)) { actual = expand(actual) } return deepEqual(expected, actual) } function deepEqual(expected , actual ) { debug('deepEqual comparing', typeof expected, expected, typeof actual, actual) if (expected instanceof RegExp) { if (expected.global || expected.sticky) { expected.lastIndex = 0 } return expected.test(actual) } if (Array.isArray(expected) && Array.isArray(actual)) { if (expected.length !== actual.length) { return false } return expected.every((expVal, idx) => deepEqual(expVal, actual[idx])) } if (isPlainObject(expected) && isPlainObject(actual)) { const allKeys = Array.from( new Set(Object.keys(expected).concat(Object.keys(actual))), ) return allKeys.every(key => deepEqual(expected[key], actual[key])) } return expected === actual } const timeouts = new Set() const immediates = new Set() const wrapTimer = (timer , ids ) => (callback , ...timerArgs ) => { const cb = (...callbackArgs ) => { try { callback(...callbackArgs) } finally { ids.delete(id) } } const id = timer(cb, ...timerArgs) ids.add(id) return id } const setTimeout = wrapTimer(timers.setTimeout, timeouts) const setImmediate = wrapTimer(timers.setImmediate, immediates) function clearTimer(clear , ids ) { ids.forEach(clear) ids.clear() } function removeAllTimers() { debug('remove all timers') clearTimer(clearTimeout, timeouts) clearTimer(clearImmediate, immediates) } function isPlainObject(value ) { if (typeof value !== 'object' || value === null) return false if (Object.prototype.toString.call(value) !== '[object Object]') return false const proto = Object.getPrototypeOf(value) if (proto === null) return true const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor return ( typeof Ctor === 'function' && Ctor instanceof Ctor && Function.prototype.call(Ctor) === Function.prototype.call(value) ) } const prototypePollutionBlockList = ['__proto__', 'prototype', 'constructor'] const blocklistFilter = function (part ) { return prototypePollutionBlockList.indexOf(part) === -1 } const expand = (input ) => { if (input === undefined || input === null) { return input } const keys = Object.keys(input) const result = {} let resultPtr = result for (let path of keys) { const originalPath = path if (path.indexOf('[') >= 0) { path = path.replace(/\[/g, '.').replace(/]/g, '') } const parts = path.split('.') const check = parts.filter(blocklistFilter) if (check.length !== parts.length) { return undefined } resultPtr = result const lastIndex = parts.length - 1 for (let i = 0; i < parts.length; ++i) { const part = parts[i] if (i === lastIndex) { if (Array.isArray(resultPtr)) { resultPtr[+part] = input[originalPath] } else { resultPtr[part] = input[originalPath] } } else { if (resultPtr[part] === undefined || resultPtr[part] === null) { const nextPart = parts[i + 1] if (/^\d+$/.test(nextPart)) { resultPtr[part] = [] } else { resultPtr[part] = {} } } else if (typeof resultPtr[part] !== 'object') { return undefined } resultPtr = resultPtr[part] } } } return result } function decompressRequestBody(buffer , contentEncoding ) { const encodings = contentEncoding .toLowerCase() .split(',') .map(coding => coding.trim()) for (const encoding of encodings) { if (encoding === 'gzip') { return zlib.gunzipSync(buffer) } else if (encoding === 'deflate') { return zlib.inflateSync(buffer) } else if (encoding === 'br') { return zlib.brotliDecompressSync(buffer) } } return buffer } function convertHeadersToRaw(headers ) { const rawHeaders = [] for (const [name, value] of headers.entries()) { rawHeaders.push(name, value) } return rawHeaders } export { contentEncoding, dataEqual, deleteHeadersField, expand, forEachHeader, formatQueryValue, headersArrayToObject, headersFieldNamesToLowerCase, headersFieldsArrayToLowerCase, headersInputToRawArray, isContentEncoded, isJSONContent, isPlainObject, isStream, isUtf8Representable, matchStringOrRegexp, normalizeOrigin, normalizeRequestOptions, percentDecode, percentEncode, removeAllTimers, setImmediate, setTimeout, stringifyRequest, decompressRequestBody, convertHeadersToRaw, }