diff --git a/dist/mobx-async-store.cjs.js b/dist/mobx-async-store.cjs.js index 91169931..71299b09 100644 --- a/dist/mobx-async-store.cjs.js +++ b/dist/mobx-async-store.cjs.js @@ -15,6 +15,10 @@ var _applyDecoratedDescriptor = _interopDefault(require('@babel/runtime/helpers/ require('@babel/runtime/helpers/initializerWarningHelper'); var _typeof = _interopDefault(require('@babel/runtime/helpers/typeof')); var mobx = require('mobx'); +var _inherits = _interopDefault(require('@babel/runtime/helpers/inherits')); +var _possibleConstructorReturn = _interopDefault(require('@babel/runtime/helpers/possibleConstructorReturn')); +var _getPrototypeOf = _interopDefault(require('@babel/runtime/helpers/getPrototypeOf')); +var _wrapNativeSuper = _interopDefault(require('@babel/runtime/helpers/wrapNativeSuper')); var uuidv1 = _interopDefault(require('uuid/v1')); var qs = _interopDefault(require('qs')); var pluralize = _interopDefault(require('pluralize')); @@ -24,11 +28,7 @@ var cloneDeep = _interopDefault(require('lodash/cloneDeep')); var _isEqual = _interopDefault(require('lodash/isEqual')); var isObject = _interopDefault(require('lodash/isObject')); var findLast = _interopDefault(require('lodash/findLast')); -var _possibleConstructorReturn = _interopDefault(require('@babel/runtime/helpers/possibleConstructorReturn')); -var _getPrototypeOf = _interopDefault(require('@babel/runtime/helpers/getPrototypeOf')); var _assertThisInitialized = _interopDefault(require('@babel/runtime/helpers/assertThisInitialized')); -var _inherits = _interopDefault(require('@babel/runtime/helpers/inherits')); -var _wrapNativeSuper = _interopDefault(require('@babel/runtime/helpers/wrapNativeSuper')); var QueryString = { parse: function parse(str) { @@ -43,6 +43,7 @@ var QueryString = { } }; +function _wrapRegExp(re, groups) { _wrapRegExp = function _wrapRegExp(re, groups) { return new BabelRegExp(re, undefined, groups); }; var _RegExp = _wrapNativeSuper(RegExp); var _super = RegExp.prototype; var _groups = new WeakMap(); function BabelRegExp(re, flags, groups) { var _this = _RegExp.call(this, re, flags); _groups.set(_this, groups || _groups.get(re)); return _this; } _inherits(BabelRegExp, _RegExp); BabelRegExp.prototype.exec = function (str) { var result = _super.exec.call(this, str); if (result) result.groups = buildGroups(result, this); return result; }; BabelRegExp.prototype[Symbol.replace] = function (str, substitution) { if (typeof substitution === "string") { var groups = _groups.get(this); return _super[Symbol.replace].call(this, str, substitution.replace(/\$<([^>]+)>/g, function (_, name) { return "$" + groups[name]; })); } else if (typeof substitution === "function") { var _this = this; return _super[Symbol.replace].call(this, str, function () { var args = []; args.push.apply(args, arguments); if (_typeof(args[args.length - 1]) !== "object") { args.push(buildGroups(args, _this)); } return substitution.apply(this, args); }); } else { return _super[Symbol.replace].call(this, str, substitution); } }; function buildGroups(result, re) { var g = _groups.get(re); return Object.keys(g).reduce(function (groups, name) { groups[name] = result[g[name]]; return groups; }, Object.create(null)); } return _wrapRegExp.apply(this, arguments); } var pending = {}; var counter = {}; var URL_MAX_LENGTH = 1024; @@ -230,20 +231,50 @@ function diff() { }); } /** - * A naive way of extracting errors from the server. - * This needs some real work. Please don't track down the original author - * of the code (it's DEFINITELY not the person writing this documentation). - * Currently it only extracts the message from the first error, but not only - * can multiple errors be returned, they will correspond to different records - * in the case of a bulk JSONAPI response. + * Parses the pointer of the error to retrieve the index of the + * record the error belongs to and the full path to the attribute + * which will serve as the key for the error. + * + * If there is no parsed index, then assume the payload was for + * a single record and default to 0. + * + * ex. + * error = { + * detail: "Foo can't be blank", + * source: { pointer: '/data/1/attributes/options/foo' }, + * title: 'Invalid foo' + * } * - * @method parseApiErrors - * @param {Array} a request to the API - * @param {String} default error message + * parsePointer(error) + * > { + * index: 1, + * key: 'options.foo' + * } + * + * @method parseErrorPointer + * @param {Object} error + * @return {Object} the matching parts of the pointer */ -function parseApiErrors(errors, defaultMessage) { - return errors[0].detail.length === 0 ? defaultMessage : errors[0].detail[0]; +function parseErrorPointer() { + var error = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + var regex = _wrapRegExp(/\/data\/([0-9]+)?\/?attributes\/(.*)$/, { + index: 1, + key: 2 + }); + + var match = dig(error, 'source.pointer', '').match(regex); + + var _ref = (match === null || match === void 0 ? void 0 : match.groups) || {}, + _ref$index = _ref.index, + index = _ref$index === void 0 ? 0 : _ref$index, + key = _ref.key; + + return { + index: parseInt(index), + key: key === null || key === void 0 ? void 0 : key.replace(/\//g, '.') + }; } /** * Splits an array of ids into a series of strings that can be used to form @@ -2264,7 +2295,7 @@ function () { var _ref3 = _asyncToGenerator( /*#__PURE__*/ _regeneratorRuntime.mark(function _callee4(response) { - var status, json, data, included, message, _json, errorString; + var status, json, data, included, _json, errorString; return _regeneratorRuntime.wrap(function _callee4$(_context4) { while (1) { @@ -2309,41 +2340,48 @@ function () { recordsArray.forEach(function (record) { record.isInFlight = false; }); - message = _this6.genericErrorMessage; _json = {}; - _context4.prev = 17; - _context4.next = 20; + _context4.prev = 16; + _context4.next = 19; return response.json(); - case 20: + case 19: _json = _context4.sent; - message = parseApiErrors(_json.errors, message); - _context4.next = 26; + _context4.next = 25; break; - case 24: - _context4.prev = 24; - _context4.t0 = _context4["catch"](17); - - case 26: - // TODO: add all errors from the API response to the record - // also TODO: split server errors by record once the info is available from the API - recordsArray[0].errors = _objectSpread$2({}, recordsArray[0].errors, { - status: status, - base: [{ - message: message - }], - server: _json.errors + case 22: + _context4.prev = 22; + _context4.t0 = _context4["catch"](16); + return _context4.abrupt("return", Promise.reject(new Error(_this6.genericErrorMessage))); + + case 25: + // Add all errors from the API response to the record(s). + // This is done by comparing the pointer in the error to + // the request. + _json.errors.forEach(function (error) { + var _parseErrorPointer = parseErrorPointer(error), + index = _parseErrorPointer.index, + key = _parseErrorPointer.key; + + if (key != null) { + var errors = recordsArray[index].errors[key] || []; + errors.push(error); + recordsArray[index].errors[key] = errors; + } }); - errorString = JSON.stringify(recordsArray[0].errors); + + errorString = recordsArray.map(function (record) { + return JSON.stringify(record.errors); + }).join(';'); return _context4.abrupt("return", Promise.reject(new Error(errorString))); - case 29: + case 28: case "end": return _context4.stop(); } } - }, _callee4, null, [[17, 24]]); + }, _callee4, null, [[16, 22]]); })); return function (_x9) { diff --git a/dist/mobx-async-store.esm.js b/dist/mobx-async-store.esm.js index 866615b7..e2f4c50f 100644 --- a/dist/mobx-async-store.esm.js +++ b/dist/mobx-async-store.esm.js @@ -9,6 +9,10 @@ import _applyDecoratedDescriptor from '@babel/runtime/helpers/applyDecoratedDesc import '@babel/runtime/helpers/initializerWarningHelper'; import _typeof from '@babel/runtime/helpers/typeof'; import { observable, computed, set, extendObservable, reaction, transaction, toJS, action } from 'mobx'; +import _inherits from '@babel/runtime/helpers/inherits'; +import _possibleConstructorReturn from '@babel/runtime/helpers/possibleConstructorReturn'; +import _getPrototypeOf from '@babel/runtime/helpers/getPrototypeOf'; +import _wrapNativeSuper from '@babel/runtime/helpers/wrapNativeSuper'; import uuidv1 from 'uuid/v1'; import qs from 'qs'; import pluralize from 'pluralize'; @@ -18,11 +22,7 @@ import cloneDeep from 'lodash/cloneDeep'; import _isEqual from 'lodash/isEqual'; import isObject from 'lodash/isObject'; import findLast from 'lodash/findLast'; -import _possibleConstructorReturn from '@babel/runtime/helpers/possibleConstructorReturn'; -import _getPrototypeOf from '@babel/runtime/helpers/getPrototypeOf'; import _assertThisInitialized from '@babel/runtime/helpers/assertThisInitialized'; -import _inherits from '@babel/runtime/helpers/inherits'; -import _wrapNativeSuper from '@babel/runtime/helpers/wrapNativeSuper'; var QueryString = { parse: function parse(str) { @@ -37,6 +37,7 @@ var QueryString = { } }; +function _wrapRegExp(re, groups) { _wrapRegExp = function _wrapRegExp(re, groups) { return new BabelRegExp(re, undefined, groups); }; var _RegExp = _wrapNativeSuper(RegExp); var _super = RegExp.prototype; var _groups = new WeakMap(); function BabelRegExp(re, flags, groups) { var _this = _RegExp.call(this, re, flags); _groups.set(_this, groups || _groups.get(re)); return _this; } _inherits(BabelRegExp, _RegExp); BabelRegExp.prototype.exec = function (str) { var result = _super.exec.call(this, str); if (result) result.groups = buildGroups(result, this); return result; }; BabelRegExp.prototype[Symbol.replace] = function (str, substitution) { if (typeof substitution === "string") { var groups = _groups.get(this); return _super[Symbol.replace].call(this, str, substitution.replace(/\$<([^>]+)>/g, function (_, name) { return "$" + groups[name]; })); } else if (typeof substitution === "function") { var _this = this; return _super[Symbol.replace].call(this, str, function () { var args = []; args.push.apply(args, arguments); if (_typeof(args[args.length - 1]) !== "object") { args.push(buildGroups(args, _this)); } return substitution.apply(this, args); }); } else { return _super[Symbol.replace].call(this, str, substitution); } }; function buildGroups(result, re) { var g = _groups.get(re); return Object.keys(g).reduce(function (groups, name) { groups[name] = result[g[name]]; return groups; }, Object.create(null)); } return _wrapRegExp.apply(this, arguments); } var pending = {}; var counter = {}; var URL_MAX_LENGTH = 1024; @@ -224,20 +225,50 @@ function diff() { }); } /** - * A naive way of extracting errors from the server. - * This needs some real work. Please don't track down the original author - * of the code (it's DEFINITELY not the person writing this documentation). - * Currently it only extracts the message from the first error, but not only - * can multiple errors be returned, they will correspond to different records - * in the case of a bulk JSONAPI response. + * Parses the pointer of the error to retrieve the index of the + * record the error belongs to and the full path to the attribute + * which will serve as the key for the error. + * + * If there is no parsed index, then assume the payload was for + * a single record and default to 0. + * + * ex. + * error = { + * detail: "Foo can't be blank", + * source: { pointer: '/data/1/attributes/options/foo' }, + * title: 'Invalid foo' + * } * - * @method parseApiErrors - * @param {Array} a request to the API - * @param {String} default error message + * parsePointer(error) + * > { + * index: 1, + * key: 'options.foo' + * } + * + * @method parseErrorPointer + * @param {Object} error + * @return {Object} the matching parts of the pointer */ -function parseApiErrors(errors, defaultMessage) { - return errors[0].detail.length === 0 ? defaultMessage : errors[0].detail[0]; +function parseErrorPointer() { + var error = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + + var regex = _wrapRegExp(/\/data\/([0-9]+)?\/?attributes\/(.*)$/, { + index: 1, + key: 2 + }); + + var match = dig(error, 'source.pointer', '').match(regex); + + var _ref = (match === null || match === void 0 ? void 0 : match.groups) || {}, + _ref$index = _ref.index, + index = _ref$index === void 0 ? 0 : _ref$index, + key = _ref.key; + + return { + index: parseInt(index), + key: key === null || key === void 0 ? void 0 : key.replace(/\//g, '.') + }; } /** * Splits an array of ids into a series of strings that can be used to form @@ -2258,7 +2289,7 @@ function () { var _ref3 = _asyncToGenerator( /*#__PURE__*/ _regeneratorRuntime.mark(function _callee4(response) { - var status, json, data, included, message, _json, errorString; + var status, json, data, included, _json, errorString; return _regeneratorRuntime.wrap(function _callee4$(_context4) { while (1) { @@ -2303,41 +2334,48 @@ function () { recordsArray.forEach(function (record) { record.isInFlight = false; }); - message = _this6.genericErrorMessage; _json = {}; - _context4.prev = 17; - _context4.next = 20; + _context4.prev = 16; + _context4.next = 19; return response.json(); - case 20: + case 19: _json = _context4.sent; - message = parseApiErrors(_json.errors, message); - _context4.next = 26; + _context4.next = 25; break; - case 24: - _context4.prev = 24; - _context4.t0 = _context4["catch"](17); - - case 26: - // TODO: add all errors from the API response to the record - // also TODO: split server errors by record once the info is available from the API - recordsArray[0].errors = _objectSpread$2({}, recordsArray[0].errors, { - status: status, - base: [{ - message: message - }], - server: _json.errors + case 22: + _context4.prev = 22; + _context4.t0 = _context4["catch"](16); + return _context4.abrupt("return", Promise.reject(new Error(_this6.genericErrorMessage))); + + case 25: + // Add all errors from the API response to the record(s). + // This is done by comparing the pointer in the error to + // the request. + _json.errors.forEach(function (error) { + var _parseErrorPointer = parseErrorPointer(error), + index = _parseErrorPointer.index, + key = _parseErrorPointer.key; + + if (key != null) { + var errors = recordsArray[index].errors[key] || []; + errors.push(error); + recordsArray[index].errors[key] = errors; + } }); - errorString = JSON.stringify(recordsArray[0].errors); + + errorString = recordsArray.map(function (record) { + return JSON.stringify(record.errors); + }).join(';'); return _context4.abrupt("return", Promise.reject(new Error(errorString))); - case 29: + case 28: case "end": return _context4.stop(); } } - }, _callee4, null, [[17, 24]]); + }, _callee4, null, [[16, 22]]); })); return function (_x9) { diff --git a/docs/classes/Model.html b/docs/classes/Model.html index c3618ec8..726a3396 100644 --- a/docs/classes/Model.html +++ b/docs/classes/Model.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/classes/RelatedRecordsArray.html b/docs/classes/RelatedRecordsArray.html index 3579776d..486933aa 100644 --- a/docs/classes/RelatedRecordsArray.html +++ b/docs/classes/RelatedRecordsArray.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/classes/Schema.html b/docs/classes/Schema.html index b13f8145..277e23b0 100644 --- a/docs/classes/Schema.html +++ b/docs/classes/Schema.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/classes/Store.html b/docs/classes/Store.html index ff38903c..01accde0 100644 --- a/docs/classes/Store.html +++ b/docs/classes/Store.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/data.json b/docs/data.json index 3e81aa8c..b7a67e92 100644 --- a/docs/data.json +++ b/docs/data.json @@ -3,7 +3,7 @@ "name": "mobx-async-store", "description": "Asyc Data Store for mobx", "url": "https://github.com/artemis-ag/mobx-async-store", - "version": "2.0.1" + "version": "3.0.0" }, "files": { "src/decorators/attributes.js": { @@ -1706,26 +1706,25 @@ { "file": "src/utils.js", "line": 196, - "description": "A naive way of extracting errors from the server.\nThis needs some real work. Please don't track down the original author\nof the code (it's DEFINITELY not the person writing this documentation).\nCurrently it only extracts the message from the first error, but not only\ncan multiple errors be returned, they will correspond to different records\nin the case of a bulk JSONAPI response.", + "description": "Parses the pointer of the error to retrieve the index of the\nrecord the error belongs to and the full path to the attribute\nwhich will serve as the key for the error.\n\nIf there is no parsed index, then assume the payload was for\na single record and default to 0.\n\nex.\n error = {\n detail: \"Foo can't be blank\",\n source: { pointer: '/data/1/attributes/options/foo' },\n title: 'Invalid foo'\n }\n\nparsePointer(error)\n> {\n index: 1,\n key: 'options.foo'\n }", "itemtype": "method", - "name": "parseApiErrors", + "name": "parseErrorPointer", "params": [ { - "name": "a", - "description": "request to the API", - "type": "Array" - }, - { - "name": "default", - "description": "error message", - "type": "String" + "name": "error", + "description": "", + "type": "Object" } ], + "return": { + "description": "the matching parts of the pointer", + "type": "Object" + }, "class": "" }, { "file": "src/utils.js", - "line": 212, + "line": 232, "description": "Splits an array of ids into a series of strings that can be used to form\nqueries that conform to a max length of URL_MAX_LENGTH. This is to prevent 414 errors.", "itemtype": "method", "name": "deriveIdQueryStrings", diff --git a/docs/files/src_Model.js.html b/docs/files/src_Model.js.html index 366cb074..431f4ff4 100644 --- a/docs/files/src_Model.js.html +++ b/docs/files/src_Model.js.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/files/src_Store.js.html b/docs/files/src_Store.js.html index 40b12b46..5bedba13 100644 --- a/docs/files/src_Store.js.html +++ b/docs/files/src_Store.js.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
@@ -85,7 +85,7 @@

File: src/Store.js

 /* global fetch */
 import { action, observable, transaction, set, toJS } from 'mobx'
-import { dbOrNewId, parseApiErrors, requestUrl, uniqueBy, combineRacedRequests, deriveIdQueryStrings } from './utils'
+import { dbOrNewId, parseErrorPointer, requestUrl, uniqueBy, combineRacedRequests, deriveIdQueryStrings } from './utils'
 
 /**
  * Defines the Artemis Data Store class.
@@ -971,24 +971,29 @@ 

File: src/Store.js

} else { recordsArray.forEach(record => { record.isInFlight = false }) - let message = this.genericErrorMessage let json = {} try { json = await response.json() - message = parseApiErrors(json.errors, message) } catch (error) { // 500 doesn't return a parsable response + return Promise.reject(new Error(this.genericErrorMessage)) } - // TODO: add all errors from the API response to the record - // also TODO: split server errors by record once the info is available from the API - recordsArray[0].errors = { - ...recordsArray[0].errors, - status: status, - base: [{ message }], - server: json.errors - } - const errorString = JSON.stringify(recordsArray[0].errors) + // Add all errors from the API response to the record(s). + // This is done by comparing the pointer in the error to + // the request. + json.errors.forEach(error => { + const { index, key } = parseErrorPointer(error) + if (key != null) { + const errors = recordsArray[index].errors[key] || [] + errors.push(error) + recordsArray[index].errors[key] = errors + } + }) + + const errorString = recordsArray + .map(record => JSON.stringify(record.errors)) + .join(';') return Promise.reject(new Error(errorString)) } }, diff --git a/docs/files/src_decorators_attributes.js.html b/docs/files/src_decorators_attributes.js.html index 031642f4..e368e9d0 100644 --- a/docs/files/src_decorators_attributes.js.html +++ b/docs/files/src_decorators_attributes.js.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/files/src_decorators_relationships.js.html b/docs/files/src_decorators_relationships.js.html index d9d49b6b..c31f9e7b 100644 --- a/docs/files/src_decorators_relationships.js.html +++ b/docs/files/src_decorators_relationships.js.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/files/src_schema.js.html b/docs/files/src_schema.js.html index 0657e5f0..d9ac1f57 100644 --- a/docs/files/src_schema.js.html +++ b/docs/files/src_schema.js.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/docs/files/src_utils.js.html b/docs/files/src_utils.js.html index 7919769f..d4e86381 100644 --- a/docs/files/src_utils.js.html +++ b/docs/files/src_utils.js.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
@@ -279,19 +279,39 @@

File: src/utils.js

} /** - * A naive way of extracting errors from the server. - * This needs some real work. Please don't track down the original author - * of the code (it's DEFINITELY not the person writing this documentation). - * Currently it only extracts the message from the first error, but not only - * can multiple errors be returned, they will correspond to different records - * in the case of a bulk JSONAPI response. + * Parses the pointer of the error to retrieve the index of the + * record the error belongs to and the full path to the attribute + * which will serve as the key for the error. * - * @method parseApiErrors - * @param {Array} a request to the API - * @param {String} default error message + * If there is no parsed index, then assume the payload was for + * a single record and default to 0. + * + * ex. + * error = { + * detail: "Foo can't be blank", + * source: { pointer: '/data/1/attributes/options/foo' }, + * title: 'Invalid foo' + * } + * + * parsePointer(error) + * > { + * index: 1, + * key: 'options.foo' + * } + * + * @method parseErrorPointer + * @param {Object} error + * @return {Object} the matching parts of the pointer */ -export function parseApiErrors (errors, defaultMessage) { - return (errors[0].detail.length === 0) ? defaultMessage : errors[0].detail[0] +export function parseErrorPointer (error = {}) { + const regex = /\/data\/(?<index>\d+)?\/?attributes\/(?<key>.*)$/ + const match = dig(error, 'source.pointer', '').match(regex) + const { index = 0, key } = match?.groups || {} + + return { + index: parseInt(index), + key: key?.replace(/\//g, '.') + } } /** diff --git a/docs/index.html b/docs/index.html index 649806ea..76a0da2c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -17,7 +17,7 @@

- API Docs for: 2.0.1 + API Docs for: 3.0.0
diff --git a/package.json b/package.json index 435f4ce0..3bbfafd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@artemisag/mobx-async-store", - "version": "2.0.1", + "version": "3.0.0", "module": "dist/mobx-async-store.esm.js", "browser": "dist/mobx-async-store.cjs.js", "main": "dist/mobx-async-store.cjs.js", diff --git a/spec/Model.spec.js b/spec/Model.spec.js index 312e6cff..bf3548e7 100644 --- a/spec/Model.spec.js +++ b/spec/Model.spec.js @@ -1036,34 +1036,6 @@ describe('Model', () => { expect(todo.hasUnpersistedChanges).toBe(false) }) - it('includes all model errors from the server', async () => { - const note = store.add('notes', { - id: 10, - description: '' - }) - const todo = store.add('organizations', { title: 'Good title' }) - todo.notes.add(note) - - // Mock the API response - fetch.mockResponse(mockNoteWithErrorResponse, { status: 422 }) - - // Trigger the save function and subsequent request - try { - await note.save() - } catch (errors) { - // Assert that errors are set on the record object - expect(note.errors).toEqual({ - status: 422, - base: [ - { message: 'Something went wrong.' } - ], - server: { - description: ['can\'t be blank'] - } - }) - } - }) - it('does not set hasUnpersistedChanges after save fails', async () => { const note = store.add('notes', { description: '' diff --git a/spec/Store.spec.js b/spec/Store.spec.js index 950d97f4..2bdd94fa 100644 --- a/spec/Store.spec.js +++ b/spec/Store.spec.js @@ -1,4 +1,4 @@ -/* global fetch */ +/* global fetch Response */ import { isObservable, toJS } from 'mobx' import { Store, Model, attribute, relatedToOne, relatedToMany } from '../src/main' import { URL_MAX_LENGTH } from '../src/utils' @@ -297,6 +297,154 @@ describe('Store', () => { }) }) + describe('updateRecords', () => { + function mockRequest (errors) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ errors }) + process.nextTick(() => resolve( + new Response(body, { status: 422 }) + )) + }) + } + + describe('error handling', () => { + it('ignores errors without a pointer', async () => { + const todo = store.add('todos', { title: '' }) + const errors = [ + { + detail: "Title can't be blank", + title: 'Invalid title' + } + ] + + try { + await store.updateRecords(mockRequest(errors), todo) + } catch (error) { + expect(todo.errors).toEqual({}) + } + }) + + it('ignores pointers not in the jsonapi spec format', async () => { + const todo = store.add('todos', { title: '' }) + const errors = [ + { + detail: "Title can't be blank", + source: { pointer: 'attributes:title' }, + title: 'Invalid title' + } + ] + + try { + await store.updateRecords(mockRequest(errors), todo) + } catch (error) { + expect(todo.errors).toEqual({}) + } + }) + + it('adds server errors to the models', async () => { + const todo = store.add('todos', { title: '' }) + const errors = [ + { + detail: "Title can't be blank", + source: { pointer: '/data/attributes/title' }, + title: 'Invalid title' + } + ] + + try { + await store.updateRecords(mockRequest(errors), todo) + } catch (error) { + expect(todo.errors.title).toEqual(errors) + } + }) + + it('adds multiple server errors for the same attribute', async () => { + const todo = store.add('todos', { title: '' }) + const errors = [ + { + detail: "Title can't be blank", + source: { pointer: '/data/attributes/title' }, + title: 'Invalid title' + }, + { + detail: 'Title is taken', + source: { pointer: '/data/attributes/title' }, + title: 'Invalid title' + } + ] + + try { + await store.updateRecords(mockRequest(errors), todo) + } catch (error) { + expect(todo.errors.title).toEqual(errors) + } + }) + + // Note: There is no support for model validations for nested attributes + it('adds server errors for nested attributes', async () => { + const todo = store.add('todos', { title: '' }) + const errors = [ + { + detail: 'Quantity must be greater than 0', + source: { + pointer: '/data/attributes/options/resources/0/quantity' + }, + title: 'Invalid quantity' + } + ] + + try { + await store.updateRecords(mockRequest(errors), todo) + } catch (error) { + expect(todo.errors['options.resources.0.quantity']).toEqual(errors) + } + }) + + it('adds server errors for multiple records', async () => { + const todo1 = store.add('todos', {}) + const todo2 = store.add('todos', {}) + const errors = [ + { + detail: "Title can't be blank", + source: { pointer: '/data/0/attributes/title' }, + title: 'Invalid title' + }, + { + detail: 'Quantity must be greater than 0', + source: { + pointer: '/data/1/attributes/quantity' + }, + title: 'Invalid quantity' + } + ] + + try { + await store.updateRecords(mockRequest(errors), [todo1, todo2]) + } catch (error) { + expect(todo2.errors.quantity).toEqual([errors[1]]) + } + }) + + it('adds server errors to the right record', async () => { + const todo1 = store.add('todos', {}) + const todo2 = store.add('todos', {}) + const errors = [ + { + detail: "Title can't be blank", + source: { pointer: '/data/1/attributes/title' }, + title: 'Invalid title' + } + ] + + try { + await store.updateRecords(mockRequest(errors), [todo1, todo2]) + } catch (error) { + expect(todo2.errors.title).toEqual(errors) + } + }) + }) + }) + describe('reset', () => { it('removes all records from the store', async () => { expect.assertions(4) diff --git a/src/Store.js b/src/Store.js index 45745331..a3f4e409 100644 --- a/src/Store.js +++ b/src/Store.js @@ -1,6 +1,6 @@ /* global fetch */ import { action, observable, transaction, set, toJS } from 'mobx' -import { dbOrNewId, parseApiErrors, requestUrl, uniqueBy, combineRacedRequests, deriveIdQueryStrings } from './utils' +import { dbOrNewId, parseErrorPointer, requestUrl, uniqueBy, combineRacedRequests, deriveIdQueryStrings } from './utils' /** * Defines the Artemis Data Store class. @@ -886,24 +886,29 @@ class Store { } else { recordsArray.forEach(record => { record.isInFlight = false }) - let message = this.genericErrorMessage let json = {} try { json = await response.json() - message = parseApiErrors(json.errors, message) } catch (error) { // 500 doesn't return a parsable response + return Promise.reject(new Error(this.genericErrorMessage)) } - // TODO: add all errors from the API response to the record - // also TODO: split server errors by record once the info is available from the API - recordsArray[0].errors = { - ...recordsArray[0].errors, - status: status, - base: [{ message }], - server: json.errors - } - const errorString = JSON.stringify(recordsArray[0].errors) + // Add all errors from the API response to the record(s). + // This is done by comparing the pointer in the error to + // the request. + json.errors.forEach(error => { + const { index, key } = parseErrorPointer(error) + if (key != null) { + const errors = recordsArray[index].errors[key] || [] + errors.push(error) + recordsArray[index].errors[key] = errors + } + }) + + const errorString = recordsArray + .map(record => JSON.stringify(record.errors)) + .join(';') return Promise.reject(new Error(errorString)) } }, diff --git a/src/utils.js b/src/utils.js index 04d12e2e..b1df7b58 100644 --- a/src/utils.js +++ b/src/utils.js @@ -194,19 +194,39 @@ export function diff (a = {}, b = {}) { } /** - * A naive way of extracting errors from the server. - * This needs some real work. Please don't track down the original author - * of the code (it's DEFINITELY not the person writing this documentation). - * Currently it only extracts the message from the first error, but not only - * can multiple errors be returned, they will correspond to different records - * in the case of a bulk JSONAPI response. + * Parses the pointer of the error to retrieve the index of the + * record the error belongs to and the full path to the attribute + * which will serve as the key for the error. * - * @method parseApiErrors - * @param {Array} a request to the API - * @param {String} default error message + * If there is no parsed index, then assume the payload was for + * a single record and default to 0. + * + * ex. + * error = { + * detail: "Foo can't be blank", + * source: { pointer: '/data/1/attributes/options/foo' }, + * title: 'Invalid foo' + * } + * + * parsePointer(error) + * > { + * index: 1, + * key: 'options.foo' + * } + * + * @method parseErrorPointer + * @param {Object} error + * @return {Object} the matching parts of the pointer */ -export function parseApiErrors (errors, defaultMessage) { - return (errors[0].detail.length === 0) ? defaultMessage : errors[0].detail[0] +export function parseErrorPointer (error = {}) { + const regex = /\/data\/(?\d+)?\/?attributes\/(?.*)$/ + const match = dig(error, 'source.pointer', '').match(regex) + const { index = 0, key } = match?.groups || {} + + return { + index: parseInt(index), + key: key?.replace(/\//g, '.') + } } /**