mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-07 22:15:45 +01:00
improve error handling in the ui
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import Notification from "./Notification";
|
||||
import { BackendError } from "./errors";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
@@ -9,12 +10,71 @@ type Props = {
|
||||
};
|
||||
|
||||
class ErrorNotification extends React.Component<Props> {
|
||||
|
||||
renderMoreInformationsLink(error: BackendError) {
|
||||
if (error.url) {
|
||||
// TODO i18n
|
||||
return (
|
||||
<p>
|
||||
For more informations, see <a href={error.url} target="_blank">{error.errorCode}</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderBackendError(error: BackendError) {
|
||||
// TODO i18n
|
||||
// how should we handle i18n for the message?
|
||||
// we could add translation for known error codes to i18n and pass the context objects as parameters,
|
||||
// but this will not work for errors from plugins, because the ErrorNotification will search for the translation
|
||||
// in the wrong namespace (plugins could only add translations to the plugins namespace.
|
||||
// should we add a special namespace for errors? which could be extend by plugins?
|
||||
|
||||
// TODO error page
|
||||
// we have currently the ErrorNotification, which is often wrapped by the ErrorPage
|
||||
// the ErrorPage has often a SubTitle like "Unkwown xzy error", which is no longer always the case
|
||||
// if the error is a BackendError its not fully unknown
|
||||
return (
|
||||
<div className="content">
|
||||
<p>{error.message}</p>
|
||||
<p><strong>Context:</strong></p>
|
||||
<ul>
|
||||
{error.context.map((context, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<strong>{context.type}:</strong> {context.id}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{ this.renderMoreInformationsLink(error) }
|
||||
<div className="level is-size-7">
|
||||
<div className="left">
|
||||
ErrorCode: {error.errorCode}
|
||||
</div>
|
||||
<div className="right">
|
||||
TransactionId: {error.transactionId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderError(error: Error) {
|
||||
if (error instanceof BackendError) {
|
||||
return this.renderBackendError(error);
|
||||
} else {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t, error } = this.props;
|
||||
const { error } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {error.message}
|
||||
{this.renderError(error)}
|
||||
</Notification>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @flow
|
||||
import { contextPath } from "./urls";
|
||||
|
||||
export const NOT_FOUND_ERROR = Error("not found");
|
||||
export const UNAUTHORIZED_ERROR = Error("unauthorized");
|
||||
import { createBackendError } from "./errors";
|
||||
import type { BackendErrorContent } from "./errors";
|
||||
|
||||
const fetchOptions: RequestOptions = {
|
||||
credentials: "same-origin",
|
||||
@@ -11,15 +10,21 @@ const fetchOptions: RequestOptions = {
|
||||
}
|
||||
};
|
||||
|
||||
function handleStatusCode(response: Response) {
|
||||
|
||||
function isBackendError(response) {
|
||||
return response.headers.get("Content-Type") === "application/vnd.scmm-error+json;v=2";
|
||||
}
|
||||
|
||||
function handleFailure(response: Response) {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw UNAUTHORIZED_ERROR;
|
||||
if (isBackendError(response)) {
|
||||
return response.json()
|
||||
.then((content: BackendErrorContent) => {
|
||||
throw createBackendError(content, response.status);
|
||||
});
|
||||
} else {
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw NOT_FOUND_ERROR;
|
||||
}
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -37,7 +42,7 @@ export function createUrl(url: string) {
|
||||
|
||||
class ApiClient {
|
||||
get(url: string): Promise<Response> {
|
||||
return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
|
||||
return fetch(createUrl(url), fetchOptions).then(handleFailure);
|
||||
}
|
||||
|
||||
post(url: string, payload: any, contentType: string = "application/json") {
|
||||
@@ -53,7 +58,7 @@ class ApiClient {
|
||||
method: "HEAD"
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
|
||||
delete(url: string): Promise<Response> {
|
||||
@@ -61,7 +66,7 @@ class ApiClient {
|
||||
method: "DELETE"
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
|
||||
httpRequestWithJSONBody(
|
||||
@@ -78,7 +83,7 @@ class ApiClient {
|
||||
// $FlowFixMe
|
||||
options.headers["Content-Type"] = contentType;
|
||||
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @flow
|
||||
import { createUrl } from "./apiclient";
|
||||
import { apiClient, createUrl } from "./apiclient";
|
||||
import {fetchMock} from "fetch-mock";
|
||||
import { BackendError } from "./errors";
|
||||
|
||||
describe("create url", () => {
|
||||
it("should not change absolute urls", () => {
|
||||
@@ -13,3 +15,64 @@ describe("create url", () => {
|
||||
expect(createUrl("users")).toBe("/api/v2/users");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("error handling tests", () => {
|
||||
|
||||
const earthNotFoundError = {
|
||||
transactionId: "42t",
|
||||
errorCode: "42e",
|
||||
message: "earth not found",
|
||||
context: [{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}]
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should create a normal error, if the content type is not scmm-error", (done) => {
|
||||
|
||||
fetchMock.getOnce("/api/v2/error", {
|
||||
status: 404
|
||||
});
|
||||
|
||||
apiClient.get("/error")
|
||||
.catch((err: Error) => {
|
||||
expect(err.name).toEqual("Error");
|
||||
expect(err.message).toContain("404");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an backend error, if the content type is scmm-error", (done) => {
|
||||
fetchMock.getOnce("/api/v2/error", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.scmm-error+json;v=2"
|
||||
},
|
||||
body: earthNotFoundError
|
||||
});
|
||||
|
||||
apiClient.get("/error")
|
||||
.catch((err: BackendError) => {
|
||||
|
||||
expect(err).toBeInstanceOf(BackendError);
|
||||
|
||||
expect(err.message).toEqual("earth not found");
|
||||
expect(err.statusCode).toBe(404);
|
||||
|
||||
expect(err.transactionId).toEqual("42t");
|
||||
expect(err.errorCode).toEqual("42e");
|
||||
expect(err.context).toEqual([{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
53
scm-ui-components/packages/ui-components/src/errors.js
Normal file
53
scm-ui-components/packages/ui-components/src/errors.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
type Context = {type: string, id: string}[];
|
||||
|
||||
export type BackendErrorContent = {
|
||||
transactionId: string,
|
||||
errorCode: string,
|
||||
message: string,
|
||||
url?: string,
|
||||
context: Context
|
||||
};
|
||||
|
||||
export class BackendError extends Error {
|
||||
|
||||
transactionId: string;
|
||||
errorCode: string;
|
||||
url: ?string;
|
||||
context: Context = [];
|
||||
statusCode: number;
|
||||
|
||||
constructor(content: BackendErrorContent, name: string, statusCode: number) {
|
||||
super(content.message);
|
||||
this.name = name;
|
||||
this.transactionId = content.transactionId;
|
||||
this.errorCode = content.errorCode;
|
||||
this.url = content.url;
|
||||
this.context = content.context;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends BackendError {
|
||||
constructor(content: BackendErrorContent, statusCode: number) {
|
||||
super(content, "UnauthorizedError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends BackendError {
|
||||
constructor(content: BackendErrorContent, statusCode: number) {
|
||||
super(content, "NotFoundError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackendError(content: BackendErrorContent, statusCode: number) {
|
||||
switch (statusCode) {
|
||||
case 403:
|
||||
return new UnauthorizedError(content, statusCode);
|
||||
case 404:
|
||||
return new NotFoundError(content, statusCode);
|
||||
default:
|
||||
return new BackendError(content, "BackendError", statusCode);
|
||||
}
|
||||
}
|
||||
35
scm-ui-components/packages/ui-components/src/errors.test.js
Normal file
35
scm-ui-components/packages/ui-components/src/errors.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// @flow
|
||||
|
||||
import { BackendError, UnauthorizedError, createBackendError, NotFoundError } from "./errors";
|
||||
|
||||
describe("test createBackendError", () => {
|
||||
|
||||
const earthNotFoundError = {
|
||||
transactionId: "42t",
|
||||
errorCode: "42e",
|
||||
message: "earth not found",
|
||||
context: [{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}]
|
||||
};
|
||||
|
||||
it("should return a default backend error", () => {
|
||||
const err = createBackendError(earthNotFoundError, 500);
|
||||
expect(err).toBeInstanceOf(BackendError);
|
||||
expect(err.name).toBe("BackendError");
|
||||
});
|
||||
|
||||
it("should return an unauthorized error for status code 403", () => {
|
||||
const err = createBackendError(earthNotFoundError, 403);
|
||||
expect(err).toBeInstanceOf(UnauthorizedError);
|
||||
expect(err.name).toBe("UnauthorizedError");
|
||||
});
|
||||
|
||||
it("should return an not found error for status code 404", () => {
|
||||
const err = createBackendError(earthNotFoundError, 404);
|
||||
expect(err).toBeInstanceOf(NotFoundError);
|
||||
expect(err.name).toBe("NotFoundError");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -22,7 +22,8 @@ export { default as HelpIcon } from "./HelpIcon";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { getPageFromMatch } from "./urls";
|
||||
|
||||
export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR } from "./apiclient.js";
|
||||
export { apiClient } from "./apiclient.js";
|
||||
export * from "./errors";
|
||||
|
||||
export * from "./buttons";
|
||||
export * from "./config";
|
||||
|
||||
Reference in New Issue
Block a user