UNPKG

nock

Version:

HTTP server mocking and expectations library for Node.js

477 lines (368 loc) • 12.8 kB
import fs from 'node:fs' import { scopeDebuglog } from './debug.js' import { addInterceptor, isOn } from './intercept.js' import * as common from './common.js' import assert from 'node:assert' import { EventEmitter } from 'node:events' import { Interceptor } from './interceptor.js' function normalizeUrl(u ) { if (typeof u === 'string') { // If the url is invalid, let the URL library report it return normalizeUrl(new URL(u)) } if (!/https?:/.test(u.protocol)) { throw new TypeError( `Protocol '${u.protocol}' not recognized. This commonly occurs when a hostname and port are included without a protocol, producing a URL that is valid but confusing, and probably not what you want.`, ) } return { href: u.href, origin: u.origin, protocol: u.protocol, username: u.username, password: u.password, host: u.host, hostname: // strip brackets from IPv6 typeof u.hostname === 'string' && u.hostname.startsWith('[') ? u.hostname.slice(1, -1) : u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), pathname: u.pathname, search: u.search, searchParams: u.searchParams, hash: u.hash, } } class Scope extends EventEmitter { /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ constructor(basePath , options ) { super() this.keyedInterceptors = {} this.interceptors = [] this.transformPathFunction = null this.transformRequestBodyFunction = null this.matchHeaders = [] this.scopeOptions = options || {} this.urlParts = {} this._persist = false this.contentLen = false this.date = null this.basePath = basePath this.basePathname = '' this.port = null this._defaultReplyHeaders = [] let logNamespace = String(basePath) if (!(basePath instanceof RegExp)) { this.urlParts = normalizeUrl(basePath ) this.port = this.urlParts.port this.basePathname = this.urlParts.pathname.replace(/\/$/, '') this.basePath = `${this.urlParts.protocol}//${this.urlParts.hostname}:${this.port}` logNamespace = this.urlParts.host } this.logger = scopeDebuglog(logNamespace) } add(key , interceptor ) { if (!(key in this.keyedInterceptors)) { this.keyedInterceptors[key] = [] } this.keyedInterceptors[key].push(interceptor) addInterceptor( this.basePath , interceptor, this, this.scopeOptions, this.urlParts.hostname , ) } remove(key , interceptor ) { if (this._persist) { return } const arr = this.keyedInterceptors[key] if (arr) { arr.splice(arr.indexOf(interceptor), 1) if (arr.length === 0) { delete this.keyedInterceptors[key] } } } intercept( uri , method , requestBody , interceptorOptions , ) { const ic = new Interceptor( this, uri, method, requestBody, interceptorOptions, ) this.interceptors.push(ic) return ic } get( uri , requestBody , options , ) { return this.intercept(uri, 'GET', requestBody, options) } post( uri , requestBody , options , ) { return this.intercept(uri, 'POST', requestBody, options) } put( uri , requestBody , options , ) { return this.intercept(uri, 'PUT', requestBody, options) } head( uri , requestBody , options , ) { return this.intercept(uri, 'HEAD', requestBody, options) } patch( uri , requestBody , options , ) { return this.intercept(uri, 'PATCH', requestBody, options) } merge( uri , requestBody , options , ) { return this.intercept(uri, 'MERGE', requestBody, options) } delete( uri , requestBody , options , ) { return this.intercept(uri, 'DELETE', requestBody, options) } options( uri , requestBody , options , ) { return this.intercept(uri, 'OPTIONS', requestBody, options) } // Returns the list of keys for non-optional Interceptors that haven't been completed yet. pendingMocks() { return this.activeMocks().filter((key ) => this.keyedInterceptors[key].some( ({ interceptionCounter, optional } ) => { const persistedAndUsed = this._persist && interceptionCounter > 0 return !persistedAndUsed && !optional }, ), ) } // Returns all keyedInterceptors that are active. activeMocks() { return Object.keys(this.keyedInterceptors) } isDone() { if (!isOn()) { return true } return this.pendingMocks().length === 0 } done() { assert.ok( this.isDone(), `Mocks not yet satisfied:\n${this.pendingMocks().join('\n')}`, ) } buildFilter() { const filteringArguments = Array.from(arguments) if (arguments[0] instanceof RegExp) { return function (candidate ) { /* istanbul ignore if */ if (typeof candidate !== 'string') { throw Error( `Nock internal assertion failed: typeof candidate is ${typeof candidate}. If you encounter this error, please report it as a bug.`, ) } return candidate.replace(filteringArguments[0], filteringArguments[1]) } } else if (typeof arguments[0] === 'function') { return arguments[0] } } filteringPath() { this.transformPathFunction = this.buildFilter.apply(this, arguments ) if (!this.transformPathFunction) { throw new Error( 'Invalid arguments: filtering path should be a function or a regular expression', ) } return this } filteringRequestBody() { this.transformRequestBodyFunction = this.buildFilter.apply( this, arguments , ) if (!this.transformRequestBodyFunction) { throw new Error( 'Invalid arguments: filtering request body should be a function or a regular expression', ) } return this } matchHeader(name , value ) { // We use lower-case header field names throughout Nock. this.matchHeaders.push({ name: name.toLowerCase(), value }) return this } defaultReplyHeaders(headers ) { this._defaultReplyHeaders = common.headersInputToRawArray(headers) return this } persist(flag = true) { if (typeof flag !== 'boolean') { throw new Error('Invalid arguments: argument should be a boolean') } this._persist = flag return this } shouldPersist() { return this._persist } replyContentLength() { this.contentLen = true return this } replyDate(d ) { this.date = d || new Date() return this } clone() { return new Scope(this.basePath, this.scopeOptions) } } function loadDefs(path ) { if (!fs) { throw new Error('No fs') } const contents = fs.readFileSync(path) return JSON.parse(contents) } function load(path ) { return define(loadDefs(path)) } function getStatusFromDefinition(nockDef ) { // Backward compatibility for when `status` was encoded as string in `reply`. if (nockDef.reply !== undefined) { const parsedReply = parseInt(nockDef.reply, 10) if (isNaN(parsedReply)) { throw Error('`reply`, when present, must be a numeric string') } return parsedReply } const DEFAULT_STATUS_OK = 200 return nockDef.status || DEFAULT_STATUS_OK } function getScopeFromDefinition(nockDef ) { // Backward compatibility for when `port` was part of definition. if (nockDef.port !== undefined) { // Include `port` into scope if it doesn't exist. const url = URL.parse(nockDef.scope) if (url.port === '') { return `${nockDef.scope}:${nockDef.port}` } else { if (parseInt(url.port) !== parseInt(nockDef.port)) { throw new Error( 'Mismatched port numbers in scope and port properties of nock definition.', ) } } } return nockDef.scope } function tryJsonParse(string ) { try { return JSON.parse(string) } catch { return string } } function define(nockDefs ) { const scopes = [] nockDefs.forEach(function (nockDef ) { const nscope = getScopeFromDefinition(nockDef) const npath = nockDef.path if (!nockDef.method) { throw Error('Method is required') } const method = nockDef.method.toLowerCase() const status = getStatusFromDefinition(nockDef) const rawHeaders = nockDef.rawHeaders || [] const reqheaders = nockDef.reqheaders || {} const badheaders = nockDef.badheaders || [] const options = { ...nockDef.options } // We use request headers for both filtering (see below) and mocking. // Here we are setting up mocked request headers but we don't want to // be changing the user's options object so we clone it first. options.reqheaders = reqheaders options.badheaders = badheaders // Response is not always JSON as it could be a string or binary data or // even an array of binary buffers (e.g. when content is encoded). let response if (!nockDef.response) { response = '' // TODO: Rename `responseIsBinary` to `responseIsUtf8Representable`. } else if (nockDef.responseIsBinary) { response = Buffer.from(nockDef.response, 'hex') } else { response = typeof nockDef.response === 'string' ? tryJsonParse(nockDef.response) : nockDef.response } const scope = new Scope(nscope, options) // If request headers were specified filter by them. Object.entries(reqheaders).forEach(([fieldName, value]) => { scope.matchHeader(fieldName, value ) }) const acceptableFilters = ['filteringRequestBody', 'filteringPath'] acceptableFilters.forEach(filter => { if (nockDef[filter]) { ;(scope )[filter](nockDef[filter]) } }) scope .intercept(npath, method, nockDef.body) .reply(status, response, rawHeaders) scopes.push(scope) }) return scopes } export { Scope, load, loadDefs, define }