diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..c6c4d22 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,96 @@ +export type ErrorsBody = { + errors: Array; +}; + +export type AnyError = + ValidationErrorMessage | PermissionErrorMessage | NotFoundErrorMessage; + +export type ValidationErrorMessage = { + model: string; + attribute: string; + type: string; +}; + +export type PermissionErrorMessage = { + policy: string; + type: "unauthorized" | "forbidden"; +}; + +export type NotFoundErrorMessage = { + model: string; + type: "not_found"; +}; + +// NOTE: This message can also include a trace in a structured way, but we don't care about that +export type RailsErrorMessage = { + status: number; + error: string; + exception: string; +}; + +/** + * We create our own subclass of error, to hold the structured errors we get from the api + * + * Each subclass of this needs to set a message for compatibility with the general `Error`, + * though we probably would only use the `details` + */ +export class CustomError extends Error { + details: Array; + + constructor(message: string, details: Array) { + super(message); + this.name = "CustomError"; + this.details = details; + } +} + +export class UnauthorizedError extends CustomError { + constructor(details: Array) { + super("You are not signed in", details); + this.name = "UnauthorizedError"; + } +} + +// Our API returns a forbidden status when the user is not allowed to perform an action, or when they enter the wrong password +export class ForbiddenError extends CustomError< + PermissionErrorMessage | ValidationErrorMessage +> { + constructor(details: Array) { + super("You are not allowed to perform this action", details); + this.name = "ForbiddenError"; + } +} + +export class NotFoundError extends CustomError { + constructor(details: Array) { + super("Could not find object", details); + this.name = "NotFoundError"; + } +} + +export class UnprocessableContentError extends CustomError { + constructor(details: Array) { + super("Could not save object", details); + this.name = "UnprocessableContentError"; + } +} + +export class UnknownError extends Error { + details?: unknown; + + constructor(message: unknown, details?: unknown) { + super(`${message}`); + this.name = "UnknownError"; + this.details = details; + } +} + +export class UnexpectedError extends Error { + details: object; + + constructor(message: string, details: RailsErrorMessage) { + super(message); + this.name = "UnexpectedError"; + this.details = details; + } +} diff --git a/src/http.ts b/src/http.ts index 3f2eaa2..aabfe60 100644 --- a/src/http.ts +++ b/src/http.ts @@ -2,6 +2,20 @@ import useFetchRetry from "fetch-retry"; import { Scope } from "./scopes"; import { ApiToken } from "./types/auth"; import { RetryOptions } from "./types/fetch_retry"; +import { + CustomError, + ErrorsBody, + ForbiddenError, + NotFoundError, + NotFoundErrorMessage, + PermissionErrorMessage, + RailsErrorMessage, + UnauthorizedError, + UnexpectedError, + UnknownError, + UnprocessableContentError, + ValidationErrorMessage, +} from "./errors"; const fetchRetry = useFetchRetry(fetch, { retries: 0, @@ -111,22 +125,61 @@ export async function httpDelete( } async function resolve(request: Request): Promise { - let response: Response, result: ReturnType; + let response: Response; try { response = await fetchRetry(request); - result = response.status === 204 ? true : await response.json(); } catch (error) { - const reason: Record = {}; if (error instanceof Error) { - reason[error.constructor.name] = [error.message]; + throw error; } else { - reason["UnknownError"] = [`${error}`]; + throw new UnknownError(error); } - throw reason; } + + // Status 204 is a special case, since this doesn't have a body + if (response.status === 204) { + return true as ReturnType; + } + + // If the body isn't json, we throw an error with the body parsed as plain text + const contentType = response.headers.get("Content-Type"); + if (contentType === null || !contentType?.startsWith("application/json")) { + const body = await response.text(); + throw new UnknownError( + `Expected a JSON response but got ${contentType}`, + body, + ); + } + if (response.ok) { - return result; - } else { - throw result; + return await response.json(); + } + + const body: ErrorsBody = await response.json(); + + if (!Array.isArray(body.errors)) { + throw new UnexpectedError( + "Received an unhandled JSON error", + body as unknown as RailsErrorMessage, + ); + } + + // We throw a specific type of error, depending on the status - we know the type of message that can occur + // for each body, so we can tell the typescript compiler what we expect + switch (response.status) { + case 401: + throw new UnauthorizedError(body.errors as Array); + case 403: + throw new ForbiddenError( + body.errors as Array, + ); + case 404: + throw new NotFoundError(body.errors as Array); + case 422: + throw new UnprocessableContentError( + body.errors as Array, + ); + default: + throw new CustomError("Unexpected structured error", body.errors); } } diff --git a/src/index.ts b/src/index.ts index 06fd0a9..976b81b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { UserModule, } from "./api_module"; export * from "./api_module"; +export * from "./errors"; export * from "./scopes"; export * from "./types";