From 0e2d89ac0b1342a00955c6ed86bea68fc9d60a01 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 12:04:58 +0200 Subject: [PATCH 01/10] Fix karma config --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a2874d..d97329f 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,11 @@ "gulp-concat": "^2.6", "gulp-ignore": "^1.2", "gulp-watch": "^4.3", + "jasmine-core": "^2.3.4", "jshint": "^2.8", "karma": "^0.13", "karma-coverage": "^0.4", - "karma-jasmine": "^0.3", + "karma-jasmine": "^0.3.6", "karma-phantomjs-launcher": "^0.2" }, "scripts": { From 86d064275b29b77d9497a58b2f9f37424dee3d61 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 13:11:41 +0200 Subject: [PATCH 02/10] Implements patch --- src/context.js | 22 +++++++++++++++++++++ src/resource.js | 46 ++++++++++++++++++++++++++++++++++++++++++++ src/resource.spec.js | 30 ++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/context.js b/src/context.js index 7a0379c..d104ef8 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) { + var self = this; + busyRequests += 1; + var request = updateHttp(resource.$patchRequest()); + 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/resource.js b/src/resource.js index e83c530..d8a5566 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/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,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 (key in self && data[key] === null) { + delete self[key]; + } else { + self[key] = data[key]; + } + return self; + }) }} }); diff --git a/src/resource.spec.js b/src/resource.spec.js index 9802336..bc58312 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/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', @@ -331,4 +346,17 @@ 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.oldVar = 'foo'; + resource.anotherVar = 'joe'; + resource.$merge({oldVar: null, newVar: 'bar', anotherVar: 'john'}); + + expect(resource.oldVar).toBeUndefined(); + expect(resource.newVar).toBe('bar'); + expect(resource.anotherVar).toBe('john'); + }); + }); From f6ce045d5ca23b9e64ffe075a99fb2118fc8e8b7 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 13:14:42 +0200 Subject: [PATCH 03/10] Make syntax more dens --- src/resource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resource.js b/src/resource.js index d8a5566..8c2625f 100644 --- a/src/resource.js +++ b/src/resource.js @@ -436,7 +436,7 @@ angular.module('hypermedia') $merge: {value: function (data){ var self = this; Object.keys(data).forEach(function(key){ - if (key in self && data[key] === null) { + if (data[key] === null) { delete self[key]; } else { self[key] = data[key]; From 01cc8fa602aef31ac33fd03bcaaff870c0eec623 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 13:14:42 +0200 Subject: [PATCH 04/10] Make syntax more dense --- src/resource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resource.js b/src/resource.js index d8a5566..8c2625f 100644 --- a/src/resource.js +++ b/src/resource.js @@ -436,7 +436,7 @@ angular.module('hypermedia') $merge: {value: function (data){ var self = this; Object.keys(data).forEach(function(key){ - if (key in self && data[key] === null) { + if (data[key] === null) { delete self[key]; } else { self[key] = data[key]; From e919a49b76e255f7426c23709fc1aac4b5aa180c Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 13:18:33 +0200 Subject: [PATCH 05/10] Fix jshint --- src/resource.js | 2 +- src/resource.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resource.js b/src/resource.js index 8c2625f..96c39f5 100644 --- a/src/resource.js +++ b/src/resource.js @@ -442,7 +442,7 @@ angular.module('hypermedia') self[key] = data[key]; } return self; - }) + }); }} }); diff --git a/src/resource.spec.js b/src/resource.spec.js index bc58312..1886e72 100644 --- a/src/resource.spec.js +++ b/src/resource.spec.js @@ -290,7 +290,7 @@ describe('Resource', function () { url: 'http://example.com', data: data, headers: {'Content-Type': 'application/json'} - }) + }); }); it('delegates HTTP PATCH request to the context', function () { From bec1c5ba8076336a3982627e2c6dca5b8fe23e3a Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 13:25:56 +0200 Subject: [PATCH 06/10] Run jshint from gulp --- gulpfile.js | 9 +++++++++ package.json | 3 +++ 2 files changed, 12 insertions(+) diff --git a/gulpfile.js b/gulpfile.js index b874cd1..48fc7b7 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -4,6 +4,8 @@ var gulp = require('gulp'), batch = require('gulp-batch'), concat = require('gulp-concat'), ignore = require('gulp-ignore'), + gulpIf = require('gulp-if'), + jshint = require('gulp-jshint'), watch = require('gulp-watch'), path = require('path'); @@ -23,3 +25,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 d97329f..c5f9a2e 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ "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.6", From 05355d74f6efc9a1854c3990cc63f3152ef9bcb8 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Mon, 12 Oct 2015 14:56:39 +0200 Subject: [PATCH 07/10] Adds unit test for context.httpPatch method --- src/context.js | 4 ++-- src/context.spec.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/context.js b/src/context.js index d104ef8..711355a 100644 --- a/src/context.js +++ b/src/context.js @@ -118,10 +118,10 @@ angular.module('hypermedia') * @returns a promise that is resolved to the resource * @see Resource#$patchRequest */ - httpPatch: {value: function (resource) { + httpPatch: {value: function (resource, data) { var self = this; busyRequests += 1; - var request = updateHttp(resource.$patchRequest()); + var request = updateHttp(resource.$patchRequest(data)); return $http(request).then(function () { Resource.prototype.$merge.call(resource, request.data); return self.markSynced(resource, Date.now()); diff --git a/src/context.spec.js b/src/context.spec.js index e97683a..6a99e2d 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/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; From d1d11890f1a27aa4af392b282571e5e93785b986 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Tue, 13 Oct 2015 10:43:04 +0200 Subject: [PATCH 08/10] Add jshint to default task --- dist/hypermedia.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++ gulpfile.js | 3 +- 2 files changed, 69 insertions(+), 2 deletions(-) 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 48fc7b7..4c24699 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -4,14 +4,13 @@ var gulp = require('gulp'), batch = require('gulp-batch'), concat = require('gulp-concat'), ignore = require('gulp-ignore'), - gulpIf = require('gulp-if'), 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')) From acdc4cbef93c039a97816ac8372393f224eb4426 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Tue, 13 Oct 2015 10:44:53 +0200 Subject: [PATCH 09/10] Fix content type for patch request Content type is application/merge-patch+json --- src/context.spec.js | 2 +- src/resource.js | 2 +- src/resource.spec.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/context.spec.js b/src/context.spec.js index 6a99e2d..c3abe20 100644 --- a/src/context.spec.js +++ b/src/context.spec.js @@ -80,7 +80,7 @@ describe('ResourceContext', function () { var data = {}; context.httpPatch(resource, data).then(function (result) { promiseResult = result; }); $httpBackend.expectPATCH(resource.$uri, data, - {'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json'}) + {'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/merge-patch+json'}) .respond(204); $httpBackend.flush(); expect(promiseResult).toBe(resource); diff --git a/src/resource.js b/src/resource.js index 96c39f5..811fa8e 100644 --- a/src/resource.js +++ b/src/resource.js @@ -319,7 +319,7 @@ angular.module('hypermedia') method: 'patch', url: this.$uri, data: data, - headers: {'Content-Type': 'application/json'} + headers: {'Content-Type': 'application/merge-patch+json'} }; }}, diff --git a/src/resource.spec.js b/src/resource.spec.js index 1886e72..e77b3b4 100644 --- a/src/resource.spec.js +++ b/src/resource.spec.js @@ -289,7 +289,7 @@ describe('Resource', function () { method: 'patch', url: 'http://example.com', data: data, - headers: {'Content-Type': 'application/json'} + headers: {'Content-Type': 'application/merge-patch+json'} }); }); From 61fab8ebde805f645f84b55023df0beeb91f1a83 Mon Sep 17 00:00:00 2001 From: Thomas Delnoij Date: Tue, 13 Oct 2015 11:28:52 +0200 Subject: [PATCH 10/10] Merge patch recursively --- src/resource.js | 29 +++++++++++++++++++++-------- src/resource.spec.js | 30 +++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/resource.js b/src/resource.js index 811fa8e..53956bd 100644 --- a/src/resource.js +++ b/src/resource.js @@ -434,15 +434,28 @@ angular.module('hypermedia') * @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]; + var mergePatch = function(target, patch){ + if (!angular.isObject(patch) || patch === null || Array.isArray(patch)) { + return patch; } - return self; - }); + + 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 e77b3b4..7ba584c 100644 --- a/src/resource.spec.js +++ b/src/resource.spec.js @@ -332,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'); @@ -350,13 +350,29 @@ describe('Resource', function () { // Merges it('merges state', function () { - resource.oldVar = 'foo'; - resource.anotherVar = 'joe'; - resource.$merge({oldVar: null, newVar: 'bar', anotherVar: 'john'}); + resource.aVar = 'foo'; + resource.bVar = 'joe'; + resource.$merge({aVar: null, bVar: 'john', newVar: 'bar'}); - expect(resource.oldVar).toBeUndefined(); + expect(resource.aVar).toBeUndefined(); + expect(resource.bVar).toBe('john'); expect(resource.newVar).toBe('bar'); - expect(resource.anotherVar).toBe('john'); + }); + + 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'); }); });