Skip to content

Commit

Permalink
Use json api errors as internal errors representation
Browse files Browse the repository at this point in the history
  • Loading branch information
tchak committed Jun 16, 2015
1 parent 974b6ff commit 8b8f6b3
Show file tree
Hide file tree
Showing 18 changed files with 599 additions and 279 deletions.
22 changes: 12 additions & 10 deletions packages/activemodel-adapter/lib/system/active-model-adapter.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {RESTAdapter} from "ember-data/adapters";
import {InvalidError} from "ember-data/system/adapter";
import {pluralize} from "ember-inflector";
import {
InvalidError,
errorsHashToArray
} from "ember-data/adapters/errors";

/**
@module ember-data
Expand Down Expand Up @@ -122,9 +125,9 @@ var ActiveModelAdapter = RESTAdapter.extend({
},

/**
The ActiveModelAdapter overrides the `ajaxError` method
to return a DS.InvalidError for all 422 Unprocessable Entity
responses.
The ActiveModelAdapter overrides the `handleResponse` method
to format errors passed to a DS.InvalidError for all
422 Unprocessable Entity responses.
A 422 HTTP response from the server generally implies that the request
was well formed but the API was unable to process it because the
Expand All @@ -137,14 +140,13 @@ var ActiveModelAdapter = RESTAdapter.extend({
@param {Object} jqXHR
@return error
*/
ajaxError: function(jqXHR) {
var error = this._super.apply(this, arguments);
handleResponse: function(status, headers, payload) {
if (this.isInvalid(status, headers, payload)) {
let errors = errorsHashToArray(payload.errors);

if (jqXHR && jqXHR.status === 422) {
var response = Ember.$.parseJSON(jqXHR.responseText);
return new InvalidError(response);
return new InvalidError(errors);
} else {
return error;
return this._super(...arguments);
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module("integration/active_model_adapter_serializer - AMS Adapter and Serializer
test('errors are camelCased and are expected under the `errors` property of the payload', function() {
var jqXHR = {
status: 422,
getAllResponseHeaders: function() { return ''; },
responseText: JSON.stringify({
errors: {
first_name: ["firstName error"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,28 @@ test('buildURL - decamelizes names', function() {
equal(adapter.buildURL('superUser', 1), "/super_users/1");
});

test('ajaxError - returns invalid error if 422 response', function() {
test('handleResponse - returns invalid error if 422 response', function() {

var jqXHR = {
status: 422,
responseText: JSON.stringify({ name: "can't be blank" })
responseText: JSON.stringify({ errors: { name: "can't be blank" } })
};

equal(adapter.ajaxError(jqXHR).errors.name, "can't be blank");
var json = adapter.parseErrorResponse(jqXHR.responseText);

var error = adapter.handleResponse(jqXHR.status, {}, json).errors[0];

equal(error.details, "can't be blank");
equal(error.source.pointer, "data/attributes/name");
});

test('ajaxError - returns ajax response if not 422 response', function() {
test('handleResponse - returns ajax response if not 422 response', function() {
var jqXHR = {
status: 500,
responseText: "Something went wrong"
};

equal(adapter.ajaxError(jqXHR), jqXHR);
var json = adapter.parseErrorResponse(jqXHR.responseText);

ok(adapter.handleResponse(jqXHR.status, {}, json) instanceof DS.AdapterError, 'must be a DS.AdapterError');
});
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,21 @@ test("extractPolymorphic does not break hasMany relationships", function() {
});

test("extractErrors camelizes keys", function() {
var payload = {
errors: {
first_name: ["firstName not evil enough"]
}
var error = {
errors: [
{
source: {
pointer: 'data/attributes/first_name'
},
details: "firstName not evil enough"
}
]
};

var payload;

run(function() {
payload = env.amsSerializer.extractErrors(env.store, SuperVillain, payload);
payload = env.amsSerializer.extractErrors(env.store, SuperVillain, error);
});

deepEqual(payload, {
Expand Down
159 changes: 159 additions & 0 deletions packages/ember-data/lib/adapters/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const EmberError = Ember.Error;
const create = Ember.create;

const forEach = Ember.ArrayPolyfills.forEach;
const SOURCE_POINTER_REGEXP = /data\/(attributes|relationships)\/(.*)/;

/**
@class AdapterError
@namespace DS
*/
export function AdapterError(errors, message) {
message = message || "Adapter operation failed";

EmberError.call(this, message);

this.errors = errors || [
{
title: "Adapter Error",
details: message
}
];
}

AdapterError.prototype = create(EmberError.prototype);

/**
A `DS.InvalidError` is used by an adapter to signal the external API
was unable to process a request because the content was not
semantically correct or meaningful per the API. Usually this means a
record failed some form of server side validation. When a promise
from an adapter is rejected with a `DS.InvalidError` the record will
transition to the `invalid` state and the errors will be set to the
`errors` property on the record.
For Ember Data to correctly map errors to their corresponding
properties on the model, Ember Data expects each error to be
a valid json-api error object with a `source/pointer` that matches
the property name. For example if you had a Post model that
looked like this.
```app/models/post.js
import DS from 'ember-data';
export default DS.Model.extend({
title: DS.attr('string'),
content: DS.attr('string')
});
```
To show an error from the server related to the `title` and
`content` properties your adapter could return a promise that
rejects with a `DS.InvalidError` object that looks like this:
```app/adapters/post.js
import Ember from 'ember';
import DS from 'ember-data';
export default DS.RESTAdapter.extend({
updateRecord: function() {
// Fictional adapter that always rejects
return Ember.RSVP.reject(new DS.InvalidError([
{
details: 'Must be unique',
source: { pointer: 'data/attributes/title' }
},
{
details: 'Must not be blank',
source: { pointer: 'data/attributes/content'}
}
]));
}
});
```
Your backend may use different property names for your records the
store will attempt extract and normalize the errors using the
serializer's `extractErrors` method before the errors get added to
the the model. As a result, it is safe for the `InvalidError` to
wrap the error payload unaltered.
@class InvalidError
@namespace DS
*/
export function InvalidError(errors) {
if (!Ember.isArray(errors)) {
Ember.deprecate("`InvalidError` expects json-api formatted errors.");
errors = errorsHashToArray(errors);
}
AdapterError.call(this, errors, "The adapter rejected the commit because it was invalid");
}

InvalidError.prototype = create(AdapterError.prototype);

/**
@class TimeoutError
@namespace DS
*/
export function TimeoutError() {
AdapterError.call(this, null, "The adapter operation timed out");
}

TimeoutError.prototype = create(AdapterError.prototype);

/**
@class AbortError
@namespace DS
*/
export function AbortError() {
AdapterError.call(this, null, "The adapter operation was aborted");
}

AbortError.prototype = create(AdapterError.prototype);

/**
@private
*/
export function errorsHashToArray(errors) {
let out = [];

if (Ember.isPresent(errors)) {
let key;
for (key in errors) {
if (errors.hasOwnProperty(key)) {
let messages = Ember.makeArray(errors[key]);
for (let i = 0; i < messages.length; i++) {
out.push({
title: 'Invalid Attribute',
details: messages[i],
source: {
pointer: `data/attributes/${key}`
}
});
}
}
}
}

return out;
}

export function errorsArrayToHash(errors) {
let out = {};

if (Ember.isPresent(errors)) {
forEach.call(errors, function(error) {
if (error.source && error.source.pointer) {
let key = error.source.pointer.match(SOURCE_POINTER_REGEXP);

if (key) {
key = key[2];
out[key] = out[key] || [];
out[key].push(error.details || error.title);
}
}
});
}

return out;
}
Loading

0 comments on commit 8b8f6b3

Please sign in to comment.