Search code examples
javascriptfluentjson-api

How to wrap a custom json:api client and make calls fluent and async (Non-ES6)


I want to create a custom client for a restful API that follows the json:api specification. So, I created this simple client with async support:

MyTools.Api = (function () {
    "use strict";
    //#region Private
    function ajax(url, method, data) {
        return new Promise(function (resolve, reject) {
            let request = new XMLHttpRequest();
            request.responseType = 'json';
            //Open first, before setting the request headers.
            request.open(method, url, true);
            request.setRequestHeader('Authorization', 'Bearer ' + window.headerToken)
            request.onreadystatechange = function () {
                if (request.readyState === XMLHttpRequest.DONE) {
                    if (request.status === 200) {
                        resolve(request.response);
                    } else {
                        reject(Error(request.status));
                    }
                }
            };
            request.onerror = function () {
                reject(Error("Network Error"));
            };
            request.open(method, url, true);
            request.send(data);
        });
    }

    //#endregion
    //#region Public
    return function (url, method, data) {
        url = window.apiBasePath + url;
        return ajax(url, method, data);
    };
    //#endregion
})(MyTools.Api || {});

I am passing the token from the back-end (.net) to a window global variable called headerToken. The same with the base path (apiBasePath). Now, I can call this client like like this

CTools.Api("/dashboard/users/", "GET").then(function (result) {
console.log(result);
});

My goal is to create a more fluent way to consume the api.For example,I want to call something like:

mytools.api.dashboard.users.get().then(function (result) {
    console.log(result);
    });

and

mytools.api.dashboard.users.get(fliteroptions).then(function (result) {
    console.log(result);
    });

and if there is another module to use like

mytools.api.basket.items.get(fliteroptions).then(function (result) {
    onsole.log(result);
    });

Dashboard and basket will have different urls. Both the client and the url building will be created inside mytools namespace. Also the variables headerToken and apiBasePath will be assigned in mytools constructor after a rest call.

What design pattern to use in this case? Keep in mind that I want a non-ES6 solution.


Solution

  • There's essentially only really two ways of doing what you want to do; either each route (dashboard, basket, etc) is a full api object with fetch behaviour etc, or the get() method in dashboard etc maps back to api.

    Something like this:

    class Route {
        constructor(url, api) {
            this.url = url;
            this.api = api;
        }
        
        get(filterOptions) {
            return this.api.get(this.url, filterOptions)
        }
        
        post(filterOptions) {
            return this.api.post(this.url, filterOptions);
        }
    }
    
    class Api {
        constructor(basePath) {
            this.basePath = basePath;
        }
        
        get(route, filterOptions) {
            // filterOptions can be null here
            return fetch(`${this.basePath}/${route}`)
                .then(data => data.json())
        }
        
        post(route, filterOptions) {
            // filterOptions can be null here
            ...
        }
        
        ...
    }
    
    mytools.api = new Api();
    mytools.api.dashboard = new Route('dashboard', mytools.api);
    mytools.api.basket = new Route('basket', mytools.api);
    ...
    

    Your Route class is a simple class that holds a url and whatever else, as well as the api which will actually do the fetching.

    Api wraps your fetch and handles any filterOptions passed.

    When you create your Api, you also create all the routes.

    The downside is that you need to know all of your routes beforehand.

    To do it a bit more dynamically, you can look into Proxy: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy

    Here, you can implement a function that's called every time your object is accessed:

    var handler = {
        get: function(target, name) {
            return "Hello, " + name;
        }
    };
    var proxy = new Proxy({}, handler);
    
    console.log(proxy.world); // output: Hello, world
    

    You should be able to hook into it so that get method returns the Api object, and you can then call directly on it

    Edit for ES5

    It's been a while since I've written pure ES5, but converting the Route class, for example, should look something like this:

    function Route(url, api) {
        this.url = url;
        this.api = api;
    }
    
    Route.prototype.get = function(filterOptions) {
        return this.api.get(this.url, filterOptions)
    }
    
    Route.prototype.post = function(filterOptions) {
        return this.api.post(this.url, filterOptions)
    }
    

    Creating the objects should stay the same - new Route(url, api)

    I'm not sure if fetch is ES5 - you'd need to look at where the code will be running to see if it's supported or not. Ditto with promises; you'd either need to use a library like Bluebird, or work with callbacks.

    More info on classes here: https://medium.com/@apalshah/javascript-class-difference-between-es5-and-es6-classes-a37b6c90c7f8

    Edit for dynamic URLs

    If I've understood you correctly, you're getting the URLs from a REST call.

    Assume that the return of this call is something like:

    {
        "dashboard": {
            "url": "dashboard/",
            "token": "..."
        },
        "basket": {
            "url": "basket/",
            "token": "..."
        },
        ...
    }
    

    At this point, you can now go through and create your api:

    mytools.api = new Api();
    mytools.api.dashboard = new Route(jsonObj.dashboard.url, mytools.api);
    mytools.api.basket = new Route(jsonObj.basket.url, mytools.api);
    ...
    

    You can also pass in token, or whatever else is in your REST JSON object. It does mean a few things though:

    1. You can't access mytools.api.dashboard etc until that initial REST call is made - it won't point to anything
    2. You need to know your routes beforehand - e.g. here, we know that we have a route behind the mytools.api.dashboard variable

    In relation to point 2, you can't really have truly dynamique urls (i.e. you can add "profile" to the REST JSON without needing to add a corresponding mytools.api.profile call in your codebase) without rethinking how you approach things. What you're doing here (making the API easier to work with) is pre-compile - as in, when you're writing your code. However, your routes aren't known until runtime