diff --git a/dist/hypermedia.js b/dist/hypermedia.js index 66d73d5..3fcd8a8 100644 --- a/dist/hypermedia.js +++ b/dist/hypermedia.js @@ -96,6 +96,10 @@ angular.module('hypermedia') angular.module('hypermedia') + .config(function ($httpProvider) { + $httpProvider.interceptors.push('errorInterceptor'); + }) + /** * @ngdoc type * @name ResourceContext @@ -104,7 +108,7 @@ angular.module('hypermedia') * Context for working with hypermedia resources. The context has methods * for making HTTP requests and acts as an identity map. */ - .factory('ResourceContext', ['$http', '$log', '$q', 'Resource', function ($http, $log, $q, Resource) { + .factory('ResourceContext', ['$http', '$log', '$q', 'Resource', 'errorInterceptor', function ($http, $log, $q, Resource, errorInterceptor) { var busyRequests = 0; @@ -300,6 +304,10 @@ angular.module('hypermedia') */ busyRequests: {get: function () { return busyRequests; + }}, + + registerErrorHandler: {value: function (contentType, handler) { + errorInterceptor.registerErrorHandler(contentType, handler); }} }); @@ -323,6 +331,22 @@ angular.module('hypermedia') } }]) + .factory('errorInterceptor', function ($q) { + var handlers; + return { + 'responseError': function (response) { + var contentType = response.headers('Content-Type'); + var handler = handlers[contentType]; + response.error = (handler ? handler(response) : {message: response.statusText}); + return $q.reject(response); + }, + 'registerErrorHandler': function (contentType, handler) { + if (!handlers) handlers = {}; + handlers[contentType] = handler; + } + }; + }) + ; /** @@ -985,3 +1009,38 @@ angular.module('hypermedia') }) ; + +'use strict'; + +angular.module('hypermedia') + + .run(function ($q, ResourceContext, VndError) { + var vndErrorHandler = function (response) { + return new VndError(response.data); + }; + + ResourceContext.registerErrorHandler('application/vnd+error', vndErrorHandler); + }) + + .factory('VndError', function () { + var HalError = function (data) { + var self = this; + this.message = data.message; + this.errors = []; + + var embeds = (data._embedded ? data._embedded.errors : undefined); + if (embeds) { + if (!Array.isArray(embeds)) { + embeds = [embeds]; + } + embeds.forEach(function (embed) { + self.errors.push(new HalError(embed)); + }); + } + }; + + return HalError; + }) + + +; diff --git a/src/context.js b/src/context.js index 711355a..aecfd54 100644 --- a/src/context.js +++ b/src/context.js @@ -2,6 +2,10 @@ angular.module('hypermedia') + .config(function ($httpProvider) { + $httpProvider.interceptors.push('errorInterceptor'); + }) + /** * @ngdoc type * @name ResourceContext @@ -10,7 +14,7 @@ angular.module('hypermedia') * Context for working with hypermedia resources. The context has methods * for making HTTP requests and acts as an identity map. */ - .factory('ResourceContext', ['$http', '$log', '$q', 'Resource', function ($http, $log, $q, Resource) { + .factory('ResourceContext', ['$http', '$log', '$q', 'Resource', 'errorInterceptor', function ($http, $log, $q, Resource, errorInterceptor) { var busyRequests = 0; @@ -206,6 +210,10 @@ angular.module('hypermedia') */ busyRequests: {get: function () { return busyRequests; + }}, + + registerErrorHandler: {value: function (contentType, handler) { + errorInterceptor.registerErrorHandler(contentType, handler); }} }); @@ -229,6 +237,31 @@ angular.module('hypermedia') } }]) + /** + * @ngdoc service + * @name errorInterceptor + * @description + * + * Intercepts error from server and invokes error handler for content-type, + * or default error handler if none is found. Error with message is published + * on response under 'error' key. + */ + .factory('errorInterceptor', function ($q) { + var handlers; + return { + 'responseError': function (response) { + var contentType = response.headers('Content-Type'); + var handler = handlers[contentType]; + response.error = (handler ? handler(response) : {message: response.statusText}); + return $q.reject(response); + }, + 'registerErrorHandler': function (contentType, handler) { + if (!handlers) handlers = {}; + handlers[contentType] = handler; + } + }; + }) + ; /** diff --git a/src/context.spec.js b/src/context.spec.js index abce928..da746b4 100644 --- a/src/context.spec.js +++ b/src/context.spec.js @@ -6,11 +6,14 @@ describe('ResourceContext', function () { // Setup - var $httpBackend, ResourceContext, context, resource; + var $httpBackend, $q, ResourceContext, context, errorInterceptor, resource; + var problemJson = 'application/problem+json'; - beforeEach(inject(function (_$httpBackend_, _ResourceContext_) { + beforeEach(inject(function (_$httpBackend_, _$q_, _ResourceContext_, _errorInterceptor_) { $httpBackend = _$httpBackend_; + $q = _$q_; ResourceContext = _ResourceContext_; + errorInterceptor = _errorInterceptor_; context = new ResourceContext(); resource = context.get('http://example.com'); })); @@ -23,6 +26,61 @@ describe('ResourceContext', function () { // Tests + it('registers error handler', function () { + var func = function () {}; + spyOn(errorInterceptor, 'registerErrorHandler'); + + ResourceContext.registerErrorHandler(problemJson, func); + + expect(errorInterceptor.registerErrorHandler).toHaveBeenCalledWith(problemJson, func); + }); + + it('invokes error handler for content type', function () { + var spy = jasmine.createSpy('spy').and.callFake(function (response) { + return {}; + }); + ResourceContext.registerErrorHandler(problemJson, spy); + + context.httpGet(resource); + $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) + .respond(500, null, {'Content-Type': problemJson}); + $httpBackend.flush(); + + expect(spy).toHaveBeenCalled(); + }); + + it('rejects response with error if no matching error handler', function () { + var statusText = 'Validation error'; + var promiseResult = null; + var spy = jasmine.createSpy('spy'); + + ResourceContext.registerErrorHandler('application/json', spy); + context.httpGet(resource).catch(function (result) { + promiseResult = result; + }); + $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) + .respond(500, {}, {'Content-Type': problemJson}, statusText); + $httpBackend.flush(); + + expect(spy).not.toHaveBeenCalled(); + expect(promiseResult.error.message).toBe(statusText); + expect(promiseResult.status).toBe(500); + }); + + it('invokes default error handler for content type "application/vnd+error"', function () { + var promiseResult; + var msg = 'Validatie fout'; + context.httpGet(resource).catch(function (result) { + promiseResult = result; + }); + $httpBackend.expectGET(resource.$uri, {'Accept': 'application/json'}) + .respond(500, {message: msg}, {'Content-Type': 'application/vnd+error'}); + $httpBackend.flush(); + + expect(promiseResult.error).toBeDefined(); + expect(promiseResult.error.message).toBe(msg); + }); + it('creates unique resources', function () { expect(context.get('http://example.com')).toBe(resource); expect(context.get('http://example.com/other')).not.toBe(resource); diff --git a/src/vnderror.js b/src/vnderror.js new file mode 100644 index 0000000..3a75446 --- /dev/null +++ b/src/vnderror.js @@ -0,0 +1,42 @@ +'use strict'; + +angular.module('hypermedia') + + .run(function ($q, ResourceContext, VndError) { + var vndErrorHandler = function (response) { + return new VndError(response.data); + }; + + ResourceContext.registerErrorHandler('application/vnd+error', vndErrorHandler); + }) + + /** + * @ngdoc type + * @name VndError + * @description + * + * VndError represents errors from server with content type 'application/vnd+error', + * see: https://github.com/blongden/vnd.error + */ + .factory('VndError', function () { + var HalError = function (data) { + var self = this; + this.message = data.message; + this.errors = []; + + var embeds = (data._embedded ? data._embedded.errors : undefined); + if (embeds) { + if (!Array.isArray(embeds)) { + embeds = [embeds]; + } + embeds.forEach(function (embed) { + self.errors.push(new HalError(embed)); + }); + } + }; + + return HalError; + }) + + +; diff --git a/src/vnderror.spec.js b/src/vnderror.spec.js new file mode 100644 index 0000000..3250856 --- /dev/null +++ b/src/vnderror.spec.js @@ -0,0 +1,29 @@ +'use strict'; + +describe('HalError', function () { + beforeEach(module('hypermedia')); + + var VndError; + + beforeEach(inject(function (_VndError_) { + VndError = _VndError_; + })); + + it('can be constructed with embedded errors', function () { + var data = { + 'message': 'Validatie fout', + '_links': {'profile': {'href': 'http://nocarrier.co.uk/profiles/vnd.error/'}}, + '_embedded': { + 'errors': { + 'message': 'Invalide nummer', + '_links': {'profile': {'href': 'http://nocarrier.co.uk/profiles/vnd.error/'}} + } + } + }; + + var error = new VndError(data); + + expect(error.message).toBe('Validatie fout'); + expect(error.errors.length).toBe(1); + }); +});