Skip to content

Commit

Permalink
Merge branch 'mohanzhang-custom-errors'
Browse files Browse the repository at this point in the history
  • Loading branch information
agraboso committed Sep 16, 2015
2 parents e7aceac + fe23044 commit 8aa86a9
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 39 deletions.
49 changes: 40 additions & 9 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ import { normalize, Schema } from 'normalizr';
import fetch from 'isomorphic-fetch';
import isPlainObject from 'lodash.isplainobject';

/**
* Error class for an API response outside the 200 range
*
* @class ApiError
* @access private
* @param {number} status - the status code of the API response
* @param {string} statusText - the status text of the API response
* @param {Object} response - the JSON response of the API server if the 'Content-Type'
* header signals a JSON response, or the raw response object otherwise
*/
class ApiError extends Error {
constructor(status, statusText, response) {
super();
this.name = 'ApiError';
this.status = status;
this.statusText = statusText;
this.message = `${status} - ${statusText}`;
this.response = response;
}
}

/**
* Fetches an API response and normalizes the resulting JSON according to schema.
*
Expand All @@ -45,10 +66,10 @@ function callApi(endpoint, method, headers, body, schema) {
if (response.ok) {
return Promise.resolve(response);
} else {
return Promise.reject(new Error(`${response.status} - ${response.statusText}`));
return Promise.reject(response);
}
})
.then(response => {
.then((response) => {
const contentType = response.headers.get('Content-Type');
if (contentType && ~contentType.indexOf('json')) {
return response.json().then((json) => {
Expand All @@ -61,11 +82,21 @@ function callApi(endpoint, method, headers, body, schema) {
} else {
return Promise.resolve();
}
},
(response) => {
const contentType = response.headers.get('Content-Type');
if (contentType && ~contentType.indexOf('json')) {
return response.json().then((json) => {
return Promise.reject(new ApiError(response.status, response.statusText, json));
});
} else {
return Promise.reject(new ApiError(response.status, response.statusText, response));
}
});
}

/**
* Action key that carries API call info interpreted by this Redux middleware.
* Symbol key that carries API call info interpreted by this Redux middleware.
*
* @constant {Symbol}
* @access public
Expand All @@ -78,7 +109,7 @@ export const CALL_API = Symbol('Call API');
*
* @function isRSAA
* @access public
* @param action - The action to check against the RSAA definition.
* @param {Object} action - The action to check against the RSAA definition.
* @returns {boolean}
*/
export function isRSAA(action) {
Expand Down Expand Up @@ -113,9 +144,9 @@ export function isRSAA(action) {

const { endpoint, method, body, headers, schema, types, bailout } = callAPI;

return Object.keys(action).every(key => ~validRootKeys.indexOf(key)) &&
return Object.keys(action).every((key) => ~validRootKeys.indexOf(key)) &&
isPlainObject(callAPI) &&
Object.keys(callAPI).every(key => ~validCallAPIKeys.indexOf(key)) &&
Object.keys(callAPI).every((key) => ~validCallAPIKeys.indexOf(key)) &&
(typeof endpoint === 'string' || typeof endpoint === 'function') &&
~validMethods.indexOf(method.toUpperCase()) &&
(Array.isArray(types) && types.length === 3) &&
Expand All @@ -132,7 +163,7 @@ export function isRSAA(action) {
* @access public
*/
export function apiMiddleware({ getState }) {
return next => action => {
return (next) => (action) => {
const callAPI = action[CALL_API];
if (!isRSAA(action)) {
return next(action);
Expand All @@ -159,8 +190,8 @@ export function apiMiddleware({ getState }) {
next(actionWith({ type: requestType }));

return callApi(endpoint, method, headers, body, schema).then(
response => next(actionWith({ type: successType }, response)),
error => next(actionWith({
(response) => next(actionWith({ type: successType }, response)),
(error) => next(actionWith({
type: failureType,
payload: error,
error: true
Expand Down
107 changes: 77 additions & 30 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import test from 'tape';
import { Schema, arrayOf } from 'normalizr';
import nock from 'nock';

import { CALL_API, apiMiddleware, isRSAA} from '../src';
import { CALL_API, apiMiddleware, isRSAA } from '../src';

test('isRSAA must identify RSAA-compliant actions', function (t) {
t.notOk(isRSAA(''), 'RSAA actions must be plain JavaScript objects');
Expand Down Expand Up @@ -141,10 +141,10 @@ test('apiMiddleware must pass non-RSAA actions to the next handler', function (t
actionHandler(nonRSAAAction);
});

test('apiMiddleware must handle a successful API request', function (t) {
test('apiMiddleware must handle an unsuccessful API request with a json response', function (t) {
const api = nock('http://127.0.0.1')
.get('/api/users/1')
.reply(200, { username: 'Alice' }, {'Content-Type': 'application/json'});
.reply(404, { error: 'API error' }, { 'Content-Type': 'application/json' });
const anAction = {
[CALL_API]: {
endpoint: 'http://127.0.0.1/api/users/1',
Expand All @@ -165,30 +165,34 @@ test('apiMiddleware must handle a successful API request', function (t) {
t.deepEqual(action.meta, anAction.meta, 'request FSA has correct meta property');
t.notOk(action.error, 'request FSA has correct error property');
break;
case 'FETCH_USER.SUCCESS':
t.pass('success FSA action passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'success FSA does not have a [CALL_API] property')
t.deepEqual(action.payload, { ...anAction.payload, username: 'Alice' }, 'success FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'success FSA has correct meta property');
t.notOk(action.error, 'success FSA has correct error property');
case 'FETCH_USER.FAILURE':
t.pass('failure FSA action passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'failure FSA does not have a [CALL_API] property')
t.equal(action.payload.name, 'ApiError', 'failure FSA has an ApiError payload');
t.equal(action.payload.status, 404, 'failure FSA has an ApiError payload with the correct status code');
t.equal(action.payload.statusText, 'Not Found', 'failure FSA has an ApiError payload with the correct status text');
t.equal(action.payload.message, '404 - Not Found', 'failure FSA has an ApiError payload with the correct message');
t.equal(action.payload.response.error, 'API error', 'failure FSA has an ApiError payload with the correct json response');
t.deepEqual(action.meta, anAction.meta, 'failure FSA has correct meta property');
t.ok(action.error, 'failure FSA has correct error property');
break;
}
};
const actionHandler = nextHandler(doNext);

t.plan(10);
t.plan(14);
actionHandler(anAction);
});

test('apiMiddleware must handle a successful API request that returns an empty body', function (t) {
test('apiMiddleware must handle an unsuccessful API request that returns a non-json response', function (t) {
const api = nock('http://127.0.0.1')
.delete('/api/users/1')
.reply(204);
.get('/api/users/1')
.reply(404, '<html><body>404 Not Found!</body></html>', { 'Content-Type': 'application/html' });
const anAction = {
[CALL_API]: {
endpoint: 'http://127.0.0.1/api/users/1',
method: 'DELETE',
types: ['DELETE_USER.REQUEST', 'DELETE_USER.SUCCESS', 'DELETE_USER.FAILURE']
method: 'GET',
types: ['FETCH_USER.REQUEST', 'FETCH_USER.SUCCESS', 'FETCH_USER.FAILURE']
},
payload: { someKey: 'someValue' },
meta: 'meta'
Expand All @@ -197,17 +201,60 @@ test('apiMiddleware must handle a successful API request that returns an empty b
const nextHandler = apiMiddleware({ getState: doGetState });
const doNext = (action) => {
switch (action.type) {
case 'DELETE_USER.REQUEST':
case 'FETCH_USER.REQUEST':
t.pass('request FSA passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'request FSA does not have a [CALL_API] property')
t.deepEqual(action.payload, anAction.payload, 'request FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'request FSA has correct meta property');
t.notOk(action.error, 'request FSA has correct error property');
break;
case 'DELETE_USER.SUCCESS':
case 'FETCH_USER.FAILURE':
t.pass('failure FSA action passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'failure FSA does not have a [CALL_API] property')
t.equal(action.payload.name, 'ApiError', 'failure FSA has an ApiError payload');
t.equal(action.payload.status, 404, 'failure FSA has an ApiError payload with the correct status code');
t.equal(action.payload.statusText, 'Not Found', 'failure FSA has an ApiError payload with the correct status text');
t.equal(action.payload.message, '404 - Not Found', 'failure FSA has an ApiError payload with the correct message');
t.equal(action.payload.response.constructor.name, 'Response', 'failure FSA has an ApiError payload with the response object');
t.deepEqual(action.meta, anAction.meta, 'failure FSA has correct meta property');
t.ok(action.error, 'failure FSA has correct error property');
break;
}
};
const actionHandler = nextHandler(doNext);

t.plan(14);
actionHandler(anAction);
});

test('apiMiddleware must handle a successful API request', function (t) {
const api = nock('http://127.0.0.1')
.get('/api/users/1')
.reply(200, { username: 'Alice' }, {'Content-Type': 'application/json'});
const anAction = {
[CALL_API]: {
endpoint: 'http://127.0.0.1/api/users/1',
method: 'GET',
types: ['FETCH_USER.REQUEST', 'FETCH_USER.SUCCESS', 'FETCH_USER.FAILURE']
},
payload: { someKey: 'someValue' },
meta: 'meta'
};
const doGetState = () => {};
const nextHandler = apiMiddleware({ getState: doGetState });
const doNext = (action) => {
switch (action.type) {
case 'FETCH_USER.REQUEST':
t.pass('request FSA passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'request FSA does not have a [CALL_API] property')
t.deepEqual(action.payload, anAction.payload, 'request FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'request FSA has correct meta property');
t.notOk(action.error, 'request FSA has correct error property');
break;
case 'FETCH_USER.SUCCESS':
t.pass('success FSA action passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'success FSA does not have a [CALL_API] property')
t.deepEqual(action.payload, anAction.payload, 'success FSA has correct payload property');
t.deepEqual(action.payload, { ...anAction.payload, username: 'Alice' }, 'success FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'success FSA has correct meta property');
t.notOk(action.error, 'success FSA has correct error property');
break;
Expand All @@ -219,15 +266,15 @@ test('apiMiddleware must handle a successful API request that returns an empty b
actionHandler(anAction);
});

test('apiMiddleware must handle an unsuccessful API request', function (t) {
test('apiMiddleware must handle a successful API request that returns an empty body', function (t) {
const api = nock('http://127.0.0.1')
.get('/api/users/1')
.reply(404);
.delete('/api/users/1')
.reply(204);
const anAction = {
[CALL_API]: {
endpoint: 'http://127.0.0.1/api/users/1',
method: 'GET',
types: ['FETCH_USER.REQUEST', 'FETCH_USER.SUCCESS', 'FETCH_USER.FAILURE']
method: 'DELETE',
types: ['DELETE_USER.REQUEST', 'DELETE_USER.SUCCESS', 'DELETE_USER.FAILURE']
},
payload: { someKey: 'someValue' },
meta: 'meta'
Expand All @@ -236,19 +283,19 @@ test('apiMiddleware must handle an unsuccessful API request', function (t) {
const nextHandler = apiMiddleware({ getState: doGetState });
const doNext = (action) => {
switch (action.type) {
case 'FETCH_USER.REQUEST':
case 'DELETE_USER.REQUEST':
t.pass('request FSA passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'request FSA does not have a [CALL_API] property')
t.deepEqual(action.payload, anAction.payload, 'request FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'request FSA has correct meta property');
t.notOk(action.error, 'request FSA has correct error property');
break;
case 'FETCH_USER.FAILURE':
t.pass('failure FSA action passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'failure FSA does not have a [CALL_API] property')
t.ok(action.payload instanceof Error, 'failure FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'failure FSA has correct meta property');
t.ok(action.error, 'failure FSA has correct error property');
case 'DELETE_USER.SUCCESS':
t.pass('success FSA action passed to the next handler');
t.equal(typeof action[CALL_API], 'undefined', 'success FSA does not have a [CALL_API] property')
t.deepEqual(action.payload, anAction.payload, 'success FSA has correct payload property');
t.deepEqual(action.meta, anAction.meta, 'success FSA has correct meta property');
t.notOk(action.error, 'success FSA has correct error property');
break;
}
};
Expand Down

0 comments on commit 8aa86a9

Please sign in to comment.