Serg Hospodarets Blog

Serg Hospodarets blog

JavaScript Fetch API in action Serg Hospodarets Blog
In short, Fetch API- it's a new Promise- based standard for doing AJAX requests.

Syntax for XHR was provided more then 10 years ago (XMLHttpRequest2 - about 4 years ago).
Many things changed, we got HTML5, CSS3, also close to start using EcmaScript 6.
From jQuery Deferred, $q and Native JavaScript Promises people became familiar and like promises in JS.
It’s time for new laconic API to do AJAX-requests and interact with them.
And time is come!

Before

Before we had XMLHttpRequest syntax. E.g. to get JSON usually we had to provide the following methods in some utilities file:
(simple demo without listening onerror etc. events and manual timeout checking)

/*--- Send Ajax ---*/
var MAX_XHR_WAITING_TIME = 5000;// in ms

var sendAjax = function (params) {
    var xhr = new XMLHttpRequest(),
            url = params.cache ? params.url + '?' + new Date().getTime() : params.url,
            timer = setTimeout(function () {// if xhr won't finish after timeout-> trigger fail
                xhr.abort();
                params.error && params.error();
                params.complete && params.complete();
            }, MAX_XHR_WAITING_TIME);
    xhr.open(params.type, url);
    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
            clearTimeout(timer);
            if (xhr.status === 200 || xhr.status === 0) {// 0 when files are loaded locally (e.g., cordova/phonegap app.)
                params.success && params.success(xhr.responseText);
                params.complete && params.complete();
            } else {
                params.error && params.error(xhr.responseText);
                params.complete && params.complete();
            }
        }
    };
    params.beforeSend && params.beforeSend(xhr);
    xhr.send();
};

/*--- Get JSON by url ---*/
var getJSON = function (params) {
    sendAjax({
        type: 'get',
        url: params.url,
        beforeSend: function (xhr) {
            xhr.setRequestHeader('Accept', 'application/json, text/javascript');
        },
        success: function (res) {
            params.success && params.success(JSON.parse(res));
        },
        error: params.error,
        complete: params.complete,
        cache: true
    });
};

// INVOKE
getJSON({
    url: 'https://api.github.com/users/malyw',
    success: onSuccess,
    error: onError,
    complete: onComplete
});

So, previously we usually sent callbacks as params.
Let’s figure out how to do the same using Fetch API.

With Fetch API

Fetch

First, we fetch a resource:

var url = 'https://api.github.com/users/malyw';
fetch(url);

Fetching resource returns promise with response data (in case of any is fetched/response gotten) or error (otherwise).

Processing status

Promise will be resolved successufully in case of any server responce, even 404 etc.
So (as in case with XHR) we need to handle it:

var processStatus = function (response) {
    // status "0" to handle local files fetching (e.g. Cordova/Phonegap etc.)
    if (response.status === 200 || response.status === 0) {
        return Promise.resolve(response)
    } else {
        return Promise.reject(new Error(response.statusText))
    }
};

fetch(url)
    .then(processStatus)
    // the following code added for example only
    .then()
    .catch();

If there is response with status “200” or “0”- will be returned resolved promise,
which means, we can use .then().then() promise syntax.
In other case .catch() will be invoked.

Parse JSON

Also there is a method json() to proceed the response.
Knowing it let’s add parsing:

var parseJson = function (response) {
    return response.json();
};

fetch(url)
    .then(parseJson);

This code gives promise in which (in case of success) will be returned parsed JSON data.

Setting options

Nice, going further. We need to set “get” as method for doing our request and proper “Accept” header.
There is a cache option but so far let’s use manual cache busting.
It can be done passing them in second argument (options) to fetch():

fetch(url, {
    method: 'get',
    headers: {
        'Accept': 'application/json'
    }
});

Adding waiting timeout

When fetch() takes more then some period of time (10-30 seconds)-
it’s good idea to define that it was unsuccessful (depends on circumstances).
And here the problems start- there is NOT timeout option. So we need to wrap fetch() promise to be able to reject it when timeout is reached. It adds complexity to our code but provides more flexibility. So let’s imagine so far (code will be provided below) that we have some wrappedFetch Promise-like object with ability to trigger .reject() on it:

var MAX_WAITING_TIME = 5000;// in ms

var timeoutId = setTimeout(function () {
    wrappedFetch.reject(new Error('Load timeout for resource: ' + params.url));// reject on timeout
}, MAX_WAITING_TIME);

return wrappedFetch.promise// getting clear promise from wrapped
    .then(function (response) {
        clearTimeout(timeoutId);
        return response;
    });

ALL TOGETHER

/* @returns {wrapped Promise} with .resolve/.reject/.catch methods */
// It goes against Promise concept to not have external access to .resolve/.reject methods, but provides more flexibility
var getWrappedPromise = function () {
    var wrappedPromise = {},
            promise = new Promise(function (resolve, reject) {
                wrappedPromise.resolve = resolve;
                wrappedPromise.reject = reject;
            });
    wrappedPromise.then = promise.then.bind(promise);
    wrappedPromise.catch = promise.catch.bind(promise);
    wrappedPromise.promise = promise;// e.g. if you want to provide somewhere only promise, without .resolve/.reject/.catch methods
    return wrappedPromise;
};

/* @returns {wrapped Promise} with .resolve/.reject/.catch methods */
var getWrappedFetch = function () {
    var wrappedPromise = getWrappedPromise();
    var args = Array.prototype.slice.call(arguments);// arguments to Array

    fetch.apply(null, args)// calling original fetch() method
        .then(function (response) {
            wrappedPromise.resolve(response);
        }, function (error) {
            wrappedPromise.reject(error);
        })
        .catch(function (error) {
            wrappedPromise.catch(error);
        });
    return wrappedPromise;
};

/**
 * Fetch JSON by url
 * @param { {
 *  url: {String},
 *  [cacheBusting]: {Boolean}
 * } } params
 * @returns {Promise}
 */
var getJSON = function (params) {
    var wrappedFetch = getWrappedFetch(
        params.cacheBusting ? params.url + '?' + new Date().getTime() : params.url,
        {
            method: 'get',// optional, "GET" is default value
            headers: {
                'Accept': 'application/json'
            }
        });

    var timeoutId = setTimeout(function () {
        wrappedFetch.reject(new Error('Load timeout for resource: ' + params.url));// reject on timeout
    }, MAX_WAITING_TIME);

    return wrappedFetch.promise// getting clear promise from wrapped
        .then(function (response) {
            clearTimeout(timeoutId);
            return response;
        })
        .then(processStatus)
        .then(parseJson);
};

/*--- TEST  --*/
var url = 'https://api.github.com/users/malyw';
var onComplete = function () {
    console.log('I\'m invoked in any case after success/error');
};

getJSON({
    url: url,
    cacheBusting: true
}).then(function (data) {// on success
    console.log('JSON parsed successfully!');
    console.log(data);
    onComplete(data);
}, function (error) {// on reject
    console.error('An error occured!');
    console.error(error.message ? error.message : error);
    onComplete(error);
});

There is NOT .always() or .complete() for Native JavaScript promises,
that’s why you have to add onComplete() callback to the end of success and reject callbacks.

But with coming of Fetch API we pass callbacks in Promise-style, which is common way nowadays.

Using Fetch API for loading CommonJS/AMD modules

Generally fetch syntax is close to AMD- based RequireJS style- you provide path to your module and then use it. So idea about providing modules loader system based on it looks very reasonable.

Fetch API-based AMD modules loader

To create loader for AMD style, we need to process all required modules together.
First- fetch them, then- get their texts, invoke them and provide modules as promise result.
Let’s create loader for simple AMD cases and test it with jQuery and d3.js libraries:

/*--- AMD-specific ---*/
var loadedModules = [];

var define = function () {
    var args = Array.prototype.slice.apply(arguments), factory, module;
    if (typeof args[0] === 'object') {// FOR AMD SYNTAX LIKE: define(object);
        module = args[0];
    } else {// FOR AMD SYNTAX LIKE: define('module', [], function(){});
        factory = args.pop();
        module = factory();
    }
    loadedModules.push(module);

    return module;
}
define.amd = true;// indicates AMD API

/*--- For fetch processing ---*/
// Fetches all provided urls
var argsToFetch = function (args) {
    var result = [];
    Array.prototype.slice.apply(args).forEach(function (url) {
        result.push(fetch(url));
    });
    return result;
};

// Converts fetch responses to text and stores urls
var responsesToText = function (responses) {
    var scriptTexts = [], urls = [];

    responses.forEach(function (response) {
        scriptTexts.push(response.text());// will convert to text
        urls.push(response.url);
    });

    return Promise.all(scriptTexts)// waiting converting to text
        .then(function (scriptTexts) {
            return {
                scriptTexts: scriptTexts,// processing scripts texts
                urls: urls// adding urls
            }
        });
};

// Gets AMD modules from loaded scripts
var getModulesFromScripts = function (data) {
    var scriptTexts = data.scriptTexts;// all loaded scripts
    var urls = data.urls;// all loaded urls

    var imports = [];
    scriptTexts.forEach(function (scriptText, i) {
        var length = loadedModules.length;// storing loaded modules length
        eval('(function (define) { ' + scriptText + '\n//*/\n})(define);\n//@ sourceURL=' + urls[i]);// evaluates loaded script
        if (loadedModules.length === length + 1) {// checking it was loaded exactly one module
            imports.push(
                    Promise.resolve(loadedModules.pop())// resolve with the last loaded module
            )
        } else {
            imports.push(
                    Promise.reject(new Error(urls[i] + ' doesn\'t provide exactly one valid AMD module'))
            )
        }

    });
    return Promise.all(imports);// wrapping in common promise
};

// COMMON REQUIRE FUNCTION
/**
 * Fetches scripts by URLs and provides AMD modules from them
 * @param {...String} arguments -  URLs of scripts to be fetched
 * @returns {Array} Array of loaded AMD modules
 */
function require(/* 'URL1', 'URL2', ... */) {
    return Promise.all(argsToFetch(arguments))// fetch all required modules
        .then(responsesToText)// parse results to text
        .then(getModulesFromScripts)// getting required modules from scripts
        .catch(function (error) {// catching errors
            console.error(error.message);
        });
}

/*--- TEST  --*/
var jqueryUrl = 'https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js';
var d3Url = 'https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.5/d3.min.js';
require(jqueryUrl, d3Url)
    .then(function (loadedModules) {// "then" accepts only one param
        var jQuery = loadedModules[0];
        var d3 = loadedModules[1];
        /* checking loaded loadedModules */
        console.log('Loaded jQuery version: ' + jQuery.fn.jquery);
        console.log('Loaded d3 version: ' + d3.version);
        /* checking global scope */
        console.log('jQuery in global scope: ' + window.jQuery);// NOT undefined - surprise, jQuery sets global variables even in AMD
        // http://bugs.jquery.com/ticket/7102#comment:10
        // https://github.com/jquery/jquery/pull/557

        console.log('d3 in global scope: ' + window.d3);// undefined - as expected
    });

Fetch API-based asynchronous CommonJS modules loader

fetch() can invoke onSuccess/onReject callbacks only asynchronously.
That’s why result loader call is similar to AMD syntax.

var require = function (url) {
    return fetch(url)
        .then(function (response) {// converts response to text
            return response.text();
        })
        .then(function (scriptText) {// evaluates loaded script and gets exports
            var mod = {exports: {}};
            eval('(function (module, exports) { ' + scriptText + '\n//*/\n})(mod, mod.exports);\n//@ sourceURL=' + url);
            return mod.exports;
        })
        .catch(function (error) {
            console.error(error.message);
        });
}

require('https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js').then(function (exports) {
    // for jQuery: exports === jQuery
    console.log('Loaded jQuery version: ' + exports.fn.jquery);// show fetched jQuery version
    console.log('jQuery in global scope: ' + window.jQuery);// undefined - nothing were added to global scope
});

Options and methods

Options

As we already saw, in second argument for fetch() we can set request headers and method for requesting resorce. As well we can set mode, body, credentials, cache, context, referrer.
E.g. to send POST request to the service for JSON validation:

var serialize = function (data) {
    return Object.keys(data).map(function (keyName) {
        return encodeURIComponent(keyName) + '=' + encodeURIComponent(data[keyName])
    }).join('&');
};

function validateJSON(url, json) {
    json = typeof json === 'object' ? JSON.stringify(json) : json;
    return fetch(url, {
        method: 'post',
        headers: {
            "Content-type": "application/x-www-form-urlencoded; charset=UTF-8"
        },
        body: serialize({json: json})
    })
    .then(function (response) {
        return response.json();// parse json
    })
    .then(function (data) {
        if (data.validate) {
            console.log('String: "' + json + '" is VALID JSON');
        } else {
            console.warn('String: "' + json + '" is NOT valid JSON');
            console.warn(data.error);
        }
    })
    .catch(function (error) {
        console.error('An error occured');
        console.error(error.message);
    });
}

var jsonTestUrl = 'http://validate.jsontest.com/';
validateJSON(jsonTestUrl, '{');// not valid JSON- the closing "}" is missed
validateJSON(jsonTestUrl, {hello: 'world'});// JS object-> valid JSON is generated

Methods

We used json() and text() to proceed response.
There are also formData(), arrayBuffer(), blob() methods for it.
So there is possibility to work with form data and files too.
Next example will show how to fetch image from one resource and upload it to another:

var downloadFile = function (url) {
    return fetch(url)
            .then(processStatus)
            .then(parseBlob);
};

function uploadImageToImgur(blob) {
    var formData = new FormData();
    formData.append('type', 'file');
    formData.append('image', blob);

    return fetch('https://api.imgur.com/3/upload.json', {
        method: 'POST',
        headers: {
            Accept: 'application/json',
            Authorization: 'Client-ID dc708f3823b7756'// imgur specific
        },
        body: formData
    })
        .then(processStatus)
        .then(parseJson);
}

// --- ACTION ---
var sourceImageUrl = 'https://lh4.googleusercontent.com/-d_O-phk2MoA/VLxXocjkT4I/AAAAAAAAFbE/lsSk997pBVQ/w600-h700-no/make-fetch-happen-small.png';
console.log('Started downloading image from <a href="' + sourceImageUrl + '">google picasa url</a>');

downloadFile(sourceImageUrl)// download file from one resource
    .then(uploadImageToImgur)// upload it to another
    .then(function (data) {
        console.log('Image successfully uploaded to <a href="//imgur.com/' + data.data.id + '">imgur.com url</a>');
        //console.log('<img src="' + data.data.link + '"/>');// for demo
    })
    .catch(function (error) {
        console.error(error.message ? error.message : error);
    });

In getting JSON example headers option was set before fetching resource to show JSON is expected as answer.
But we also can work with response headers. Let’s check that server answers with JSON as expected:

function loadJSON(url) {
    fetch(url)
            .then(function (response) {
                if (response.headers.get("content-type").indexOf("application/json") !== -1) {// checking response header
                    return response.json();
                } else {
                    throw new TypeError('Response from "' + url + '" has unexpected "content-type"');
                }
            })
            .then(function (data) {
                console.log('JSON from "' + url + '" parsed successfully!');
                console.log(data);
            })
            .catch(function (error) {
                console.error(error.message);
            });
}

loadJSON('https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js');// throws a TypeError
loadJSON('https://api.github.com/users/malyw');// is parsed normally

Browser support

Fetch API is available in Chrome 42+, Firefox 39+
and is under consideration for IE.
With Fetch and Promise Polyfills it works wonderfully in all browsers up-to IE9 (but IE9 doesn’t support Cross-origin fetching, which works in others).

Limitations

  • Promises don’t have finally / always method- there is a workaround

  • There is neither abort method nor a timeout property for fetch()

  • fetch() (as Promise- based standard) doesn’t have ability to provide onProgress() callback- so you cannot process a response by chunks

  • Haven’t found clear info about doing synchronous fetching
    but, most of all, it’s not provided, because even in such case onSuccess and onReject callbacks would be invoked on the next tick- due to Promise nature.

🔚

Provide your code in <pre><code></code></pre> tags