Search code examples
javascriptangularjspromiseangular-resource

Using $resource in a promise chain (fixing deferred anti-pattern)


I have a service with a method that gets me a list of project types using a $resource. It's working well for me, except that if I make multiple nearly simultaneous calls (from say, two directives) each will create another request instead of using the same response/$promise/data.

I found this which led me to this and TL;DR, apparently it's creating a redundant $q.defer() and is actually considered to be a deferred anti-pattern.

The code below works well if the calls to get project types are significantly staggered (like more than milliseconds apart). The consecutive calls are resolved with the shared.projectTypes. It also works in the sense that if the request to get project types fails, the dfr.reject() will be triggered and be caught by .catch in the calling controller.

angular.module('projects')
.factory('projectService', function(notificationService){

    // an object to share data gathered by this service
    var shared = {};

    // $resource for projects API
    var projectResource = $resource(baseApiPath + 'projects', {}, {
        ...,
        getProjectTypes: {
            method: 'GET',
            url: baseApiPath + 'projects/types'
        },
        ...
    });

    // loads a list of project types
    var loadProjectTypes = function(){
        var dfr = $q.defer();

        // if we've already done this, just return what we have.
        if(shared.projectTypes){
            dfr.resolve(shared.projectTypes);
        }
        else {
            // begin anti-pattern (?)
            projectResource.getProjectTypes(null,
            function(response){
                shared.projectTypes = response.result.projectTypes;
                dfr.resolve(response);
            },
            function(errResponse){
                console.error(errResponse);
                notificationService.setNotification('error', errResponse.data.messages[0]);
                dfr.reject(errResponse);
            });
        }
        return dfr.promise;
    };

    return {
        shared: shared,
        project: projectResource,
        loadProjectTypes: loadProjectTypes
    };
});

So, I read that having this extra var dfr = $q.defer() is not necessary as the $resource would provide all that for me. With a bit of refactoring, I ended up with this:

...
    // $resource for projects API
    var projectResource = $resource(baseApiPath + 'projects', {}, {
        ...,
        getProjectTypes: {
            method: 'GET',
            url: baseApiPath + 'projects/types',
            isArray: true,
            transformResponse: function(response){
                return JSON.parse(response).result.projectTypes;
            }
        },
        ...
    });

    // loads a list of project types
    var loadProjectTypes = function(){
        return shared.projectTypes || (shared.projectTypes = projectResource.getProjectTypes());
    };
...

To clarify, I have added isArray and transformResponse to the resource because my API returns a lot of extra meta information and all I wanted was an array of types. In my loadProjectTypes method, I'm including the same caching we originally had, but I'm caching the result of projectResource.getProjectTypes() instead of the actual response data (even though that might be exactly what I'm caching because of the transformResponse).

This works on the happy path (reduced calls to API, returns the same thing to everyone, etc) but my main problem is with the chaining and catching of errors.

In my original anti-pattern example, if there is an error with GET /project/types, I'm using dfr.reject() which is then passed back to my controller where I have a .catch().

This is code from the controller which actually makes the original request to get project types:

$q.all([
    projectService.loadProjects(),
    userService.loadUserRole('project_manager'),
    userService.loadUserRole('sales_representative'),
    projectService.loadProjectTypes(),
    clientService.loadClients()
])
.then(function(response){
    // doing stuff with response
})
.catch(function(errResponse){
    // expecting errors from service to bubble through here
    console.error(errResponse);
});

With the anti-pattern example, the dfr.reject is causing the error to show up here in the catch, but in my supposed non-anti-pattern example, it's not happening. I'm not sure how to reject or resolve the $resource results in the same way I was before. If one of the points of promise chaining is to have one spot to handle errors from any chain link, I was doing it right.

I tried to use $q.resolve()/reject(), since I don't have dfr anymore, but this seems dumb and doesn't work anyway.

return shared.projectTypes || (shared.projectTypes = projectResource.getProjectTypes(null, 
    function(response){
        return $q.resolve(response);
    },
    function(errResponse){
        return $q.reject(errResponse);
    }));

How do I get the chain to work so that .catch() in the controller is where the errors get handled?

Did I actually implement the anti-pattern in my original code, or was that one of the accepted ways to use $q.defer() and it wasn't an anti-pattern at all?

In the second link I posted, there is an answer that says:

"What's wrong with it? But the pattern works! Lucky you. Unfortunately, it probably doesn't, as you likely forgot some edge case. In more than half of the occurrences I've seen, the author has forgotten to take care of the error handler."

However, my original code was addressing the errors. It was working, except that each caller was getting it's own promise. I feel that's where I missed something.

I might be confused, but I'm thinking that the loadProjectTypes method should return the same promise/data to anyone who calls it, no matter when it's called. It should be the one true source of anything projectTypes and only make the call once, the very first time.

Any time I look for any of this (lots of purple/visited google links on these subjects), everyone is either showing chaining with contrived examples, or only using $http, or something else. I haven't found anyone doing error catching in a promise chain that uses $resource.

UPDATE: Adding my requirements for the solution. I posted them in my answer, but wanted to include them in the original post too.

Requirement 1: Allows multiple calls to the method, but only makes one API request which updates all callers with the same data.

Requirement 2: Must be able to use result of method as actual data, just as the promise spec intends. var myStuff = service.loadStuff() should actually set myStuff to be "stuff".

Requirement 3: Must allow promise chaining so that all errors in any part of the chain can be caught by a single catch at the end of the chain. As I've found in my solution, there can be more than one chain, and more than one catch, but the point is that each chain has a catch, and any "links" in the chain that break should all report their errors to their respective catch.


Solution

  • Isn't that always the way, as soon as you speak your problems, you come across your solution.

    Requirement 1: Only make one request per method call. This is solved with the original fix to the anti-pattern. This will either always return the $resource result by either returning the cached $resource or returning and caching at the same time.

    var loadProjectTypes = function(){
        return shared.projectTypes || (shared.projectTypes = projectResource.getProjectTypes());
    };
    

    Requirement 2: Be able to use the service method as a promise where I can set the value of a $scope variable directly to the result of loadProjectTypes(). Using the revised method above, I can simply state $scope.theTypes = projectService.loadProjectTypes() and it'll automatically be filled with the list of types when they come in, just as the promise spec intends.

    Requirement 3: Be able to chain together multiple $resource calls and have their errors be caught by a single .catch(). By using the $promise of the result of loadProjectTypes within $q.all(), I can catch any errors in any catch I want.

    $q.all([
        ...,
        projectService.loadProjectTypes().$promise,
        ...
    ])
    .then(function(response){
        // my project types comes in as response[n]
    })
    .catch(function(errResponse){
        // but any errors will be caught here
    });
    

    Technically, I can put catches in different places and they'll all work the same. Anytime I have loadProjectTypes(), I can use a .catch() and my errors will be handled there. Each loader of types can handle the API being down in it's own way. This could be really good actually. A controller might get the UI to display a message and a small directive might just display something else, or nothing at all. They each can handle the bad in their own way.

    My service, directive and controller look like this now:

    angular.module('projects')
    .factory('projectService', function(notificationService){
    
        // an object to share data gathered by this service
        var shared = {};
    
        // $resource for projects API
        var projectResource = $resource(baseApiPath + 'projects', {}, {
            ...,
            getProjectTypes: {
                method: 'GET',
                url: baseApiPath + 'projects/types',
                isArray: true,
                transformResponse: function(response){
                    return JSON.parse(response).result.projectTypes;
                }
            },
            ...
        });
    
        // loads a list of project types
        var loadProjectTypes = function(){
            return shared.projectTypes || (shared.projectTypes = projectResource.getProjectTypes());
        };
    
        return {
            shared: shared,
            project: projectResource,
            loadProjectTypes: loadProjectTypes
        };
    });
    
    
    angular.module('projects')
    .directive('projectPageHeader', ['projectService', function(projectService){
        return {
            restrict: 'E',
            scope: {
                active: '@',
            },
            templateUrl: 'src/js/apps/projects/partials/dir_projectPageHeader.html',
            replace: true,
            controller: function($scope){
                $scope.projectService = projectService;
    
                // sets the types to the array of types
                // as given by the transformResponse
                $scope.types = projectService.getProjectTypes();
    
                // could also do a .$promise.catch here if I wanted.
                // all catches will fire if get projectTypes fails.
            }
        };
    }]);
    
    angular.module('projects')
    .controller('projectListPageController', [
        '$scope','projectService',
    function($scope, projectService){
    
        // load it all up
        $q.all([
            projectService.loadProjectDetails($routeParams.projectId).$promise,
            userService.loadUserRole('project_manager').$promise,
            userService.loadUserRole('sales_representative').$promise,
            projectService.loadProjectStatuses().$promise,
            projectService.loadProjectTypes().$promise,
            clientService.loadClients().$promise
        ])
        .then(function(response){
            // do work with any/all the responses
        })
        .catch(function(errResponse){
            // catches any errors from any of the $promises above.
        })
    }]);
    

    Since the loadProjectTypes (or any other load_____ method) saves the types within the service it comes from, I don't really need to do any storing on the controller. projectService.shared.projectTypes is universal across the entire app. The .then() method in my controller could potentially be noop if all the services were storing the results of their loads internally (which is how I like it) unless there was some view specific thing I needed to do with them. I typically only use controllers for entire pages, or $modals. Everything else is broken up into directives and most information and logic is in services.

    I'm leaving the question open in case someone has a better solution. I like the one that Jack A. posted, but I feel it makes my load___ methods more verbose than they already are. Since there are a few of them with slight differences, it leads to a lot of redundant code, or complex 'smart' methods in my actual code. It definitely solves Requirement 1 and possibly 2 and 3 though.

    UPDATE (GOTCHA):

    So, I've been using this pattern for a few days now and it's working really exactly as I intend. It's really streamlined our process; however, I recently came upon a gotcha when using a method like loadProjectTypes in a singular context (i.e.: outside of $q.all()).

    If you just use the load method like so:

    // This code is just placed in your controllers init section
    loadProjectTypes()
    .$promise
    .then(function(response){
        // ... do something with response (or noop)
    })
    .catch(function(errResponse){
        // ... do something with error
    });
    

    You will run into a situation when that controller 'refreshes'. For example, you have the code above in controllerA, you change "pages" which uses controllerB, then you go back to the first "page" and controllerA refreshes and tries to run this again. The error you get is that "there is no .then of undefined."

    Inspecting this in the console, the first time loadProjectTypes() runs, it returns the response from the $resource (which includes $promise AND all the projectType data). The second time - coming back from controllerB - it will only hold the projectType data. There is no more $promise because you are not returning the result of a $resource, you returned the cached shared.projectTypes that you set after the first time. That's why we did all this, remember? I'm not sure why this goes away since that's what you saved to shared.projectTypes, but it does, and it doesn't actually matter.

    return shared.projectTypes || (shared.projectTypes = projectResource.getProjectTypes());
    

    For me, the easiest fix was to just have loadProjectTypes().$promise as the only member of a $q.all() set:

    // again, this code is just placed somewhere near the top of your controller
    $q.all([
        loadProjectTypes().$promise
    ])
    .then(...)
    .catch(...);
    

    In most cases, my controllers will be getting more than one thing so this would've happened eventually, but there will always be a situation where you only need to load one thing. Using a single item set in $q.all() is the only way to have no issues when using this solution. It's not that bad really, could've been worse.