@selkirk-systems/fetch 0.1.6 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,515 @@
1
+ //Inspired by https://www.bennadel.com/blog/4180-canceling-api-requests-using-fetch-and-abortcontroller-in-javascript.htm
2
+
3
+ import Download from './Download';
4
+ import FetchErrorHandler from './middleware/FetchErrorHandler';
5
+
6
+ // Regular expression patterns for testing content-type response headers.
7
+ const RE_CONTENT_TYPE_JSON = /^application\/(x-)?json/i;
8
+ const RE_CONTENT_TYPE_TEXT = /"^text\/"/i;
9
+ const RE_QUERY_STRING = /\/.+\?/;
10
+
11
+ const UNEXPECTED_ERROR_MESSAGE = "An unexpected error occurred while processing your request.";
12
+
13
+ const CONTENT_TYPE_DOWNLOADS = {
14
+ 'application/pdf': true,
15
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': true
16
+ }
17
+ //We store the original promise.catch so we can override it in some
18
+ //scenarios when we want to swallow errors vs bubble them up.
19
+ const ORIGINAL_CATCH_FN = Promise.prototype.catch;
20
+
21
+ //Auto applied middleware that dispatches all errors so any UI's can respond.
22
+ let _middlewares = [FetchErrorHandler];
23
+
24
+
25
+ //Cache of request AbortController signals for auto request aborting.
26
+ let _requests = {};
27
+
28
+ /**
29
+ * PUBLIC: Apply custom middleware to act on any fetch responses and errors.
30
+ *
31
+ * NOTE: Middleware can handle errors and swallow them by passing back a new error object.
32
+ *
33
+ * @param {array} middleware
34
+ */
35
+ export function applyMiddleware( middleware = [] ) {
36
+ _middlewares = middleware;
37
+
38
+ _middlewares.push( FetchErrorHandler );
39
+ }
40
+
41
+ /**
42
+ * Make the fetch request with the given configuration options.
43
+ *
44
+ * GUARANTEE: All errors produced by this method will have consistent structure, even
45
+ * if they are low-level networking errors. At a minimum, every Promise rejection will
46
+ * have the following properties:
47
+ *
48
+ * TODO: Add support for multi-form uploads (images, files etc)
49
+ *
50
+ * - data.type
51
+ * - data.message
52
+ * - status.code
53
+ * - status.text
54
+ * - status.isAbort
55
+ */
56
+ export default function Fetch( url, options = {} ) {
57
+
58
+ const config = {
59
+ downloadFileName: null,
60
+ contentType: "application/json",
61
+ headers: {
62
+ accept: "*/*"
63
+ },
64
+ credentials: "same-origin",
65
+ url: url || "",
66
+ urlTemplateData: {},
67
+ method: "GET",
68
+ params: {},
69
+ form: null,
70
+ json: null,
71
+ body: null,
72
+ signal: new AbortController(),
73
+ ...options,
74
+ _hasCatch: false,
75
+ _promiseChain: null,
76
+ _userSignal: Boolean( options.signal )
77
+ }
78
+
79
+ let finalHeaders, finalMethod, finalUrl, finalBody, finalSignal, request;
80
+
81
+
82
+ try {
83
+
84
+ finalHeaders = buildHeaders( config.headers );
85
+ finalMethod = config.method;
86
+ finalUrl = buildURL( config.url, config.urlTemplateData, config.params );
87
+ finalBody = config.body;
88
+ finalSignal = config.signal;
89
+
90
+ // Bail out early if url contains url params,
91
+ //these should be set via the options.params object.
92
+ if ( !config.url.href && RE_QUERY_STRING.test( config.url ) ) {
93
+ return ( Promise.reject( normalizeError( {
94
+ type: "INVALID_URL",
95
+ message: `${config.url} contains query parameters: please use options.params object for this purpose.`
96
+ }, {}, {}, config ) ) );
97
+ }
98
+
99
+
100
+ if ( CONTENT_TYPE_DOWNLOADS[config.contentType] && finalMethod === "GET" ) {
101
+ finalHeaders.credentials = "same-origin";
102
+
103
+ finalHeaders.accept = "*/*";
104
+ }
105
+
106
+ if ( config.form ) {
107
+
108
+ // For form data posts, we want the browser to build the Content-
109
+ // Type for us so that it puts in both the "multipart/form-data" plus the
110
+ // correct, auto-generated field delimiter.
111
+ delete ( finalHeaders["content-type"] );
112
+
113
+ finalMethod = "POST";
114
+ finalBody = buildFormData( config.form );
115
+
116
+ } else if ( config.json ) {
117
+
118
+ finalHeaders["content-type"] = ( config.contentType || "application/x-json" );
119
+ finalBody = JSON.stringify( config.json );
120
+
121
+ } else if ( config.body ) {
122
+
123
+ finalHeaders["content-type"] = ( config.contentType || "application/octet-stream" );
124
+
125
+ }
126
+ else {
127
+ finalHeaders["content-type"] = config.contentType;
128
+ }
129
+
130
+ request = new window.Request(
131
+ finalUrl,
132
+ {
133
+ headers: finalHeaders,
134
+ method: finalMethod,
135
+ body: finalBody,
136
+ signal: finalSignal.signal
137
+ }
138
+ );
139
+
140
+ //Check if a pending request is in-flight, if so and it is the exact same url abort it.
141
+ if ( _requests[finalUrl] ) {
142
+ _requests[finalUrl].abort();
143
+ }
144
+
145
+ //Cache requests abort signal by url
146
+ cacheRequestSignal( finalUrl, finalSignal );
147
+
148
+ config._promiseChain = Promise.resolve( window.fetch( request ) )
149
+ .then( async ( response ) => {
150
+
151
+ deleteCachedRequestSignal( finalUrl );
152
+
153
+ const data = await unwrapResponseData( response );
154
+
155
+ if ( response.ok ) {
156
+
157
+ //Run response through middleware
158
+ const nextResp = _applyMiddleware( null, response, config );
159
+
160
+ if ( config.downloadFileName ) {
161
+ presetBrowserDownloadDialog( data, config );
162
+ }
163
+
164
+
165
+ return [
166
+ {
167
+ request: request,
168
+ response: nextResp || response,
169
+ data: data
170
+ },
171
+ false
172
+ ]
173
+
174
+ }
175
+
176
+ return handleError(
177
+ normalizeError( data, request, response, config ),
178
+ config
179
+ );
180
+
181
+ } ).catch( ( err ) => {
182
+
183
+ deleteCachedRequestSignal( finalUrl );
184
+
185
+ const error = isNormalizedError( err ) ?
186
+ err :
187
+ normalizeTransportError( err );
188
+
189
+ return handleError(
190
+ error,
191
+ config
192
+ );
193
+ } )
194
+
195
+ }
196
+ catch ( err ) {
197
+
198
+ deleteCachedRequestSignal( finalUrl );
199
+
200
+ return handleError(
201
+ normalizeTransportError( err ),
202
+ config
203
+ );
204
+ }
205
+
206
+
207
+ if ( config._promiseChain ) {
208
+
209
+ //If catch is added outside, then assume they want to handle errors and
210
+ //not have them swallowed
211
+
212
+ config._promiseChain.catch = function ( ...args ) {
213
+ config._hasCatch = true;
214
+ return ORIGINAL_CATCH_FN.apply( this, args );
215
+ };
216
+ }
217
+
218
+ return config._promiseChain;
219
+ }
220
+
221
+ /**
222
+ * Shows the browser download dialog
223
+ * @param {response.blob} blob
224
+ * @param {object} config
225
+ * @returns
226
+ */
227
+ function presetBrowserDownloadDialog( blob, config ) {
228
+ return Download( blob, config.downloadFileName, config.contentType );
229
+ }
230
+
231
+ /**
232
+ * Stores a ref to the abort signal
233
+ * @param {string} url
234
+ * @param {AbortController.signal} signal
235
+ */
236
+ function cacheRequestSignal( url, signal ) {
237
+ _requests[url] = signal;
238
+ }
239
+
240
+
241
+ /**
242
+ * Deletes a req from the cached requests
243
+ * @param {string} url
244
+ */
245
+ function deleteCachedRequestSignal( url ) {
246
+ delete _requests[url];
247
+ }
248
+
249
+
250
+ /**
251
+ * Checks if the error structure mimics ours and has already been normalized.
252
+ * @param {Object} err
253
+ * @returns
254
+ */
255
+ function isNormalizedError( err ) {
256
+ return err.hasOwnProperty( "status" ) && err.hasOwnProperty( "data" )
257
+ }
258
+
259
+
260
+ /**
261
+ * Handles errors by passing them through any middleware and finally throwing them or swallowing them.
262
+ * @param {object} normalizedError
263
+ * @param {object} config
264
+ * @returns
265
+ */
266
+ function handleError( normalizedError, config ) {
267
+ const hasCatch = config._hasCatch;
268
+ const promiseChain = config._promiseChain;
269
+
270
+ const nextErr = _applyMiddleware( normalizedError, null, config );
271
+
272
+ //Only swallow errors if they have been handled by middleware AND they have not
273
+ //Added a catch outside
274
+ if ( !hasCatch && !nextErr ) {
275
+ if ( promiseChain && promiseChain.cancel ) promiseChain.cancel();
276
+ //In the case of aborted requests etc, we treat them as success and let the initial fetch chain
277
+ //handle them.
278
+ return Promise.resolve( [normalizedError, true] );
279
+ }
280
+
281
+ // The request failed in a critical way; the content of this error will be
282
+ // entirely unpredictable.
283
+ return ( Promise.reject( nextErr || normalizedError ) );
284
+ }
285
+
286
+
287
+ /**
288
+ * Passes any errors, responses through configured middleware
289
+ *
290
+ * @param {ErrorEvent} err the error.
291
+ * @param {Object} response the network response.
292
+ * @param {Object} options the request options.
293
+ * @returns null
294
+ */
295
+ function _applyMiddleware( err, response, options ) {
296
+ let i = -1;
297
+
298
+ const next = function ( nextErr, nextState ) {
299
+ i++;
300
+ const middleware = _middlewares[i];
301
+
302
+ if ( !middleware ) return nextErr || nextState;
303
+
304
+ return middleware( nextErr )( nextState )( options )( next );
305
+ };
306
+
307
+ return next( err, response );
308
+ }
309
+
310
+
311
+ /**
312
+ * Unwrap the response payload from the given response based on the reported
313
+ * content-type.
314
+ */
315
+ async function unwrapResponseData( response ) {
316
+
317
+ var contentType = response.headers.has( "content-type" )
318
+ ? response.headers.get( "content-type" )
319
+ : ""
320
+ ;
321
+
322
+ if ( RE_CONTENT_TYPE_JSON.test( contentType ) ) {
323
+
324
+ return ( response.json() );
325
+
326
+ } else if ( RE_CONTENT_TYPE_TEXT.test( contentType ) ) {
327
+
328
+ return ( response.text() );
329
+
330
+ } else {
331
+
332
+ return ( response.blob() );
333
+
334
+ }
335
+
336
+ }
337
+
338
+
339
+ /**
340
+ * FormData instance from the given object.
341
+ *
342
+ * NOTE: At this time, only simple values (ie, no files) are supported.
343
+ */
344
+ function buildFormData( form ) {
345
+ var formData = new FormData();
346
+
347
+ Object.entries( form ).forEach(
348
+ ( [key, value] ) => {
349
+
350
+ formData.append( key, value );
351
+
352
+ }
353
+ );
354
+
355
+ return ( formData );
356
+ }
357
+
358
+
359
+ /**
360
+ * Supports url or url with template identifiers,creates a url with optional query parameters.
361
+ * @param {string} url
362
+ * @param {object} templateData
363
+ * @param {object} params
364
+ * @returns
365
+ */
366
+ function buildURL( url, templateData, params ) {
367
+
368
+ if ( url.href ) return url;
369
+
370
+ const formattedUrl = buildURLTemplate( url, templateData );
371
+
372
+ const finalUrl = new URL( formattedUrl, `${window.location.origin}${window.baseUrl || ""}` );
373
+
374
+ const searchParams = new URLSearchParams();
375
+
376
+ Object.entries( params ).forEach(
377
+ ( [key, value] ) => {
378
+ if ( Array.isArray( value ) ) {
379
+ for ( let i = 0; i < value.length; i++ ) {
380
+ const e = value[i];
381
+ searchParams.append( key, e );
382
+ }
383
+ return;
384
+ }
385
+
386
+ searchParams.append( key, value );
387
+ }
388
+ )
389
+
390
+ finalUrl.search = searchParams;
391
+
392
+ return finalUrl;
393
+ }
394
+
395
+
396
+ /**
397
+ * Builds a urls tring using template identifiers.
398
+ * @param {string} url
399
+ * @param {object} templateData
400
+ */
401
+ function buildURLTemplate( url, templateData ) {
402
+ Object.entries( templateData ).forEach(
403
+ ( [key, value] ) => {
404
+ url = url.replace( new RegExp( `{${key}}`, "ig" ), value.toString() )
405
+ }
406
+ )
407
+
408
+ return url;
409
+ }
410
+
411
+
412
+ /**
413
+ * Transform the collection of HTTP headers into a like collection wherein the names
414
+ * of the headers have been lower-cased. This way, if we need to manipulate the
415
+ * collection prior to transport, we'll know what key-casing to use.
416
+ */
417
+ function buildHeaders( headers ) {
418
+
419
+ var lowercaseHeaders = {};
420
+
421
+ Object.entries( headers ).forEach(
422
+ ( [key, value] ) => {
423
+
424
+ lowercaseHeaders[key.toLowerCase()] = value;
425
+
426
+ }
427
+ );
428
+
429
+ return ( lowercaseHeaders );
430
+
431
+ }
432
+
433
+
434
+ /**
435
+ * At a minimum, we want every error to have the following properties:
436
+ *
437
+ * - data.type
438
+ * - data.message
439
+ * - status.code
440
+ * - status.text
441
+ * - status.isAbort
442
+ *
443
+ * These are the keys that the calling context will depend on; and, are the minimum
444
+ * keys that the server is expected to return when it throws domain errors.
445
+ */
446
+ function normalizeError( data, request, response, config ) {
447
+ var error = {
448
+ data: {
449
+ type: "ServerError",
450
+ message: UNEXPECTED_ERROR_MESSAGE
451
+ },
452
+ status: {
453
+ code: response.status,
454
+ text: response.statusText,
455
+ isAbort: false
456
+ },
457
+ // The following data is being provided to make debugging AJAX errors easier.
458
+ request: request,
459
+ response: response,
460
+ config: config || {}
461
+ };
462
+
463
+ // If the error data is an Object (which it should be if the server responded
464
+ // with a domain-based error), then it should have "type" and "message"
465
+ // properties within it. That said, just because this isn't a transport error, it
466
+ // doesn't mean that this error is actually being returned by our application.
467
+ if (
468
+ ( typeof ( data?.type ) === "string" ) &&
469
+ ( typeof ( data?.message ) === "string" )
470
+ ) {
471
+
472
+ Object.assign( error.data, data );
473
+
474
+ // If the error data has any other shape, it means that an unexpected error
475
+ // occurred on the server (or somewhere in transit). Let's pass that raw error
476
+ // through as the rootCause, using the default error structure.
477
+ } else {
478
+
479
+ error.data.rootCause = data;
480
+
481
+ }
482
+
483
+ return ( error );
484
+ }
485
+
486
+
487
+ /**
488
+ * If our request never makes it to the server (or the round-trip is interrupted
489
+ * somehow), we still want the error response to have a consistent structure with the
490
+ * application errors returned by the server. At a minimum, we want every error to
491
+ * have the following properties:
492
+ *
493
+ * - data.type
494
+ * - data.message
495
+ * - status.code
496
+ * - status.text
497
+ * - status.isAbort
498
+ */
499
+ function normalizeTransportError( transportError ) {
500
+
501
+ const isAbort = ( transportError.name === "AbortError" )
502
+ return ( {
503
+ data: {
504
+ type: "TransportError",
505
+ message: isAbort ? "Network Request Aborted" : UNEXPECTED_ERROR_MESSAGE,
506
+ rootCause: transportError
507
+ },
508
+ status: {
509
+ code: 0,
510
+ text: "Unknown",
511
+ isAbort: isAbort
512
+ }
513
+ } );
514
+
515
+ }
@@ -0,0 +1 @@
1
+ export const ADD_ERROR = "ADD_ERROR";
package/lib/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as Download } from './Download';
2
+ export { default as fetch, applyMiddleware } from './FetchWrapper';
3
+ export { ADD_ERROR } from "./constants/ErrorConstants";
@@ -0,0 +1,42 @@
1
+ import { dispatch } from "@selkirk-systems/state-management";
2
+ import { ADD_ERROR } from "../constants/ErrorConstants";
3
+
4
+ const handler = ( err ) => ( response ) => ( options ) => ( next ) => {
5
+ if ( err ) {
6
+ dispatch( ADD_ERROR, err );
7
+
8
+ return;
9
+ }
10
+
11
+ return next( null, response );
12
+ };
13
+
14
+ // NOTE: event name is all lower case as per DOM convention
15
+ window.addEventListener( "unhandledrejection", function ( e ) {
16
+ // NOTE: e.preventDefault() must be manually called to prevent the default
17
+ // action which is currently to log the stack trace to console.warn
18
+ e.preventDefault();
19
+ // NOTE: parameters are properties of the event detail property
20
+ var reason = e.reason || e.detail.reason;
21
+ //var promise = e.detail.promise;
22
+ // See Promise.onPossiblyUnhandledRejection for parameter documentation
23
+ console.groupEnd();
24
+ console.groupEnd();
25
+ console.groupEnd();
26
+ throw reason;
27
+ } );
28
+
29
+ // NOTE: event name is all lower case as per DOM convention
30
+ window.addEventListener( "rejectionhandled", function ( e ) {
31
+ // NOTE: e.preventDefault() must be manually called prevent the default
32
+ // action which is currently unset (but might be set to something in the future)
33
+ e.preventDefault();
34
+ // NOTE: parameters are properties of the event detail property
35
+ var promise = e.reason || e.detail.promise;
36
+ // See Promise.onUnhandledRejectionHandled for parameter documentation
37
+ console.groupEnd();
38
+ console.log( "REJECTION HANDLED", promise );
39
+ } );
40
+
41
+ export default handler;
42
+
@@ -0,0 +1,38 @@
1
+ function getStatusCode(err) {
2
+ return err && err.response && err.response.statusCode ? err.response.statusCode : 500;
3
+ }
4
+
5
+ const FetchLogger = err => response => options => next => {
6
+ //Only log outputs for 'development' environment
7
+ if (process.env.NODE_ENV !== "development") return next(err, response);
8
+
9
+ const headerCSS = ['color:gray;font-weight:lighter'];
10
+ const bodyCSS = 'color:gray;font-weight:lighter';
11
+ const successCSS = 'color: green; font-weight:bold';
12
+ const errorCSS = 'color:red;font-weight:bold';
13
+ const responseCSS = 'color:#87A7DB;font-weight:bold';
14
+ const paramsCSS = 'color:orange;font-weight:bold';
15
+
16
+ if (err) {
17
+ headerCSS.push(errorCSS);
18
+ headerCSS.push(bodyCSS);
19
+
20
+ console.groupCollapsed(`%cfetch %cFAIL (${getStatusCode(err)}) %c${options.url}`, ...headerCSS);
21
+ console.log("error", err.response);
22
+ }
23
+ else {
24
+ headerCSS.push(successCSS);
25
+ headerCSS.push(bodyCSS);
26
+
27
+ console.groupCollapsed(`%cfetch %cSUCCESS %c${options.url}`, ...headerCSS);
28
+ console.log("%cerror", errorCSS, err);
29
+ }
30
+
31
+ console.log("%cresponse", responseCSS, response);
32
+ console.log("%cparams", paramsCSS, options);
33
+
34
+ console.groupEnd();
35
+
36
+ return next(err, response);
37
+ }
38
+ export default FetchLogger;
package/package.json CHANGED
@@ -1,69 +1,39 @@
1
1
  {
2
2
  "name": "@selkirk-systems/fetch",
3
- "version": "0.1.6",
4
- "description": "A wrapper on fetch-polyfill that will work for server and client environments",
5
- "selkirk": {
6
- "bambooDeploy": "npm",
7
- "bambooKey": "EDF",
8
- "bambooUrl": [
9
- "http://bamboo5.selkirksystems.com/browse/RCB-EDFDEF",
10
- "http://bamboo5.selkirksystems.com/browse/RCB-EDFPUB"
11
- ]
3
+ "version": "1.0.2",
4
+ "description": "Abortable fetch library",
5
+ "keywords": [],
6
+ "author": "Marcos Bernal <mbernal@selkirksystems.com>",
7
+ "license": "ISC",
8
+ "main": "dist/index.js",
9
+ "module": "dist/index.js",
10
+ "directories": {
11
+ "dist": "dist",
12
+ "lib": "lib",
13
+ "test": "__tests__"
14
+ },
15
+ "files": [
16
+ "dist/**",
17
+ "lib/**"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
12
21
  },
13
22
  "repository": {
14
23
  "type": "git",
15
- "url": "https://bitbucket.org/selkirk/selkirk-fetch.git"
24
+ "url": "git+https://galderak@bitbucket.org/selkirk/web-component-library.git"
16
25
  },
17
- "author": "Marcos Bernal <mbernal@selkirksystems.com>",
18
- "contributors": [
19
- {
20
- "name": "Marcos Bernal",
21
- "email": "mbernal@selkirksystems.com"
22
- }
23
- ],
24
- "homepage": "https://bitbucket.org/selkirk/selkirk-fetch/wiki/Home",
25
- "keywords": [
26
- "react-component",
27
- "react",
28
- "utility"
29
- ],
30
26
  "scripts": {
31
- "prepublish": "babel src --ignore __tests__ --out-dir ./dist",
32
- "lint": "eslint ./src",
33
- "lintfix": "eslint ./src --fix",
34
- "testonly": "mocha --require scripts/mocha_runner src/**/__tests__/**/*.js",
35
- "test": "npm run lint && npm run testonly",
36
- "test-watch": "npm run testonly -- --watch --watch-extensions js"
27
+ "test": "node ./__tests__/**",
28
+ "docs": "jsdoc --configure ../../jsdoc.json -r ./lib -d docs",
29
+ "build": "del dist && cross-env CI=false NODE_ENV=production babel lib --out-dir dist --copy-files --ignore __tests__,spec.js,test.js,__snapshots__"
30
+ },
31
+ "bugs": {
32
+ "url": "https://bitbucket.org/selkirk/web-component-library/issues"
37
33
  },
38
- "devDependencies": {
39
- "babel-cli": "^6.6.4",
40
- "babel-core": "^6.7.4",
41
- "babel-eslint": "^6.0.2",
42
- "babel-plugin-transform-es2015-modules-umd": "^6.6.5",
43
- "babel-polyfill": "^6.7.4",
44
- "babel-preset-es2015": "^6.6.0",
45
- "babel-preset-react": "^6.5.0",
46
- "babel-preset-stage-2": "^6.5.0",
47
- "babelify": "^7.3.0",
48
- "@selkirk-systems/bamboo-util": "~1.2.0",
49
- "chai": "^3.5.0",
50
- "enzyme": "^2.2.0",
51
- "eslint": "^2.7.0",
52
- "eslint-plugin-babel": "^3.1.0",
53
- "eslint-plugin-react": "^4.2.3",
54
- "gulp": "^3.9.1",
55
- "gulp-concat": "^2.6.0",
56
- "jsdom": "^8.1.0",
57
- "mocha": "^2.4.5",
58
- "nodemon": "^1.9.1",
59
- "react-addons-test-utils": "^15.0.0",
60
- "sinon": "^1.17.3"
34
+ "homepage": "https://bitbucket.org/selkirk/web-component-library#readme",
35
+ "peerDependencies": {
36
+ "@selkirk-systems/state-management": "^1.0.0"
61
37
  },
62
- "dependencies": {
63
- "babel-runtime": "^6.6.1",
64
- "bluebird": "^3.4.6",
65
- "es6-promise": "^4.2.2",
66
- "isomorphic-fetch": "^2.2.1",
67
- "object-assign": "^4.1.0"
68
- }
69
- }
38
+ "gitHead": "5c983164ac8e9ba82f7029463483fe5635987802"
39
+ }