UNPKG

7.28 kBJavaScriptView Raw
1const { debug, setDebugPhase, getDebug } = require('./debug');
2const responseBuilder = require('./response-builder');
3const requestUtils = require('./request-utils');
4const FetchMock = {};
5
6// see https://heycam.github.io/webidl/#aborterror for the standardised interface
7// Note that this differs slightly from node-fetch
8class AbortError extends Error {
9 constructor() {
10 super(...arguments);
11 this.name = 'AbortError';
12 this.message = 'The operation was aborted.';
13
14 // Do not include this class in the stacktrace
15 if (Error.captureStackTrace) {
16 Error.captureStackTrace(this, this.constructor);
17 }
18 }
19}
20
21const resolve = async (
22 { response, responseIsFetch = false },
23 url,
24 options,
25 request
26) => {
27 const debug = getDebug('resolve()');
28 debug('Recursively resolving function and promise responses');
29 // We want to allow things like
30 // - function returning a Promise for a response
31 // - delaying (using a timeout Promise) a function's execution to generate
32 // a response
33 // Because of this we can't safely check for function before Promisey-ness,
34 // or vice versa. So to keep it DRY, and flexible, we keep trying until we
35 // have something that looks like neither Promise nor function
36 while (true) {
37 if (typeof response === 'function') {
38 debug(' Response is a function');
39 // in the case of falling back to the network we need to make sure we're using
40 // the original Request instance, not our normalised url + options
41 if (responseIsFetch) {
42 if (request) {
43 debug(' -> Calling fetch with Request instance');
44 return response(request);
45 }
46 debug(' -> Calling fetch with url and options');
47 return response(url, options);
48 } else {
49 debug(' -> Calling response function');
50 response = response(url, options, request);
51 }
52 } else if (typeof response.then === 'function') {
53 debug(' Response is a promise');
54 debug(' -> Resolving promise');
55 response = await response;
56 } else {
57 debug(' Response is not a function or a promise');
58 debug(' -> Exiting response resolution recursion');
59 return response;
60 }
61 }
62};
63
64FetchMock.fetchHandler = function (url, options, request) {
65 setDebugPhase('handle');
66 const debug = getDebug('fetchHandler()');
67 debug('fetch called with:', url, options);
68 const normalizedRequest = requestUtils.normalizeRequest(
69 url,
70 options,
71 this.config.Request
72 );
73
74 ({ url, options, request } = normalizedRequest);
75
76 const { signal } = normalizedRequest;
77
78 debug('Request normalised');
79 debug(' url', url);
80 debug(' options', options);
81 debug(' request', request);
82 debug(' signal', signal);
83
84 if (request && this.routes.some(({ usesBody }) => usesBody)) {
85 debug(
86 'Need to wait for Body to be streamed before calling router: switching to async mode'
87 );
88 return this._asyncFetchHandler(url, options, request, signal);
89 }
90 return this._fetchHandler(url, options, request, signal);
91};
92
93FetchMock._asyncFetchHandler = async function (url, options, request, signal) {
94 options.body = await options.body;
95 return this._fetchHandler(url, options, request, signal);
96};
97
98FetchMock._fetchHandler = function (url, options, request, signal) {
99 const route = this.executeRouter(url, options, request);
100
101 // this is used to power the .flush() method
102 let done;
103 this._holdingPromises.push(new this.config.Promise((res) => (done = res)));
104
105 // wrapped in this promise to make sure we respect custom Promise
106 // constructors defined by the user
107 return new this.config.Promise((res, rej) => {
108 if (signal) {
109 debug('signal exists - enabling fetch abort');
110 const abort = () => {
111 debug('aborting fetch');
112 // note that DOMException is not available in node.js; even node-fetch uses a custom error class: https://github.com/bitinn/node-fetch/blob/master/src/abort-error.js
113 rej(
114 typeof DOMException !== 'undefined'
115 ? new DOMException('The operation was aborted.', 'AbortError')
116 : new AbortError()
117 );
118 done();
119 };
120 if (signal.aborted) {
121 debug('signal is already aborted - aborting the fetch');
122 abort();
123 }
124 signal.addEventListener('abort', abort);
125 }
126
127 this.generateResponse(route, url, options, request)
128 .then(res, rej)
129 .then(done, done)
130 .then(() => {
131 setDebugPhase();
132 });
133 });
134};
135
136FetchMock.fetchHandler.isMock = true;
137
138FetchMock.executeRouter = function (url, options, request) {
139 const debug = getDebug('executeRouter()');
140 debug(`Attempting to match request to a route`);
141 if (this.getOption('fallbackToNetwork') === 'always') {
142 debug(
143 ' Configured with fallbackToNetwork=always - passing through to fetch'
144 );
145 return { response: this.getNativeFetch(), responseIsFetch: true };
146 }
147
148 const match = this.router(url, options, request);
149
150 if (match) {
151 debug(' Matching route found');
152 return match;
153 }
154
155 if (this.getOption('warnOnFallback')) {
156 console.warn(`Unmatched ${(options && options.method) || 'GET'} to ${url}`); // eslint-disable-line
157 }
158
159 this.push({ url, options, request, isUnmatched: true });
160
161 if (this.fallbackResponse) {
162 debug(' No matching route found - using fallbackResponse');
163 return { response: this.fallbackResponse };
164 }
165
166 if (!this.getOption('fallbackToNetwork')) {
167 throw new Error(
168 `fetch-mock: No fallback response defined for ${
169 (options && options.method) || 'GET'
170 } to ${url}`
171 );
172 }
173
174 debug(' Configured to fallbackToNetwork - passing through to fetch');
175 return { response: this.getNativeFetch(), responseIsFetch: true };
176};
177
178FetchMock.generateResponse = async function (route, url, options, request) {
179 const debug = getDebug('generateResponse()');
180 const response = await resolve(route, url, options, request);
181
182 // If the response says to throw an error, throw it
183 // Type checking is to deal with sinon spies having a throws property :-0
184 if (response.throws && typeof response !== 'function') {
185 debug('response.throws is defined - throwing an error');
186 throw response.throws;
187 }
188
189 // If the response is a pre-made Response, respond with it
190 if (this.config.Response.prototype.isPrototypeOf(response)) {
191 debug('response is already a Response instance - returning it');
192 return response;
193 }
194
195 // finally, if we need to convert config into a response, we do it
196 return responseBuilder({
197 url,
198 responseConfig: response,
199 fetchMock: this,
200 route,
201 });
202};
203
204FetchMock.router = function (url, options, request) {
205 const route = this.routes.find((route, i) => {
206 debug(`Trying to match route ${i}`);
207 return route.matcher(url, options, request);
208 });
209
210 if (route) {
211 this.push({
212 url,
213 options,
214 request,
215 identifier: route.identifier,
216 });
217 return route;
218 }
219};
220
221FetchMock.getNativeFetch = function () {
222 const func = this.realFetch || (this.isSandbox && this.config.fetch);
223 if (!func) {
224 throw new Error(
225 'fetch-mock: Falling back to network only available on global fetch-mock, or by setting config.fetch on sandboxed fetch-mock'
226 );
227 }
228 return func;
229};
230
231FetchMock.push = function ({ url, options, request, isUnmatched, identifier }) {
232 debug('Recording fetch call', {
233 url,
234 options,
235 request,
236 isUnmatched,
237 identifier,
238 });
239 const args = [url, options];
240 args.request = request;
241 args.identifier = identifier;
242 args.isUnmatched = isUnmatched;
243 this._calls.push(args);
244};
245
246module.exports = FetchMock;