Source: problem.js

/**
 * RFC 9457 problem details, expressed in Links Notation (specification ยง5).
 */

/** Base URI every registered problem type is resolved against. */
export const PROBLEM_TYPE_BASE =
  "https://link-foundation.github.io/lino-rest-api/errors/";

const REASON_PHRASES = {
  400: "Bad Request",
  401: "Unauthorized",
  403: "Forbidden",
  404: "Not Found",
  405: "Method Not Allowed",
  406: "Not Acceptable",
  409: "Conflict",
  410: "Gone",
  412: "Precondition Failed",
  413: "Content Too Large",
  415: "Unsupported Media Type",
  422: "Unprocessable Content",
  428: "Precondition Required",
  429: "Too Many Requests",
  500: "Internal Server Error",
  501: "Not Implemented",
  502: "Bad Gateway",
  503: "Service Unavailable",
  504: "Gateway Timeout",
};

/**
 * Reason phrase of a status code, falling back to a generic class phrase.
 *
 * @param {number} status - HTTP status code
 * @returns {string} Human readable reason phrase
 */
export function reasonPhrase(status) {
  if (REASON_PHRASES[status]) {
    return REASON_PHRASES[status];
  }
  return status >= 500 ? "Server Error" : "Request Error";
}

/**
 * Kebab-case slug used as the last segment of a problem type URI.
 *
 * @param {string} title - Problem title
 * @returns {string} Slug
 */
export function problemSlug(title) {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

/**
 * An HTTP error that carries problem details.
 *
 * Throwing one of these from a handler produces a conforming error response; the
 * client library raises the same shape when a server answers with `4xx`/`5xx`.
 */
export class LinoHttpError extends Error {
  /**
   * @param {number} status - HTTP status code
   * @param {string} [detail] - Human readable explanation of this occurrence
   * @param {object} [options] - Additional problem members
   * @param {string} [options.title] - Short, type-wide summary
   * @param {string} [options.type] - Problem type URI
   * @param {string} [options.instance] - URI of this occurrence
   * @param {object} [options.headers] - Response headers to send with the error
   * @param {object} [options.extensions] - Extra problem members
   */
  constructor(status, detail, options = {}) {
    const title = options.title ?? reasonPhrase(status);
    super(detail ?? title);

    this.name = "LinoHttpError";
    this.status = status;
    this.title = title;
    this.detail = detail;
    this.type = options.type ?? `${PROBLEM_TYPE_BASE}${problemSlug(title)}`;
    this.instance = options.instance;
    this.headers = options.headers ?? {};
    this.extensions = options.extensions ?? {};
  }

  /**
   * Render the error as the problem details object of the specification.
   *
   * @param {string} [instance] - URI of this occurrence when not already set
   * @returns {object} Problem details ready to be encoded
   */
  toProblem(instance) {
    const problem = {
      type: this.type,
      title: this.title,
      status: this.status,
    };
    if (this.detail !== undefined && this.detail !== null) {
      problem.detail = this.detail;
    }
    const resolvedInstance = this.instance ?? instance;
    if (resolvedInstance) {
      problem.instance = resolvedInstance;
    }
    return { ...problem, ...this.extensions };
  }
}

/**
 * Build problem details for a status code without throwing.
 *
 * @param {number} status - HTTP status code
 * @param {string} [detail] - Human readable explanation
 * @param {object} [options] - Additional problem members, see {@link LinoHttpError}
 * @returns {object} Problem details ready to be encoded
 */
export function problemDetails(status, detail, options = {}) {
  return new LinoHttpError(status, detail, options).toProblem(options.instance);
}

/**
 * A `422` carrying field-level validation failures.
 *
 * @param {Array<{field: string, message: string}>} errors - Field failures
 * @param {string} [detail] - Human readable explanation
 * @returns {LinoHttpError} Error ready to be thrown
 */
export function validationError(
  errors,
  detail = "Request body failed validation",
) {
  return new LinoHttpError(422, detail, {
    title: "Unprocessable Content",
    type: `${PROBLEM_TYPE_BASE}validation-failed`,
    extensions: { errors },
  });
}

/**
 * Turn any thrown value into a {@link LinoHttpError}.
 *
 * Errors that are already problem-shaped keep their status and details; anything
 * else becomes a `500` whose detail is the original message.
 *
 * @param {*} error - Thrown value
 * @returns {LinoHttpError} Normalised error
 */
export function toHttpError(error) {
  if (error instanceof LinoHttpError) {
    return error;
  }
  if (error && typeof error.status === "number" && error.status >= 400) {
    return new LinoHttpError(error.status, error.message, {
      headers: error.headers,
    });
  }
  return new LinoHttpError(500, error?.message ?? String(error));
}