Enhancement: Add API error handling utilities

This commit is contained in:
Gustav Hansen 2022-12-22 15:45:16 +01:00
parent 43067bf4c2
commit 8e9f2b74ed
12 changed files with 311 additions and 68 deletions

View File

@ -1,10 +1,9 @@
import React, { memo, useState } from 'react';
import { useIntl } from 'react-intl';
import get from 'lodash/get';
import isEqual from 'react-fast-compare';
import { Button } from '@strapi/design-system/Button';
import Trash from '@strapi/icons/Trash';
import { ConfirmDialog, useNotification } from '@strapi/helper-plugin';
import { ConfirmDialog, useNotification, formatAPIError } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { getTrad } from '../../../utils';
import { connect, select } from './utils';
@ -29,14 +28,12 @@ const DeleteLink = ({ isCreatingEntry, onDelete, onDeleteSucceeded, trackerPrope
toggleWarningDelete();
onDeleteSucceeded();
} catch (err) {
const errorMessage = get(
err,
'response.payload.message',
formatMessage({ id: getTrad('error.record.delete') })
);
setIsModalConfirmButtonLoading(false);
toggleWarningDelete();
toggleNotification({ type: 'warning', message: errorMessage });
toggleNotification({
type: 'warning',
message: formatAPIError(err, { formatMessage, getTrad }),
});
}
};

View File

@ -20,6 +20,7 @@ import {
useRBACProvider,
useTracking,
Link,
formatAPIError,
} from '@strapi/helper-plugin';
import { IconButton } from '@strapi/design-system/IconButton';
@ -147,7 +148,6 @@ function ListView({
return;
}
console.error(err);
toggleNotification({
type: 'warning',
message: { id: getTrad('error.model.fetch') },
@ -170,11 +170,11 @@ function ListView({
} catch (err) {
toggleNotification({
type: 'warning',
message: { id: getTrad('error.record.delete') },
message: formatAPIError(err, { formatMessage, getTrad }),
});
}
},
[fetchData, params, slug, toggleNotification]
[fetchData, params, slug, toggleNotification, formatMessage]
);
const handleConfirmDeleteData = useCallback(
@ -190,15 +190,9 @@ function ListView({
message: { id: getTrad('success.record.delete') },
});
} catch (err) {
const errorMessage = get(
err,
'response.payload.message',
formatMessage({ id: getTrad('error.record.delete') })
);
toggleNotification({
type: 'warning',
message: errorMessage,
message: formatAPIError(err, { formatMessage, getTrad }),
});
}
},

View File

@ -101,4 +101,7 @@ export { default as wrapAxiosInstance } from './utils/wrapAxiosInstance';
export { default as request } from './utils/request';
export { default as getAPIInnerErrors } from './utils/getAPIInnerErrors';
export { default as getYupInnerErrors } from './utils/getYupInnerErrors';
export { default as formatAPIError } from './utils/formatAPIError';
export { default as normalizeAPIError } from './utils/normalizeAPIError';
export { default as getFetchClient } from './utils/getFetchClient';

View File

@ -0,0 +1,16 @@
import normalizeAPIError from '../normalizeAPIError';
export default function formatAPIError(error, { formatMessage, getTrad }) {
if (!formatMessage) {
throw new Error('formatMessage() is a mandatory argument.');
}
const normalizedError = normalizeAPIError(error, getTrad);
// stringify multiple errors
if (normalizedError?.errors) {
return Object.values(normalizedError.errors).map(formatMessage).join('\n');
}
return formatMessage(normalizedError);
}

View File

@ -0,0 +1,65 @@
import formatAPIError from '../index';
const API_VALIDATION_ERROR_FIXTURE = {
response: {
data: {
error: {
name: 'ValidationError',
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
const formatMessage = jest.fn((t) => t.defaultMessage);
describe('formatAPIError', () => {
test('handles ValidationError', () => {
expect(
formatAPIError(API_VALIDATION_ERROR_FIXTURE, {
formatMessage,
getTrad: (translation) => `plugin.${translation}`,
})
).toBe(`Field contains errors\nField must be unique`);
});
test('handles ValidationError and applies a global translation prefix without getTrad', () => {
expect(formatAPIError(API_VALIDATION_ERROR_FIXTURE, { formatMessage })).toBe(
`Field contains errors\nField must be unique`
);
});
test('handles ApplicationError errors', () => {
expect(
formatAPIError(
{
response: {
data: {
error: {
name: 'ApplicationError',
message: 'Error message',
},
},
},
},
{ formatMessage, getTrad: (translation) => translation }
)
).toBe('Error message');
});
test('error if formatMessage was not passed', () => {
expect(() => formatAPIError(API_VALIDATION_ERROR_FIXTURE)).toThrow();
});
});

View File

@ -1,16 +1,11 @@
import normalizeAPIError from '../normalizeAPIError';
export default function getAPIInnerErrors(error, { getTrad }) {
const errorPayload = error.response.data.error.details.errors;
const validationErrors = errorPayload.reduce((acc, err) => {
acc[err.path.join('.')] = {
id: getTrad(`apiError.${err.message}`),
defaultMessage: err.message,
values: {
field: err.path[err.path.length - 1],
},
};
const normalizedError = normalizeAPIError(error, getTrad);
return acc;
}, {});
if (normalizedError?.errors) {
return normalizedError.errors;
}
return validationErrors;
return normalizedError.defaultMessage;
}

View File

@ -1,36 +0,0 @@
import getAPIInnerErrors from '../index';
const API_ERROR_FIXTURE = {
response: {
data: {
error: {
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
describe('getAPIInnerError', () => {
test('transforms API errors into errors, which can be rendered by the CM', () => {
expect(getAPIInnerErrors(API_ERROR_FIXTURE)).toMatchObject({
'field.0.name': {
id: 'content-manager.apiError.Field contains errors',
},
field: {
id: 'content-manager.apiError.Field must be unique',
},
});
});
});

View File

@ -0,0 +1,56 @@
import getAPIInnerErrors from '../index';
const API_VALIDATION_ERROR_FIXTURE = {
response: {
data: {
error: {
name: 'ValidationError',
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
const API_APPLICATION_ERROR_FIXTURE = {
response: {
data: {
error: {
name: 'ApplicationError',
message: 'Error message',
},
},
},
};
describe('getAPIInnerError', () => {
test('handles ValidationError errors', () => {
expect(
getAPIInnerErrors(API_VALIDATION_ERROR_FIXTURE, { getTrad: (translation) => translation })
).toMatchObject({
'field.0.name': {
id: 'apiError.Field contains errors',
},
field: {
id: 'apiError.Field must be unique',
},
});
});
test('handles ApplicationError errors', () => {
expect(
getAPIInnerErrors(API_APPLICATION_ERROR_FIXTURE, { getTrad: (translation) => translation })
).toBe('Error message');
});
});

View File

@ -0,0 +1,48 @@
function getPrefixedId(message, getTrad) {
const errorPrefix = 'apiError.';
const prefixedMessage = `${errorPrefix}${message}`;
if (getTrad) {
return getTrad(prefixedMessage);
}
return prefixedMessage;
}
export default function normalizeAPIError(resError, getTrad) {
const { error } = resError.response.data;
switch (error.name) {
case 'ValidationError':
const { errors } = error.details;
const normalizedErrors = errors.reduce((acc, err) => {
const path = err?.path.join('.');
const { message } = err;
acc[path] = {
id: getPrefixedId(message, getTrad),
defaultMessage: message,
values: {
field: err.path[err.path.length - 1],
},
};
return acc;
}, {});
return {
name: error.name,
errors: normalizedErrors,
};
default:
const { message } = error;
return {
name: error.name,
id: getPrefixedId(message, getTrad),
defaultMessage: message,
};
}
}

View File

@ -0,0 +1,103 @@
import normalizeAPIError from '../';
const API_VALIDATION_ERROR_FIXTURE = {
response: {
data: {
error: {
name: 'ValidationError',
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
const API_APPLICATION_ERROR_FIXTURE = {
response: {
data: {
error: {
name: 'ApplicationError',
message: 'Application crashed',
},
},
},
};
describe('normalizeAPIError', () => {
test('Handle ValidationError', () => {
expect(normalizeAPIError(API_VALIDATION_ERROR_FIXTURE)).toStrictEqual({
name: 'ValidationError',
errors: {
field: {
defaultMessage: 'Field must be unique',
id: 'apiError.Field must be unique',
values: {
field: 'field',
},
},
'field.0.name': {
defaultMessage: 'Field contains errors',
id: 'apiError.Field contains errors',
values: {
field: 'name',
},
},
},
});
});
test('Handle ValidationError with custom prefix function', () => {
const prefixFunction = (id) => `custom.${id}`;
expect(normalizeAPIError(API_VALIDATION_ERROR_FIXTURE, prefixFunction)).toStrictEqual({
name: 'ValidationError',
errors: {
field: {
defaultMessage: 'Field must be unique',
id: 'custom.apiError.Field must be unique',
values: {
field: 'field',
},
},
'field.0.name': {
defaultMessage: 'Field contains errors',
id: 'custom.apiError.Field contains errors',
values: {
field: 'name',
},
},
},
});
});
test('Handle ApplicationError', () => {
expect(normalizeAPIError(API_APPLICATION_ERROR_FIXTURE)).toStrictEqual({
name: 'ApplicationError',
defaultMessage: 'Application crashed',
id: 'apiError.Application crashed',
});
});
test('Handle ApplicationError with custom prefix function', () => {
const prefixFunction = (id) => `custom.${id}`;
expect(normalizeAPIError(API_APPLICATION_ERROR_FIXTURE, prefixFunction)).toStrictEqual({
name: 'ApplicationError',
defaultMessage: 'Application crashed',
id: 'custom.apiError.Application crashed',
});
});
});

View File

@ -167,11 +167,13 @@ describe('EditFolderDialog', () => {
response: {
data: {
error: {
name: 'ValidationError',
details: {
errors: [
{
path: ['parent'],
message: FIXTURE_ERROR_MESSAGE,
name: 'ValidationError',
},
],
},

View File

@ -139,13 +139,13 @@ describe('BulkMoveButton', () => {
response: {
data: {
error: {
name: 'ValidationError',
details: {
errors: [
{
key: 'destination',
path: [],
message: FIXTURE_ERROR_MESSAGE,
defaultMessage: FIXTURE_ERROR_MESSAGE,
name: 'ValidationError',
},
],
},