From 33c14d6002e07cb3845409bdf650092cf108fbe4 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Wed, 3 Jul 2024 11:51:10 +0200 Subject: [PATCH] Add handleErrorResponse to the public API This prevents from having to manually map the received error to an Error class. --- CHANGELOG.md | 2 + src/http/handleErrorResponse.test.ts | 77 +++++++++++ src/http/handleErrorResponse.ts | 122 ++++++++++++++++++ src/http/httpError.mock.ts | 48 +++++++ src/http/httpError.test.ts | 17 +-- .../wellKnown/internalServerErrorError.ts | 2 +- src/index.ts | 2 +- 7 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 src/http/handleErrorResponse.test.ts create mode 100644 src/http/handleErrorResponse.ts create mode 100644 src/http/httpError.mock.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index df9c10f..bf4445b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,3 +15,5 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html `MethodNotAllowedError`, `NotAcceptableError`, `NotFoundError`, `PreconditionFailedError`, `TooManyRequestsError`, `UnauthorizedError`, `UnsupportedMediaTypeError`: Specializations of the `ClientHttpError` to represent common HTTP error responses. +- `handleErrorResponse`: a function to map the received HTTP error to the appropriate error + class. diff --git a/src/http/handleErrorResponse.test.ts b/src/http/handleErrorResponse.test.ts new file mode 100644 index 0000000..254c15e --- /dev/null +++ b/src/http/handleErrorResponse.test.ts @@ -0,0 +1,77 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { describe, it, expect } from "@jest/globals"; +import { handleErrorResponse } from "./handleErrorResponse"; +import { + BadRequestError, + BAD_REQUEST_STATUS, +} from "./wellKnown/badRequestError"; +import { mockResponse } from "./httpError.mock"; +import ConflictError, { CONFLICT_STATUS } from "./wellKnown/conflictError"; +import ForbiddenError, { FORBIDDEN_STATUS } from "./wellKnown/forbiddenError"; +import GoneError, { GONE_STATUS } from "./wellKnown/goneError"; +import InternalServerErrorError, { + INTERNAL_SERVER_ERROR_STATUS, +} from "./wellKnown/internalServerErrorError"; +import MethodNotAllowedError, { + METHOD_NOT_ALLOWED_STATUS, +} from "./wellKnown/methodNotAllowedError"; +import NotAcceptableError, { + NOT_ACCEPTABLE_STATUS, +} from "./wellKnown/notAcceptableError"; +import NotFoundError, { NOT_FOUND_STATUS } from "./wellKnown/notFoundError"; +import PreconditionFailedError, { + PRECONDITION_FAILED_STATUS, +} from "./wellKnown/preconditionFailedError"; +import TooManyRequestsError, { + TOO_MANY_REQUESTS_STATUS, +} from "./wellKnown/tooManyRequestsError"; +import UnauthorizedError, { + UNAUTHORIZED_STATUS, +} from "./wellKnown/unauthorizedError"; +import UnsupportedMediaTypeError, { + UNSUPPORTED_MEDIA_TYPE_STATUS, +} from "./wellKnown/unsupportedMediaTypeError"; + +describe("handleErrorResponse", () => { + it.each([ + [BAD_REQUEST_STATUS, BadRequestError], + [CONFLICT_STATUS, ConflictError], + [FORBIDDEN_STATUS, ForbiddenError], + [GONE_STATUS, GoneError], + [INTERNAL_SERVER_ERROR_STATUS, InternalServerErrorError], + [METHOD_NOT_ALLOWED_STATUS, MethodNotAllowedError], + [NOT_ACCEPTABLE_STATUS, NotAcceptableError], + [NOT_FOUND_STATUS, NotFoundError], + [PRECONDITION_FAILED_STATUS, PreconditionFailedError], + [TOO_MANY_REQUESTS_STATUS, TooManyRequestsError], + [UNAUTHORIZED_STATUS, UnauthorizedError], + [UNSUPPORTED_MEDIA_TYPE_STATUS, UnsupportedMediaTypeError], + ])("maps %i status to %s class", (responseStatus, errorClass) => { + const response = mockResponse({ status: responseStatus }); + const error = handleErrorResponse( + response, + "Some response body", + "Some error message", + ); + expect(error).toBeInstanceOf(errorClass); + }); +}); diff --git a/src/http/handleErrorResponse.ts b/src/http/handleErrorResponse.ts new file mode 100644 index 0000000..0cfe42c --- /dev/null +++ b/src/http/handleErrorResponse.ts @@ -0,0 +1,122 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { ClientHttpError } from "./httpError"; +import BadRequestError, { + BAD_REQUEST_STATUS, +} from "./wellKnown/badRequestError"; +import ConflictError, { CONFLICT_STATUS } from "./wellKnown/conflictError"; +import ForbiddenError, { FORBIDDEN_STATUS } from "./wellKnown/forbiddenError"; +import GoneError, { GONE_STATUS } from "./wellKnown/goneError"; +import InternalServerErrorError, { + INTERNAL_SERVER_ERROR_STATUS, +} from "./wellKnown/internalServerErrorError"; +import MethodNotAllowedError, { + METHOD_NOT_ALLOWED_STATUS, +} from "./wellKnown/methodNotAllowedError"; +import NotAcceptableError, { + NOT_ACCEPTABLE_STATUS, +} from "./wellKnown/notAcceptableError"; +import NotFoundError, { NOT_FOUND_STATUS } from "./wellKnown/notFoundError"; +import PreconditionFailedError, { + PRECONDITION_FAILED_STATUS, +} from "./wellKnown/preconditionFailedError"; +import TooManyRequestsError, { + TOO_MANY_REQUESTS_STATUS, +} from "./wellKnown/tooManyRequestsError"; +import UnauthorizedError, { + UNAUTHORIZED_STATUS, +} from "./wellKnown/unauthorizedError"; +import UnsupportedMediaTypeError, { + UNSUPPORTED_MEDIA_TYPE_STATUS, +} from "./wellKnown/unsupportedMediaTypeError"; + +/** + * Map an HTTP error response to one of the Error classes exported by this library. + * + * @example + * ```ts + * const response = await fetch("https://example.org/resource"); + * if (!response.ok) { + * const responseBody = await response.text(); + * throw handleErrorResponse(response, responseBody, "Fetch got error response"); + * } + * ``` + * + * @param responseMetadata the response metadata + * @param responseBody the response body + * @param message the error message + * @returns an instance of the ClientHttpError subclass matching the response metadata status. + * If the response status is unkown, the generic ClientHttpError class is used. + * @since unreleased + */ +export function handleErrorResponse( + responseMetadata: { + status: number; + statusText: string; + headers: Headers; + url: string; + }, + responseBody: string, + message: string, +): ClientHttpError { + switch (responseMetadata.status) { + case BAD_REQUEST_STATUS: + return new BadRequestError(responseMetadata, responseBody, message); + case CONFLICT_STATUS: + return new ConflictError(responseMetadata, responseBody, message); + case FORBIDDEN_STATUS: + return new ForbiddenError(responseMetadata, responseBody, message); + case GONE_STATUS: + return new GoneError(responseMetadata, responseBody, message); + case INTERNAL_SERVER_ERROR_STATUS: + return new InternalServerErrorError( + responseMetadata, + responseBody, + message, + ); + case METHOD_NOT_ALLOWED_STATUS: + return new MethodNotAllowedError(responseMetadata, responseBody, message); + case NOT_ACCEPTABLE_STATUS: + return new NotAcceptableError(responseMetadata, responseBody, message); + case NOT_FOUND_STATUS: + return new NotFoundError(responseMetadata, responseBody, message); + case PRECONDITION_FAILED_STATUS: + return new PreconditionFailedError( + responseMetadata, + responseBody, + message, + ); + case TOO_MANY_REQUESTS_STATUS: + return new TooManyRequestsError(responseMetadata, responseBody, message); + case UNAUTHORIZED_STATUS: + return new UnauthorizedError(responseMetadata, responseBody, message); + case UNSUPPORTED_MEDIA_TYPE_STATUS: + return new UnsupportedMediaTypeError( + responseMetadata, + responseBody, + message, + ); + default: + return new ClientHttpError(responseMetadata, responseBody, message); + } +} + +export default handleErrorResponse; diff --git a/src/http/httpError.mock.ts b/src/http/httpError.mock.ts new file mode 100644 index 0000000..95c76e2 --- /dev/null +++ b/src/http/httpError.mock.ts @@ -0,0 +1,48 @@ +// +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { PROBLEM_DETAILS_MIME } from "./problemDetails"; + +export const mockResponse = ({ + body, + status, + statusText, + headers, +}: { + body?: string; + status?: number; + statusText?: string; + headers?: Headers; + responseUrl?: string; +} = {}): Response => { + const response = new Response(body ?? undefined, { + status: status ?? 400, + statusText: statusText ?? "Bad Request", + headers: + headers ?? + new Headers({ + "Content-Type": PROBLEM_DETAILS_MIME, + }), + }); + return response; +}; + +export default mockResponse; diff --git a/src/http/httpError.test.ts b/src/http/httpError.test.ts index de8db94..5c8db3a 100644 --- a/src/http/httpError.test.ts +++ b/src/http/httpError.test.ts @@ -21,13 +21,10 @@ import { describe, it, expect, jest } from "@jest/globals"; import { ClientHttpError } from "./httpError"; import { mockProblemDetails } from "./problemDetails.mock"; -import { - DEFAULT_TYPE, - PROBLEM_DETAILS_MIME, - hasProblemDetails, -} from "./problemDetails"; +import { DEFAULT_TYPE, hasProblemDetails } from "./problemDetails"; import InruptClientError from "../clientError"; import { hasErrorResponse } from "./errorResponse"; +import mockResponseBase from "./httpError.mock"; const mockResponse = ({ body, @@ -42,15 +39,7 @@ const mockResponse = ({ headers?: Headers; responseUrl?: string; } = {}): Response => { - const response = new Response(body ?? undefined, { - status: status ?? 400, - statusText: statusText ?? "Bad Request", - headers: - headers ?? - new Headers({ - "Content-Type": PROBLEM_DETAILS_MIME, - }), - }); + const response = mockResponseBase({ body, status, statusText, headers }); jest .spyOn(response, "url", "get") .mockReturnValue(responseUrl ?? "https://example.org/resource"); diff --git a/src/http/wellKnown/internalServerErrorError.ts b/src/http/wellKnown/internalServerErrorError.ts index 252f20d..cf9f1ca 100644 --- a/src/http/wellKnown/internalServerErrorError.ts +++ b/src/http/wellKnown/internalServerErrorError.ts @@ -22,7 +22,7 @@ import { InruptClientError } from "../../clientError"; import type { ErrorResponse } from "../errorResponse"; import { ClientHttpError } from "../httpError"; -export const INTERNAL_SERVER_ERROR_STATUS = 410 as const; +export const INTERNAL_SERVER_ERROR_STATUS = 500 as const; export type InternalServerErrorErrorResponse = ErrorResponse & { status: typeof INTERNAL_SERVER_ERROR_STATUS; diff --git a/src/index.ts b/src/index.ts index a5b57ab..086f56b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ // export { InruptClientError } from "./clientError"; -export { ClientHttpError } from "./http/httpError"; +export { ClientHttpError, handleErrorResponse } from "./http/httpError"; export { DEFAULT_TYPE, PROBLEM_DETAILS_MIME,