ondrasak
4/26/2014 - 11:14 AM

resources.js

'use strict';

describe('Rest API resources', function () {
  var backend, $httpBackend, apiEndpoint = 'http://localhost';

  beforeEach(function () {
    module('realty.common.resources', function (backendProvider) {
      backendProvider.configure({ apiEndpoint: apiEndpoint });
    });

    inject(function (_$httpBackend_, _backend_) {
      $httpBackend = _$httpBackend_;
      backend = _backend_;
    });
  });

  afterEach(function () {
    $httpBackend.flush();
    $httpBackend.verifyNoOutstandingExpectation();
    $httpBackend.verifyNoOutstandingRequest();
  });


  it('should return root resource', function () {
    $httpBackend.expectGET(apiEndpoint).respond({});

    backend.read().then(function (root) {
      expect(root.isInvalid()).toBeFalsy();
      expect(root._links.self).toBeDefined();
      expect(root.selfLink() === apiEndpoint).toBeTruthy();
    });
  });

  it('should throws no exception while traversing', function () {
    $httpBackend.expectGET(apiEndpoint).respond({});
    backend.read('invalid1 invalid2 invalid3').then(function (resource) {
      expect(resource.isInvalid()).toBeTruthy();
    });
  });

  it('should traverse with promises', function () {
    $httpBackend.whenGET(apiEndpoint).respond({ _links: { outer: { href: apiEndpoint + '/outer'}}});
    $httpBackend.whenGET(apiEndpoint + '/outer').respond({ _links: { inner: { href: apiEndpoint + '/inner' }}});
    $httpBackend.expectGET(apiEndpoint + '/inner').respond({});

    backend.read('outer').then(function (outer) {
      expect(outer.isInvalid()).toBeFalsy();
      expect(outer.selfLink() === (apiEndpoint + '/outer')).toBeTruthy();
      outer.read('inner').then(function (inner) {
        expect(inner.isInvalid()).toBeFalsy();
        expect(inner.selfLink() === (apiEndpoint + '/inner')).toBeTruthy();
      });
    });
  });

  it('should traverse resources', function () {
    $httpBackend.expectGET(apiEndpoint).respond({ _links: { outer: { href: apiEndpoint + '/outer'}}});
    $httpBackend.expectGET(apiEndpoint + '/outer').respond({ _links: { inner: { href: apiEndpoint + '/inner' }}});
    $httpBackend.expectGET(apiEndpoint + '/inner').respond({});

    backend.read('outer inner').then(function (inner) {
      expect(inner.isInvalid()).toBeFalsy();
      expect(inner.selfLink() === (apiEndpoint + '/inner')).toBeTruthy();
    });
  });

  it('should process _embedded property', function () {
    $httpBackend.expectGET(apiEndpoint).respond({ _links: { outer: { href: apiEndpoint + '/outer' }}, _embedded: { outer: {} } });

    backend.read('outer').then(function (outer) {
      expect(outer.isInvalid()).toBeFalsy();
      expect(outer.selfLink() === (apiEndpoint + '/outer')).toBeTruthy();
    });
  });

  it('should parse templated url', function () {
    $httpBackend.expectGET(apiEndpoint).respond({ _links: { find: { href: apiEndpoint + '/items{?colour}', templated: true } } });
    $httpBackend.expectGET(apiEndpoint + '/items?colour=blue').respond({ colour: 'blue' });

    backend.read('find', { colour: 'blue' }).then(function (item) {
      expect(item.isInvalid()).toBeFalsy();
      expect(item.colour === 'blue').toBeTruthy();
    });
  });

  describe('when collection is returned', function () {

    beforeEach(function () {
      $httpBackend.expectGET(apiEndpoint).respond({
        _links: { items: { href: apiEndpoint + '/items' } },
        _embedded: { items: [
          { _links: { self: { href: apiEndpoint + '/items/0' } } },
          { _links: { self: { href: apiEndpoint + '/items/1' } } },
          { _links: { self: { href: apiEndpoint + '/items/2' } } }
        ]}
      });
    });

    it('should get collection values', function () {
      backend.readAll('items').then(function (items) {
        var i;

        expect(angular.isArray(items)).toBeTruthy();
        expect(items.length).toBe(3);

        for (i = 0; i < 3; i++) {
          expect(items[i].selfLink() === (apiEndpoint + '/items/' + i)).toBeTruthy();
        }
      });
    });

    it('should read first value from collection', function () {
      backend.read('items').then(function (item) {
        expect(item.isInvalid()).toBeFalsy();
        expect(item.selfLink() === (apiEndpoint + '/items/0'));
      });
    });

  });

  it('should create entity', function () {
    $httpBackend.expectGET(apiEndpoint).respond({ _links: { items: { href: apiEndpoint + '/items' } } });
    $httpBackend.expectPOST(apiEndpoint + '/items').respond(201, {});

    backend.create('items', { id: 1 }).then(function (items) {
      expect(items.isInvalid()).toBeFalsy();
      expect(items.getStatus()).toBe(201);

      $httpBackend.expectGET(apiEndpoint).respond({ _links: { items: { href: apiEndpoint + '/items' } } });
      $httpBackend.expectPOST(apiEndpoint + '/items').respond(201, {});

      backend.create('items', { id: 2 }).then(function (items) {
        expect(items.isInvalid()).toBeFalsy();
        expect(items.getStatus()).toBe(201);
      });
    });
  });

  it('should update entity', function () {
    $httpBackend.expectGET(apiEndpoint).respond({ _links: { items: { href: apiEndpoint + '/items' } } });
    $httpBackend.expectPUT(apiEndpoint + '/items').respond({});

    backend.update('items', { id: 1 }).then(function (items) {
      expect(items.isInvalid()).toBeFalsy();
      expect(items.getStatus()).toBe(200);
    });
  });

  it('should delete entity', function () {
    $httpBackend.expectGET(apiEndpoint).respond({ _links: { item: { href: apiEndpoint + '/item' } } });
    $httpBackend.expectDELETE(apiEndpoint + '/item').respond({});

    backend.destroy('item').then(function (item) {
      expect(item.isInvalid()).toBeFalsy();
      expect(item.getStatus()).toBe(200);
    });
  });

});
'use strict';

//
// Implementation of HAL http://stateless.co/hal_specification.html
//
// NB: self link always must exist!
//
angular.module('realty.common.resources', [])
  .provider('backend', function () {
    var options = {
      apiEndpoint: 'http://localhost',
      errorHandler: undefined
    };

    this.configure = function (opts) {
      angular.extend(options, opts);
    };

    this.$get = function ($q, $http, $injector) {
      var Resource, emptyResource;

      Resource = (function () {
        var ctr = function (_status_) {
          var status = _status_, isLoaded = false;

          this._links = {};
          this._embedded = {};

          this._isLoadedFlag = function (enabled) {
            if (!isLoaded) {
              isLoaded = enabled || false;
              return false;
            }
            return true;
          };

          this.getStatus = function () {
            return status;
          };
        };

        ctr.prototype.constructor = ctr;

        ctr.prototype.selfLink = function () {
          return this._links.self.href;
        };

        // Return true iff resource is dummy
        ctr.prototype.isInvalid = function () {
          var nLinks, nEmbedded;
          nLinks = Object.getOwnPropertyNames(this._links).length;
          nEmbedded = Object.getOwnPropertyNames(this._embedded).length;
          return (nLinks + nEmbedded) === 0;
        };

        ctr.prototype.create = function (relation, data, flags) {
          return this._loadSelf(flags).then(function (self) {
            return self._sendRequest({ method: 'POST', relation: relation, data: data, flags: flags });
          });
        };

        ctr.prototype.link = function (rel) {
          if (this._hasLink(rel)) {
            return this._links[rel].href;
          }
          if (this._hasEmbedded(rel)) {
            var data = this._embedded[rel];
            if (angular.isArray(data)) {
              data = data[0];
            }
            return data._links.self.href;
          }
        };

        ctr.prototype.readAll = function (query, flags) {
          var rel, relations;
          relations = query.split(/\s+/);
          rel = [].pop.apply(relations);
          return this.read.apply(this, relations, flags).then(function (resource) {
            if (!rel || !resource._hasEmbedded(rel)) {
              return $q.when([]);
            }
            var i, list = resource._embedded[rel];
            if (!angular.isArray(list)) {
              list = [ list ];
            }
            for (i = 0; i < list.length; i++) {
              list[i] = resource._embeddedToResource(list[i]);
            }
            return $q.when(list);
          });
        };

        ctr.prototype.read = function (query, params, flags) {
          var promise, i, args;
          args = !query ? [] : query.split(/\s+/);

          function makePromise(idx, promise) {
            return promise.then(function (resource) {
              return resource._readOne(args[idx], params, flags);
            });
          }

          promise = this._loadSelf(flags);
          for (i = 0; i < args.length; i++) {
            promise = makePromise(i, promise);
          }

          return promise;
        };

        ctr.prototype.update = function (relation, data, flags) {
          return this._loadSelf(flags).then(function (self) {
            return self._sendRequest({ method: 'PUT', relation: relation, data: data, flags: flags });
          });
        };

        ctr.prototype.destroy = function (relation, flags) {
          return this._loadSelf(flags).then(function (self) {
            return self._sendRequest({ method: 'DELETE', relation: relation, flags: flags });
          });
        };

        ctr.prototype._loadSelf = function (_flags_) {
          if (this._isLoadedFlag()) {
            return $q.when(this);
          }
          var flags = { skipErrors: true };
          angular.extend(flags, _flags_);
          return this._readOne('self', undefined, flags);
        };

        ctr.prototype._readOne = function (rel, params, flags) {
          if (this._hasEmbedded(rel)) {
            var data = this._embedded[rel];
            if (angular.isArray(data)) {
              data = data[0];
            }
            return $q.when(this._embeddedToResource(data, rel));
          }
          return this._sendRequest({ method: 'GET', relation: rel, uriParams: params, flags: flags });
        };

        ctr.prototype._embeddedToResource = function (_data_, rel) {
          var resource, data = angular.copy(_data_);

          if (!data._links || !data._links.self) {
            if (rel && this._hasLink(rel)) {
              this._createSelfLinkIfNeeded(data, this._links[rel].href);
            } else {
              throw new Error('Not found self link for embedded relation ' + rel);
            }
          }
          resource = makeResource(data);
          resource._isLoadedFlag(true);
          return resource;
        };

        ctr.prototype._sendRequest = function (opts) {
          var url, self = this, flags = opts.flags || {};

          if (!this._hasLink(opts.relation)) {
            return $q.when(emptyResource);
          }

          url = this._extractUrl(this._links[opts.relation], opts.uriParams);

          return $http({
            method: opts.method,
            url: url,
            data: opts.data,
            headers: {
              'Accept': 'application/hal+json'
            }
          }).then(function (response) {
            var data;
            data = response.data;
            self._createSelfLinkIfNeeded(data, url);
            return makeResource(data, response.status);
          }, function (response) {
            var resource, handler, data;
            data = response.data;
            self._createSelfLinkIfNeeded(data, url);
            resource = makeResource(data, response.status);
            if (options.errorHandler && !flags.disableResponseHandler) {
              handler = $injector.get(options.errorHandler);
              return handler(resource);
            }
            if (flags.skipErrors) {
              return resource;
            }
            return $q.reject(resource);
          });
        };

        ctr.prototype._createSelfLinkIfNeeded = function (data, url) {
          if (!data._links) {
            data._links = {};
          }
          if (!data._links.self) {
            angular.extend(data._links, { self: { href: url } });
          }
        };

        ctr.prototype._hasEmbedded = function (relation) {
          return this._embedded.hasOwnProperty(relation);
        };

        ctr.prototype._hasLink = function (relation) {
          return this._links.hasOwnProperty(relation);
        };

        ctr.prototype._extractUrl = function (link, params) {
          /* global UriTemplate */
          var url = link.href,
            tpl = new UriTemplate(url);
          return link.templated ? tpl.fillFromObject(params) : url;
        };

        return ctr;
      }());

      emptyResource = new Resource();
      Object.freeze(emptyResource);


      function makeResource(_data_, status) {
        var data = angular.copy(_data_) || {},
          resource = new Resource(status || 200);

        angular.extend(resource, data);
        if (angular.isUndefined(resource._embedded)) {
          resource._embedded = {};
        }

        Object.freeze(resource);
        return resource;
      }

      return makeResource({ _links: { self: { href: options.apiEndpoint } } });
    };
  });