From 782c5b1b24a8abaa9f6b5919dde7d03f00d253a4 Mon Sep 17 00:00:00 2001 From: Mike North Date: Wed, 29 Jul 2015 13:56:32 -0700 Subject: [PATCH] [BUGFIX release] Fixes #3603 - Validate JSON API documents returned by serializer#normalizeResponse --- .../lib/system/store/serializer-response.js | 65 +++++- .../store/json-api-validation-test.js | 185 ++++++++++++++++++ 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 packages/ember-data/tests/integration/store/json-api-validation-test.js diff --git a/packages/ember-data/lib/system/store/serializer-response.js b/packages/ember-data/lib/system/store/serializer-response.js index 7ccf99a1bdf..d97c18f08d0 100644 --- a/packages/ember-data/lib/system/store/serializer-response.js +++ b/packages/ember-data/lib/system/store/serializer-response.js @@ -2,6 +2,65 @@ import Model from 'ember-data/system/model/model'; const get = Ember.get; +/** + This is a helper method that validates a JSON API top-level document + + The format of a document is described here: + http://jsonapi.org/format/#document-top-level + + @method validateDocumentStructure + @param {Object} doc JSON API document + @return {array} An array of errors found in the document structure +*/ +export function validateDocumentStructure(doc) { + let errors = []; + if (!doc || typeof doc !== 'object') { + errors.push('Top level of a JSON API document must be an object'); + } else { + if (!('data' in doc) && + !('errors' in doc) && + !('meta' in doc)) { + errors.push('One or more of the following keys must be present: "data", "errors", "meta".'); + } else { + if (('data' in doc) && ('errors' in doc)) { + errors.push('Top level keys "errors" and "data" cannot both be present in a JSON API document'); + } + } + if ('data' in doc) { + if (!(doc.data === null || Ember.isArray(doc.data) || typeof doc.data === 'object')) { + errors.push('data must be null, an object, or an array'); + } + } + if ('meta' in doc) { + if (typeof doc.meta !== 'object') { + errors.push('meta must be an object'); + } + } + if ('errors' in doc) { + if (!Ember.isArray(doc.errors)) { + errors.push('errors must be an array'); + } + } + if ('links' in doc) { + if (typeof doc.links !== 'object') { + errors.push('links must be an object'); + } + } + if ('jsonapi' in doc) { + if (typeof doc.jsonapi !== 'object') { + errors.push('jsonapi must be an object'); + } + } + if ('included' in doc) { + if (typeof doc.included !== 'object') { + errors.push('included must be an array'); + } + } + } + + return errors; +} + /** This is a helper method that always returns a JSON-API Document. @@ -16,7 +75,11 @@ const get = Ember.get; */ export function normalizeResponseHelper(serializer, store, modelClass, payload, id, requestType) { let normalizedResponse = serializer.normalizeResponse(store, modelClass, payload, id, requestType); - + let validationErrors = []; + Ember.runInDebug(() => { + validationErrors = validateDocumentStructure(normalizedResponse); + }); + Ember.assert(`normalizeResponse must return a valid JSON API document:\n\t* ${validationErrors.join('\n\t* ')}`, Ember.isEmpty(validationErrors)); // TODO: Remove after metadata refactor if (normalizedResponse.meta) { store._setMetadataFor(modelClass.modelName, normalizedResponse.meta); diff --git a/packages/ember-data/tests/integration/store/json-api-validation-test.js b/packages/ember-data/tests/integration/store/json-api-validation-test.js new file mode 100644 index 00000000000..b65808622b7 --- /dev/null +++ b/packages/ember-data/tests/integration/store/json-api-validation-test.js @@ -0,0 +1,185 @@ +var Person, store, env; +var run = Ember.run; + +module("integration/store/json-validation", { + setup: function() { + Person = DS.Model.extend({ + updatedAt: DS.attr('string'), + name: DS.attr('string'), + firstName: DS.attr('string'), + lastName: DS.attr('string') + }); + + env = setupStore({ + person: Person + }); + store = env.store; + }, + + teardown: function() { + run(store, 'destroy'); + } +}); + +test("when normalizeResponse returns undefined (or doesn't return), throws an error", function() { + + env.registry.register('serializer:person', DS.Serializer.extend({ + normalizeResponse() {} + })); + + env.registry.register('adapter:person', DS.Adapter.extend({ + findRecord() { + return Ember.RSVP.resolve({}); + } + })); + + throws(function () { + run(function() { + store.find('person', 1); + }); + }, /Top level of a JSON API document must be an object/); +}); + +test("when normalizeResponse returns null, throws an error", function() { + + env.registry.register('serializer:person', DS.Serializer.extend({ + normalizeResponse() {return null;} + })); + + env.registry.register('adapter:person', DS.Adapter.extend({ + findRecord() { + return Ember.RSVP.resolve({}); + } + })); + + throws(function () { + run(function() { + store.find('person', 1); + }); + }, /Top level of a JSON API document must be an object/); +}); + + +test("when normalizeResponse returns an empty object, throws an error", function() { + + env.registry.register('serializer:person', DS.Serializer.extend({ + normalizeResponse() {return {};} + })); + + env.registry.register('adapter:person', DS.Adapter.extend({ + findRecord() { + return Ember.RSVP.resolve({}); + } + })); + + throws(function () { + run(function() { + store.find('person', 1); + }); + }, /One or more of the following keys must be present/); +}); + +test("when normalizeResponse returns a document with both data and errors, throws an error", function() { + + env.registry.register('serializer:person', DS.Serializer.extend({ + normalizeResponse() { + return { + data: [], + errors: [] + }; + } + })); + + env.registry.register('adapter:person', DS.Adapter.extend({ + findRecord() { + return Ember.RSVP.resolve({}); + } + })); + + throws(function () { + run(function() { + store.find('person', 1); + }); + }, /cannot both be present/); +}); + +function testPayloadError(payload, expectedError) { + env.registry.register('serializer:person', DS.Serializer.extend({ + normalizeResponse(store, type, pld) { + return pld; + } + })); + env.registry.register('adapter:person', DS.Adapter.extend({ + findRecord() { + return Ember.RSVP.resolve(payload); + } + })); + throws(function () { + run(function() { + store.find('person', 1); + }); + }, expectedError, `Payload ${JSON.stringify(payload)} should throw error ${expectedError}`); + env.registry.unregister('serializer:person'); + env.registry.unregister('adapter:person'); +} + +test("normalizeResponse 'data' cannot be undefined, a number, a string or a boolean", function() { + + testPayloadError({ data: undefined }, /data must be/); + testPayloadError({ data: 1 }, /data must be/); + testPayloadError({ data: 'lollerskates' }, /data must be/); + testPayloadError({ data: true }, /data must be/); + +}); + +test("normalizeResponse 'meta' cannot be an array, undefined, a number, a string or a boolean", function() { + + testPayloadError({ meta: undefined }, /meta must be an object/); + testPayloadError({ meta: [] }, /meta must be an object/); + testPayloadError({ meta: 1 }, /meta must be an object/); + testPayloadError({ meta: 'lollerskates' }, /meta must be an object/); + testPayloadError({ meta: true }, /meta must be an object/); + +}); + +test("normalizeResponse 'links' cannot be an array, undefined, a number, a string or a boolean", function() { + + testPayloadError({ data: [], links: undefined }, /links must be an object/); + testPayloadError({ data: [], links: [] }, /links must be an object/); + testPayloadError({ data: [], links: 1 }, /links must be an object/); + testPayloadError({ data: [], links: 'lollerskates' }, /links must be an object/); + testPayloadError({ data: [], links: true }, /links must be an object/); + +}); + +test("normalizeResponse 'jsonapi' cannot be an array, undefined, a number, a string or a boolean", function() { + + testPayloadError({ data: [], jsonapi: undefined }, /jsonapi must be an object/); + testPayloadError({ data: [], jsonapi: [] }, /jsonapi must be an object/); + testPayloadError({ data: [], jsonapi: 1 }, /jsonapi must be an object/); + testPayloadError({ data: [], jsonapi: 'lollerskates' }, /jsonapi must be an object/); + testPayloadError({ data: [], jsonapi: true }, /jsonapi must be an object/); + +}); + +test("normalizeResponse 'included' cannot be an object, undefined, a number, a string or a boolean", function() { + + testPayloadError({ included: undefined }, /included must be an array/); + testPayloadError({ included: {} }, /included must be an array/); + testPayloadError({ included: 1 }, /included must be an array/); + testPayloadError({ included: 'lollerskates' }, /included must be an array/); + testPayloadError({ included: true }, /included must be an array/); + +}); + +test("normalizeResponse 'errors' cannot be an object, undefined, a number, a string or a boolean", function() { + + testPayloadError({ errors: undefined }, /errors must be an array/); + testPayloadError({ errors: {} }, /errors must be an array/); + testPayloadError({ errors: 1 }, /errors must be an array/); + testPayloadError({ errors: 'lollerskates' }, /errors must be an array/); + testPayloadError({ errors: true }, /errors must be an array/); + +}); + +