diff --git a/.eslintrc.js b/.eslintrc.js index e033996..77cbdd6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { 'sourceType': 'module' }, 'rules': { + 'no-unused-vars': 'off', 'indent': [ 'error', 2 diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..831bb90 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + trailingComma: 'es5', + tabWidth: 2, + singleQuote: true, +}; diff --git a/index.d.ts b/index.d.ts index 348fd4f..153a2fe 100644 --- a/index.d.ts +++ b/index.d.ts @@ -43,8 +43,12 @@ interface ExtraRequestData { params: Params; queryParams: QueryParams; } -export type ResponseHandler = ( - request: FakeXMLHttpRequest & ExtraRequestData -) => ResponseData | PromiseLike; +export type ResponseHandler = { + (request: FakeXMLHttpRequest & ExtraRequestData): + | ResponseData + | PromiseLike; + async: boolean; + numberOfCalls: number; +}; export default Server; diff --git a/src/index.ts b/src/index.ts index 42ef1b1..b5abde4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,259 +1,9 @@ -import * as FakeFetch from 'whatwg-fetch'; import parseURL from './parse-url'; import Registry from './registry'; import Hosts from './hosts'; -import { interceptor } from './interceptor'; - -function Pretender(/* routeMap1, routeMap2, ..., options*/) { - this.hosts = new Hosts(); - - let lastArg = arguments[arguments.length - 1]; - let options = typeof lastArg === 'object' ? lastArg : null; - let shouldNotTrack = options && (options.trackRequests === false); - let noopArray = { push: function() {}, length: 0 }; - - this.handlers = []; - this.handledRequests = shouldNotTrack ? noopArray: []; - this.passthroughRequests = shouldNotTrack ? noopArray: []; - this.unhandledRequests = shouldNotTrack ? noopArray: []; - this.requestReferences = []; - this.forcePassthrough = options && (options.forcePassthrough === true); - this.disableUnhandled = options && (options.disableUnhandled === true); - - // reference the native XMLHttpRequest object so - // it can be restored later - this._nativeXMLHttpRequest = self.XMLHttpRequest; - this.running = false; - let ctx = { pretender: this }; - this.ctx = ctx; - - // capture xhr requests, channeling them into - // the route map. - self.XMLHttpRequest = interceptor(ctx); - - // polyfill fetch when xhr is ready - this._fetchProps = FakeFetch ? ['fetch', 'Headers', 'Request', 'Response'] : []; - this._fetchProps.forEach(function(name) { - this['_native' + name] = self[name]; - self[name] = FakeFetch[name]; - }, this); - - // 'start' the server - this.running = true; - - // trigger the route map DSL. - let argLength = options ? arguments.length - 1 : arguments.length; - for (let i = 0; i < argLength; i++) { - this.map(arguments[i]); - } -} - -function verbify(verb) { - return function(path, handler, async) { - return this.register(verb, path, handler, async); - }; -} - -function scheduleProgressEvent(request, startTime, totalTime) { - setTimeout(function() { - if (!request.aborted && !request.status) { - let elapsedTime = new Date().getTime() - startTime.getTime(); - let progressTotal; - let body = request.requestBody; - if (!body) { - progressTotal = 0; - } else { - // Support Blob, BufferSource, USVString, ArrayBufferView - progressTotal = body.byteLength || body.size || body.length || 0; - } - let progressTransmitted = - totalTime <= 0 ? 0 : (elapsedTime / totalTime) * progressTotal; - // ProgressEvent expects loaded, total - // https://xhr.spec.whatwg.org/#interface-progressevent - request.upload._progress(true, progressTransmitted, progressTotal); - request._progress(true, progressTransmitted, progressTotal); - scheduleProgressEvent(request, startTime, totalTime); - } - }, 50); -} - -function isArray(array) { - return Object.prototype.toString.call(array) === '[object Array]'; -} - -let PASSTHROUGH = {}; - -Pretender.prototype = { - get: verbify('GET'), - post: verbify('POST'), - put: verbify('PUT'), - 'delete': verbify('DELETE'), - patch: verbify('PATCH'), - head: verbify('HEAD'), - options: verbify('OPTIONS'), - map: function(maps) { - maps.call(this); - }, - register: function register(verb, url, handler, async) { - if (!handler) { - throw new Error('The function you tried passing to Pretender to handle ' + - verb + ' ' + url + ' is undefined or missing.'); - } - - handler.numberOfCalls = 0; - handler.async = async; - this.handlers.push(handler); - - let registry = this.hosts.forURL(url)[verb]; - - registry.add([{ - path: parseURL(url).fullpath, - handler: handler - }]); - - return handler; - }, - passthrough: PASSTHROUGH, - checkPassthrough: function checkPassthrough(request) { - let verb = request.method.toUpperCase(); - let path = parseURL(request.url).fullpath; - let recognized = this.hosts.forURL(request.url)[verb].recognize(path); - let match = recognized && recognized[0]; - - if ((match && match.handler === PASSTHROUGH) || this.forcePassthrough) { - this.passthroughRequests.push(request); - this.passthroughRequest(verb, path, request); - return true; - } - - return false; - }, - handleRequest: function handleRequest(request) { - let verb = request.method.toUpperCase(); - let path = request.url; - - let handler = this._handlerFor(verb, path, request); - - if (handler) { - handler.handler.numberOfCalls++; - let async = handler.handler.async; - this.handledRequests.push(request); - - let pretender = this; - - let _handleRequest = function(statusHeadersAndBody) { - if (!isArray(statusHeadersAndBody)) { - let note = 'Remember to `return [status, headers, body];` in your route handler.'; - throw new Error('Nothing returned by handler for ' + path + '. ' + note); - } - - let status = statusHeadersAndBody[0]; - let headers = pretender.prepareHeaders(statusHeadersAndBody[1]); - let body = pretender.prepareBody(statusHeadersAndBody[2], headers); - - pretender.handleResponse(request, async, function() { - request.respond(status, headers, body); - pretender.handledRequest(verb, path, request); - }); - }; - - try { - let result = handler.handler(request); - if (result && typeof result.then === 'function') { - // `result` is a promise, resolve it - result.then(function(resolvedResult) { - _handleRequest(resolvedResult); - }); - } else { - _handleRequest(result); - } - } catch (error) { - this.erroredRequest(verb, path, request, error); - this.resolve(request); - } - } else { - if (!this.disableUnhandled) { - this.unhandledRequests.push(request); - this.unhandledRequest(verb, path, request); - } - } - }, - handleResponse: function handleResponse(request, strategy, callback) { - let delay = typeof strategy === 'function' ? strategy() : strategy; - delay = typeof delay === 'boolean' || typeof delay === 'number' ? delay : 0; - - if (delay === false) { - callback(); - } else { - let pretender = this; - pretender.requestReferences.push({ - request: request, - callback: callback - }); - - if (delay !== true) { - scheduleProgressEvent(request, new Date(), delay); - setTimeout(function() { - pretender.resolve(request); - }, delay); - } - } - }, - resolve: function resolve(request) { - for (let i = 0, len = this.requestReferences.length; i < len; i++) { - let res = this.requestReferences[i]; - if (res.request === request) { - res.callback(); - this.requestReferences.splice(i, 1); - break; - } - } - }, - requiresManualResolution: function(verb, path) { - let handler = this._handlerFor(verb.toUpperCase(), path, {}); - if (!handler) { return false; } - - let async = handler.handler.async; - return typeof async === 'function' ? async() === true : async === true; - }, - prepareBody: function(body) { return body; }, - prepareHeaders: function(headers) { return headers; }, - handledRequest: function(/* verb, path, request */) { /* no-op */}, - passthroughRequest: function(/* verb, path, request */) { /* no-op */}, - unhandledRequest: function(verb, path/*, request */) { - throw new Error('Pretender intercepted ' + verb + ' ' + - path + ' but no handler was defined for this type of request'); - }, - erroredRequest: function(verb, path, request, error) { - error.message = 'Pretender intercepted ' + verb + ' ' + - path + ' but encountered an error: ' + error.message; - throw error; - }, - _handlerFor: function(verb, url, request) { - let registry = this.hosts.forURL(url)[verb]; - let matches = registry.recognize(parseURL(url).fullpath); - - let match = matches ? matches[0] : null; - if (match) { - request.params = match.params; - request.queryParams = matches.queryParams; - } - - return match; - }, - shutdown: function shutdown() { - self.XMLHttpRequest = this._nativeXMLHttpRequest; - this._fetchProps.forEach(function(name) { - self[name] = this['_native' + name]; - }, this); - this.ctx.pretender = undefined; - // 'stop' the server - this.running = false; - } -}; +import Pretender from './pretender'; Pretender.parseURL = parseURL; Pretender.Hosts = Hosts; Pretender.Registry = Registry; - export default Pretender; diff --git a/src/pretender.ts b/src/pretender.ts new file mode 100644 index 0000000..0795e33 --- /dev/null +++ b/src/pretender.ts @@ -0,0 +1,339 @@ +import * as FakeFetch from 'whatwg-fetch'; +import FakeXMLHttpRequest from 'fake-xml-http-request'; +import { Params, QueryParams } from 'route-recognizer'; +import { ResponseHandler } from '../index.d'; +import Hosts from './hosts'; +import parseURL from './parse-url'; +import Registry from './registry'; +import { interceptor } from './interceptor'; + +interface ExtraRequestData { + url: string; + method: string; + params: Params; + queryParams: QueryParams; +} +type FakeRequest = FakeXMLHttpRequest & ExtraRequestData; + +type Verb = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'; + +class NoopArray { + length = 0; + push(..._items: any[]) { + return 0; + } +} + +function scheduleProgressEvent(request, startTime, totalTime) { + setTimeout(function () { + if (!request.aborted && !request.status) { + let elapsedTime = new Date().getTime() - startTime.getTime(); + let progressTotal; + let body = request.requestBody; + if (!body) { + progressTotal = 0; + } else { + // Support Blob, BufferSource, USVString, ArrayBufferView + progressTotal = body.byteLength || body.size || body.length || 0; + } + let progressTransmitted = + totalTime <= 0 ? 0 : (elapsedTime / totalTime) * progressTotal; + // ProgressEvent expects loaded, total + // https://xhr.spec.whatwg.org/#interface-progressevent + request.upload._progress(true, progressTransmitted, progressTotal); + request._progress(true, progressTransmitted, progressTotal); + scheduleProgressEvent(request, startTime, totalTime); + } + }, 50); +} + +function isArray(array) { + return Object.prototype.toString.call(array) === '[object Array]'; +} + +const PASSTHROUGH = {}; + +function verbify(verb: Verb) { + return function ( + this: Pretender, + path: string, + handler: ResponseHandler, + async: boolean + ) { + return this.register(verb, path, handler, async); + }; +} + +export default class Pretender { + static parseURL = parseURL; + static Hosts = Hosts; + static Registry = Registry; + + hosts = new Hosts(); + handlers: ResponseHandler[] = []; + handledRequests: any[] | NoopArray; + passthroughRequests: any[] | NoopArray; + unhandledRequests: any[] | NoopArray; + requestReferences: any[]; + forcePassthrough: boolean; + disableUnhandled: boolean; + + ctx: { pretender?: Pretender }; + running: boolean; + + private _nativeXMLHttpRequest: any; + private _fetchProps: string[]; + + constructor() { + let lastArg = arguments[arguments.length - 1]; + let options = typeof lastArg === 'object' ? lastArg : null; + let shouldNotTrack = options && options.trackRequests === false; + + this.handledRequests = shouldNotTrack ? new NoopArray() : []; + this.passthroughRequests = shouldNotTrack ? new NoopArray() : []; + this.unhandledRequests = shouldNotTrack ? new NoopArray() : []; + this.requestReferences = []; + this.forcePassthrough = options && options.forcePassthrough === true; + this.disableUnhandled = options && options.disableUnhandled === true; + + // reference the native XMLHttpRequest object so + // it can be restored later + this._nativeXMLHttpRequest = (self).XMLHttpRequest; + this.running = false; + let ctx = { pretender: this }; + this.ctx = ctx; + + // capture xhr requests, channeling them into + // the route map. + (self).XMLHttpRequest = interceptor(ctx); + + // polyfill fetch when xhr is ready + this._fetchProps = FakeFetch + ? ['fetch', 'Headers', 'Request', 'Response'] + : []; + this._fetchProps.forEach((name) => { + (this)['_native' + name] = self[name]; + self[name] = FakeFetch[name]; + }, this); + + // 'start' the server + this.running = true; + + // trigger the route map DSL. + let argLength = options ? arguments.length - 1 : arguments.length; + for (let i = 0; i < argLength; i++) { + this.map(arguments[i]); + } + } + + get = verbify('GET'); + post = verbify('POST'); + put = verbify('PUT'); + delete = verbify('DELETE'); + patch = verbify('PATCH'); + head = verbify('HEAD'); + options = verbify('OPTIONS'); + + map(maps: (pretender: Pretender) => void) { + maps.call(this); + } + + register( + verb: string, + url: string, + handler: ResponseHandler, + async: boolean + ): ResponseHandler { + if (!handler) { + throw new Error( + 'The function you tried passing to Pretender to handle ' + + verb + + ' ' + + url + + ' is undefined or missing.' + ); + } + + handler.numberOfCalls = 0; + handler.async = async; + this.handlers.push(handler); + + let registry = this.hosts.forURL(url)[verb]; + + registry.add([ + { + path: parseURL(url).fullpath, + handler: handler, + }, + ]); + + return handler; + } + + passthrough = PASSTHROUGH; + + checkPassthrough(request: FakeRequest) { + let verb = request.method.toUpperCase() as Verb; + let path = parseURL(request.url).fullpath; + let recognized = this.hosts.forURL(request.url)[verb].recognize(path); + let match = recognized && recognized[0]; + + if ((match && match.handler === PASSTHROUGH) || this.forcePassthrough) { + this.passthroughRequests.push(request); + this.passthroughRequest(verb, path, request); + return true; + } + + return false; + } + + handleRequest(request: FakeRequest) { + let verb = request.method.toUpperCase(); + let path = request.url; + + let handler = this._handlerFor(verb, path, request); + + if (handler) { + handler.handler.numberOfCalls++; + let async = handler.handler.async; + this.handledRequests.push(request); + + let pretender = this; + + let _handleRequest = function (statusHeadersAndBody) { + if (!isArray(statusHeadersAndBody)) { + let note = + 'Remember to `return [status, headers, body];` in your route handler.'; + throw new Error( + 'Nothing returned by handler for ' + path + '. ' + note + ); + } + + let status = statusHeadersAndBody[0]; + let headers = pretender.prepareHeaders(statusHeadersAndBody[1]); + let body = pretender.prepareBody(statusHeadersAndBody[2], headers); + + pretender.handleResponse(request, async, function () { + request.respond(status, headers, body); + pretender.handledRequest(verb, path, request); + }); + }; + + try { + let result = handler.handler(request); + if (result && typeof result.then === 'function') { + // `result` is a promise, resolve it + result.then(function (resolvedResult) { + _handleRequest(resolvedResult); + }); + } else { + _handleRequest(result); + } + } catch (error) { + this.erroredRequest(verb, path, request, error); + this.resolve(request); + } + } else { + if (!this.disableUnhandled) { + this.unhandledRequests.push(request); + this.unhandledRequest(verb, path, request); + } + } + } + + handleResponse(request: FakeRequest, strategy, callback: Function) { + let delay = typeof strategy === 'function' ? strategy() : strategy; + delay = typeof delay === 'boolean' || typeof delay === 'number' ? delay : 0; + + if (delay === false) { + callback(); + } else { + let pretender = this; + pretender.requestReferences.push({ + request: request, + callback: callback, + }); + + if (delay !== true) { + scheduleProgressEvent(request, new Date(), delay); + setTimeout(function () { + pretender.resolve(request); + }, delay); + } + } + } + + resolve(request: FakeRequest) { + for (let i = 0, len = this.requestReferences.length; i < len; i++) { + let res = this.requestReferences[i]; + if (res.request === request) { + res.callback(); + this.requestReferences.splice(i, 1); + break; + } + } + } + + requiresManualResolution(verb: string, path: string) { + let handler = this._handlerFor(verb.toUpperCase(), path, {}); + if (!handler) { + return false; + } + + let async = handler.handler.async; + return typeof async === 'function' ? async() === true : async === true; + } + prepareBody(body, _headers) { + return body; + } + prepareHeaders(headers) { + return headers; + } + handledRequest(_verb, _path, _request) { + /* no-op */ + } + passthroughRequest(_verb, _path, _request) { + /* no-op */ + } + unhandledRequest(verb, path, _request) { + throw new Error( + 'Pretender intercepted ' + + verb + + ' ' + + path + + ' but no handler was defined for this type of request' + ); + } + erroredRequest(verb, path, _request, error) { + error.message = + 'Pretender intercepted ' + + verb + + ' ' + + path + + ' but encountered an error: ' + + error.message; + throw error; + } + shutdown() { + (self).XMLHttpRequest = this._nativeXMLHttpRequest; + this._fetchProps.forEach((name) => { + self[name] = this['_native' + name]; + }, this); + this.ctx.pretender = undefined; + // 'stop' the server + this.running = false; + } + + private _handlerFor(verb: Verb, url: string, request: FakeRequest) { + let registry = this.hosts.forURL(url)[verb]; + let matches = registry.recognize(parseURL(url).fullpath); + + let match = matches ? matches[0] : null; + if (match) { + request.params = match.params; + request.queryParams = matches.queryParams; + } + + return match; + } +}