diff --git a/dist/hypermedia.js b/dist/hypermedia.js index c7ff0d6..6a029e7 100644 --- a/dist/hypermedia.js +++ b/dist/hypermedia.js @@ -197,6 +197,28 @@ angular.module('hypermedia') }); }}, + /** + * Perform a HTTP PATCH request. + * + * @function + * @param {Resource} resource + * @returns a promise that is resolved to the resource + * @see Resource#$patchRequest + */ + httpPatch: {value: function (resource, data) { + var self = this; + busyRequests += 1; + var request = updateHttp(resource.$patchRequest(data)); + return $http(request).then(function () { + Resource.prototype.$merge.call(resource, request.data); + return self.markSynced(resource, Date.now()); + }).then(function () { + return resource; + }).finally(function () { + busyRequests -= 1; + }); + }}, + /** * Perform a HTTP DELETE request and unmark the resource as synchronized. * @@ -723,6 +745,31 @@ angular.module('hypermedia') return this.$context.httpPut(this); }}, + /** + * Create a $http PATCH request configuration object. + * + * @function + * @returns {object} + */ + $patchRequest: {value: function (data) { + return { + method: 'patch', + url: this.$uri, + data: data, + headers: {'Content-Type': 'application/json'} + }; + }}, + + /** + * Perform an HTTP PATCH request with the resource state. + * + * @function + * @returns a promise that is resolved to the resource + */ + $patch: {value: function () { + return this.$context.httpPatch(this); + }}, + /** * Create a $http DELETE request configuration object. * @@ -812,6 +859,27 @@ angular.module('hypermedia') if (profileUris) this.$profile = profileUris; return this; + }}, + + /** + * Merges the resource with new data following algorithm defined + * in JSON Merge Patch specification (Rfc 7386, https://tools.ietf.org/html/rfc7386). + * + * @function + * @param {object} data + * @param {object} [links] + * @returns the resource + */ + $merge: {value: function (data){ + var self = this; + Object.keys(data).forEach(function(key){ + if (data[key] === null) { + delete self[key]; + } else { + self[key] = data[key]; + } + return self; + }); }} }); diff --git a/gulpfile.js b/gulpfile.js index b874cd1..4c24699 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -4,12 +4,13 @@ var gulp = require('gulp'), batch = require('gulp-batch'), concat = require('gulp-concat'), ignore = require('gulp-ignore'), + jshint = require('gulp-jshint'), watch = require('gulp-watch'), path = require('path'); var dist = 'dist/hypermedia.js'; -gulp.task('default', function(){ +gulp.task('default', ['jshint'], function(){ var distDir = path.dirname(dist); return gulp.src('src/*.js') .pipe(ignore.exclude('*.spec.js')) @@ -23,3 +24,10 @@ gulp.task('watch', function () { gulp.start('default', done); })); }); + +gulp.task('jshint', function () { + return gulp.src(['src/*.js']) + .pipe(jshint('./.jshintrc')) + .pipe(jshint.reporter('jshint-stylish')) + .pipe(jshint.reporter('fail')); +}); diff --git a/package.json b/package.json index 2a2874d..c5f9a2e 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,16 @@ "gulp": "^3.9", "gulp-batch": "^1.0", "gulp-concat": "^2.6", + "gulp-if": "^2.0.0", "gulp-ignore": "^1.2", + "gulp-jshint": "^1.11.2", "gulp-watch": "^4.3", + "jasmine-core": "^2.3.4", "jshint": "^2.8", + "jshint-stylish": "^2.0.1", "karma": "^0.13", "karma-coverage": "^0.4", - "karma-jasmine": "^0.3", + "karma-jasmine": "^0.3.6", "karma-phantomjs-launcher": "^0.2" }, "scripts": { diff --git a/src/context.js b/src/context.js index 7a0379c..711355a 100644 --- a/src/context.js +++ b/src/context.js @@ -110,6 +110,28 @@ angular.module('hypermedia') }); }}, + /** + * Perform a HTTP PATCH request. + * + * @function + * @param {Resource} resource + * @returns a promise that is resolved to the resource + * @see Resource#$patchRequest + */ + httpPatch: {value: function (resource, data) { + var self = this; + busyRequests += 1; + var request = updateHttp(resource.$patchRequest(data)); + return $http(request).then(function () { + Resource.prototype.$merge.call(resource, request.data); + return self.markSynced(resource, Date.now()); + }).then(function () { + return resource; + }).finally(function () { + busyRequests -= 1; + }); + }}, + /** * Perform a HTTP DELETE request and unmark the resource as synchronized. * diff --git a/src/context.spec.js b/src/context.spec.js index e97683a..c3abe20 100644 --- a/src/context.spec.js +++ b/src/context.spec.js @@ -75,6 +75,18 @@ describe('ResourceContext', function () { expect(resource.$syncTime / 10).toBeCloseTo(Date.now() / 10, 0); }); + it('performs HTTP PATCH requests', function () { + var promiseResult = null; + var data = {}; + context.httpPatch(resource, data).then(function (result) { promiseResult = result; }); + $httpBackend.expectPATCH(resource.$uri, data, + {'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/merge-patch+json'}) + .respond(204); + $httpBackend.flush(); + expect(promiseResult).toBe(resource); + expect(resource.$syncTime / 10).toBeCloseTo(Date.now() / 10, 0); + }); + it('performs HTTP DELETE requests', function () { var promiseResult = null; resource.$syncTime = 1; diff --git a/src/resource.js b/src/resource.js index e83c530..53956bd 100644 --- a/src/resource.js +++ b/src/resource.js @@ -308,6 +308,31 @@ angular.module('hypermedia') return this.$context.httpPut(this); }}, + /** + * Create a $http PATCH request configuration object. + * + * @function + * @returns {object} + */ + $patchRequest: {value: function (data) { + return { + method: 'patch', + url: this.$uri, + data: data, + headers: {'Content-Type': 'application/merge-patch+json'} + }; + }}, + + /** + * Perform an HTTP PATCH request with the resource state. + * + * @function + * @returns a promise that is resolved to the resource + */ + $patch: {value: function () { + return this.$context.httpPatch(this); + }}, + /** * Create a $http DELETE request configuration object. * @@ -397,6 +422,40 @@ angular.module('hypermedia') if (profileUris) this.$profile = profileUris; return this; + }}, + + /** + * Merges the resource with new data following algorithm defined + * in JSON Merge Patch specification (Rfc 7386, https://tools.ietf.org/html/rfc7386). + * + * @function + * @param {object} data + * @param {object} [links] + * @returns the resource + */ + $merge: {value: function (data){ + var mergePatch = function(target, patch){ + if (!angular.isObject(patch) || patch === null || Array.isArray(patch)) { + return patch; + } + + if (!angular.isObject(target) || target === null || Array.isArray(target)) { + target = {}; + } + + Object.keys(patch).forEach(function (key) { + var value = patch[key]; + if (value === null) { + delete target[key]; + } else { + target[key] = mergePatch(target[key], value); + } + }); + + return target; + }; + + return mergePatch(this, data); }} }); diff --git a/src/resource.spec.js b/src/resource.spec.js index 9802336..7ba584c 100644 --- a/src/resource.spec.js +++ b/src/resource.spec.js @@ -12,7 +12,7 @@ describe('Resource', function () { $q = _$q_; $rootScope = _$rootScope_; Resource = _Resource_; - mockContext = jasmine.createSpyObj('mockContext', ['get', 'httpGet', 'httpPut', 'httpDelete', 'httpPost']); + mockContext = jasmine.createSpyObj('mockContext', ['get', 'httpGet', 'httpPut', 'httpDelete', 'httpPost', 'httpPatch']); uri = 'http://example.com'; resource = new Resource(uri, mockContext); })); @@ -283,6 +283,21 @@ describe('Resource', function () { expect(mockContext.httpPut).toHaveBeenCalledWith(resource); }); + it('creates HTTP PATCH request', function () { + var data = {}; + expect(resource.$patchRequest(data)).toEqual({ + method: 'patch', + url: 'http://example.com', + data: data, + headers: {'Content-Type': 'application/merge-patch+json'} + }); + }); + + it('delegates HTTP PATCH request to the context', function () { + resource.$patch(); + expect(mockContext.httpPatch).toHaveBeenCalledWith(resource); + }); + it('creates HTTP DELETE request', function () { expect(resource.$deleteRequest()).toEqual({ method: 'delete', @@ -317,9 +332,9 @@ describe('Resource', function () { // Updates it('updates state, links and profile', function () { - resource.oldVar = 'test'; + resource.aVar = 'test'; resource.$update({foo: 'bar'}, {profile: {href: 'http://example.com/profile'}}); - expect(resource.oldVar).toBeUndefined(); + expect(resource.aVar).toBeUndefined(); expect(resource.foo).toBe('bar'); expect(resource.$links).toEqual({profile: {href: 'http://example.com/profile'}}); expect(resource.$profile).toBe('http://example.com/profile'); @@ -331,4 +346,33 @@ describe('Resource', function () { resource.$update({foo: 'qux'}, {self: {href: 'http://example.com/other'}}); }).toThrowError('Self link href differs: expected "http://example.com", was "http://example.com/other"'); }); + + // Merges + + it('merges state', function () { + resource.aVar = 'foo'; + resource.bVar = 'joe'; + resource.$merge({aVar: null, bVar: 'john', newVar: 'bar'}); + + expect(resource.aVar).toBeUndefined(); + expect(resource.bVar).toBe('john'); + expect(resource.newVar).toBe('bar'); + }); + + it('merges nested state', function () { + resource.nested = { + aVar: 'foo', + bVar: 'joe' + }; + resource.$merge({nested: { + aVar: null, + bVar: 'john', + newVar: 'bar' + }}); + + expect(resource.nested.aVar).toBeUndefined(); + expect(resource.nested.bVar).toBe('john'); + expect(resource.nested.newVar).toBe('bar'); + }); + });