Ctrl K
ComunidadGuías…

app/utils/ApiClient.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import retry from "fetch-retry";
import { chunk, trim } from "es-toolkit/compat";
import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import type { JSONObject } from "@shared/types";
import { Scope } from "@shared/types";
import { version } from "../../package.json";
import env from "~/env";
import Logger from "./Logger";
import { download } from "./download";
import {
  AuthorizationError,
  BadGatewayError,
  BadRequestError,
  ClientClosedRequestError,
  NetworkError,
  NotFoundError,
  OfflineError,
  PaymentRequiredError,
  RateLimitExceededError,
  RequestError,
  ServiceUnavailableError,
  UnprocessableEntityError,
  UpdateRequiredError,
} from "./errors";
import { BatchableApiMethods, BatchMaxRequests, CSRF } from "@shared/constants";
import { getCSRFToken } from "./csrf";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";

type Options = {
  baseUrl?: string;
};

/** An HTTP method supported by the API client. */
type Method = "GET" | "POST" | "PUT";

/** Shape of an error payload returned by the API. */
interface ApiErrorResponse {
  message?: string;
  error?: string;
  data?: Record<string, unknown>;
}

/** Reason the server rejected a request as unauthenticated. */
export type UnauthorizedReason = "unauthorized" | "user_suspended";

/** Handler invoked when a request is rejected as unauthenticated. */
type UnauthorizedHandler = (reason: UnauthorizedReason) => void | Promise<void>;

interface FetchOptions {
  download?: boolean;
  retry?: boolean;
  credentials?: "omit" | "same-origin" | "include";
  headers?: Record<string, string>;
  baseUrl?: string;
}

/** A request captured during a batch, awaiting dispatch in a `/batch` call. */
interface BatchedRequest {
  method: string;
  body?: JSONObject;
  resolve: (value: unknown) => void;
  reject: (reason: unknown) => void;
}

/** A single sub-response within a `/batch` response. */
interface BatchSubResponse {
  ok: boolean;
  status: number;
  data?: unknown;
  policies?: unknown;
  /** Structured error code, mirroring a top-level error response's `error`. */
  error?: string;
  message?: string;
}

/** Methods that may be collected into a single `/batch` request. */
const batchableMethods = new Set<string>(BatchableApiMethods);

class ApiClient {
  baseUrl: string;

  shareId?: string;

  /** Map of in-flight requests for deduplication, keyed by method + path + body. */
  // oxlint-disable-next-line no-explicit-any
  private inflightRequests = new Map<string, Promise<any>>();

  /** Requests collected while a batch is open, or undefined when not batching. */
  private batchQueue?: BatchedRequest[];

  private onUnauthorized?: UnauthorizedHandler;

  constructor(options: Options = {}) {
    this.baseUrl = options.baseUrl || "/api";
  }

  /**
   * Sets the share identifier appended to subsequent requests, used to
   * authenticate access to publicly shared documents.
   *
   * @param shareId the share identifier, or undefined to clear it.
   */
  setShareId = (shareId: string | undefined) => {
    this.shareId = shareId;
  };

  /**
   * Registers a handler invoked when a request is rejected as unauthenticated
   * (a 401, or a 403 indicating the user was suspended). Used to keep
   * session/logout policy out of the transport layer.
   *
   * @param handler the handler to invoke.
   */
  setUnauthorizedHandler = (handler: UnauthorizedHandler) => {
    this.onUnauthorized = handler;
  };

  /**
   * Performs an HTTP request against the API, handling serialization, headers,
   * CSRF, retries, and error mapping.
   *
   * @param path the request path, relative to the base URL or an absolute URL.
   * @param method the HTTP method to use.
   * @param data the request payload, sent as a query string for GET requests
   * and as a JSON or multipart body otherwise.
   * @param options additional request options.
   * @returns the parsed JSON response, or undefined for downloads and empty responses.
   * @throws {RequestError} if the response status indicates failure.
   */
  // oxlint-disable-next-line no-explicit-any
  fetch = async <T = any>(
    path: string,
    method: Method,
    data: JSONObject | FormData | undefined,
    options: FetchOptions = {}
  ): Promise<T> => {
    let body: string | FormData | undefined;
    let modifiedPath: string | undefined;
    let urlToFetch: string;
    let isJson = false;

    if (this.shareId) {
      if (data instanceof FormData) {
        data.append("shareId", this.shareId);
      } else {
        data = {
          ...data,
          shareId: this.shareId,
        };
      }
    }

    if (method === "GET") {
      if (data) {
        modifiedPath = `${path}?${queryString.stringify(data)}`;
      } else {
        modifiedPath = path;
      }
    } else if (method === "POST" || method === "PUT") {
      if (data instanceof FormData || typeof data === "string") {
        body = data;
      } else {
        isJson = true;

        // Only stringify data if its a normal object and
        // not if it's [object FormData], in addition to
        // toggling Content-Type to application/json
        if (
          typeof data === "object" &&
          Object.prototype.toString.call(data) === "[object Object]"
        ) {
          body = JSON.stringify(data);
        }
      }
    }

    if (path.match(/^http/)) {
      urlToFetch = modifiedPath || path;
    } else {
      urlToFetch = (options.baseUrl ?? this.baseUrl) + (modifiedPath || path);
    }

    const headerOptions: Record<string, string> = {
      Accept: "application/json",
      "cache-control": "no-cache",
      "x-editor-version": EDITOR_VERSION,
      "x-api-version": "4",
      "x-client-version": env.VERSION ? `${version}-${env.VERSION}` : version,
      pragma: "no-cache",
      ...options?.headers,
    };

    // Mutating requests require a CSRF token, unless exempt server-side.
    const isModifyingRequest = method === "POST" || method === "PUT";
    const canAccessWithReadOnly = AuthenticationHelper.canAccess(path, [
      Scope.Read,
    ]);
    const requiresCsrfToken = isModifyingRequest && !canAccessWithReadOnly;

    // for multipart forms or other non JSON requests fetch
    // populates the Content-Type without needing to explicitly
    // set it.
    if (isJson) {
      headerOptions["Content-Type"] = "application/json";
    }

    const headers = new Headers(headerOptions);

    // The token is read before each attempt so that retries reflect any
    // rotation of the cookie since the request was prepared.
    const fetchWithFreshCsrfToken: typeof fetch = (input, init) => {
      if (requiresCsrfToken) {
        const csrfToken = getCSRFToken();
        if (csrfToken) {
          headers.set(CSRF.headerName, csrfToken);
        }
      }
      return fetch(input, init);
    };

    const timeStart = window.performance.now();
    let response;

    try {
      response = await (
        options?.retry === false
          ? fetchWithFreshCsrfToken
          : retry(fetchWithFreshCsrfToken)
      )(urlToFetch, {
        method,
        body,
        headers,
        redirect: "follow",
        credentials: "same-origin",
        cache: "no-cache",
      });
    } catch (_err) {
      if (window.navigator.onLine) {
        throw new NetworkError("A network error occurred, try again?");
      } else {
        throw new OfflineError("No internet connection available");
      }
    }

    const timeEnd = window.performance.now();
    const success = response.status >= 200 && response.status < 300;

    if (options.download && success) {
      const blob = await response.blob();
      const fileName = (
        response.headers.get("content-disposition") || ""
      ).split("filename=")[1];
      download(blob, trim(fileName, '"'));
      return undefined as T;
    } else if (success && response.status === 204) {
      return undefined as T;
    } else if (success) {
      return response.json();
    }

    // The gateway or an upstream proxy failed before the app could respond; the
    // raw body is captured for diagnosis.
    if (response.status === 502) {
      const text = await response.text();

      // Gateways often respond with an empty body, or an HTML error page that is
      // too long to read as a title, so the endpoint leads the message.
      const detail = text.trim().slice(0, 200);
      const err = new BadGatewayError(
        detail
          ? `Bad gateway response from ${path}: ${detail}`
          : `Bad gateway response from ${path} with empty body`
      );

      // Cloudflare Zero Trust proxies on the customer's network add this header
      // to their own error pages, so the failure is outside of our control.
      if (response.headers.has("cf-team")) {
        throw err;
      }

      Logger.error(
        "BadGatewayError",
        err,
        {
          path,
          url: urlToFetch,
          requestTime: Math.round(timeEnd - timeStart),
          responseText: text,
          responseHeaders: Object.fromEntries(response.headers.entries()),
        },
        // Grouped together, as the message varies by endpoint and response body.
        ["BadGatewayError"]
      );
      throw err;
    }

    // Parse the structured error payload, if present.
    const error: ApiErrorResponse = {};
    try {
      const parsed: ApiErrorResponse = await response.json();
      error.message = parsed.message || "";
      error.error = parsed.error;
      error.data = parsed.data;
    } catch (_err) {
      // we're trying to parse an error so JSON may not be valid
    }

    const err = await this.toError(response.status, error.error, error.message);

    // Log failures that aren't mapped to a specific error type.
    if (err.constructor === RequestError) {
      Logger.error("Request failed", err, { ...error, url: urlToFetch });
    }

    // Still need to throw to trigger retry
    throw err;
  };

  /**
   * Maps a failed response's status and error code to the corresponding error
   * type, triggering the unauthorized handler for authentication failures.
   * Shared by top-level requests and batched sub-requests so both surface
   * identical errors and side effects.
   *
   * @param status The response status code.
   * @param code The structured error code, if any.
   * @param message The human-readable error message, if any.
   * @returns the error to throw or reject with.
   */
  private toError = async (
    status: number,
    code: string | undefined,
    message: string | undefined
  ): Promise<Error> => {
    if (status === 401) {
      if (!this.shareId) {
        await this.onUnauthorized?.("unauthorized");
      }
      return new AuthorizationError();
    }

    if (status === 400 && code === "editor_update_required") {
      window.location.reload();
      return new UpdateRequiredError(message);
    }

    if (status === 400) {
      return new BadRequestError(message);
    }

    if (status === 402) {
      return new PaymentRequiredError(message);
    }

    if (status === 403) {
      if (code === "user_suspended") {
        await this.onUnauthorized?.("user_suspended");
      }

      if (code === "csrf_error") {
        return new AuthorizationError(
          "CSRF token invalid, please try reloading."
        );
      }

      return new AuthorizationError(message);
    }

    if (status === 404) {
      return new NotFoundError(message);
    }

    // A proxy on the client's network requires authentication.
    if (status === 407) {
      return new NetworkError(message);
    }

    if (status === 503 || status === 504) {
      return new ServiceUnavailableError(message);
    }

    if (status === 422) {
      return new UnprocessableEntityError(message);
    }

    if (status === 429) {
      return new RateLimitExceededError(
        `Too many requests, try again in a minute.`
      );
    }

    // The client, or an intermediate proxy, closed the connection before the
    // response was received – there is nothing actionable to report.
    if (status === 499) {
      return new ClientClosedRequestError(message);
    }

    return new RequestError(`Error ${status}`);
  };

  /**
   * Performs a GET request against the API.
   *
   * @param path the request path, relative to the base URL or an absolute URL.
   * @param data the data serialized into the query string.
   * @param options additional request options.
   * @returns the parsed JSON response.
   */
  // oxlint-disable-next-line no-explicit-any
  get = <T = any>(
    path: string,
    data: JSONObject | undefined,
    options?: FetchOptions
  ) => this.fetch<T>(path, "GET", data, options);

  /**
   * Performs a POST request against the API. Identical in-flight requests are
   * deduplicated and share a single response, except for multipart uploads.
   *
   * @param path the request path, relative to the base URL or an absolute URL.
   * @param data the request payload, sent as a JSON or multipart body.
   * @param options additional request options.
   * @returns the parsed JSON response.
   */
  // oxlint-disable-next-line no-explicit-any
  post = <T = any>(
    path: string,
    data?: JSONObject | FormData,
    options?: FetchOptions
  ): Promise<T> => {
    const method = path.replace(/^\//, "");
    if (
      this.batchQueue &&
      !(data instanceof FormData) &&
      batchableMethods.has(method)
    ) {
      return new Promise<T>((resolve, reject) => {
        this.batchQueue!.push({
          method,
          body: data,
          resolve: resolve as (value: unknown) => void,
          reject,
        });
      });
    }
    return this.deduplicate<T>(path, "POST", data, options);
  };

  /**
   * Collects every batchable POST request issued during the synchronous
   * execution of `fn` and dispatches them as a single `/batch` request once
   * `fn` returns. Non-batchable requests, and requests made after `fn` returns,
   * are sent normally; nested calls join the enclosing batch.
   *
   * @param fn A function that issues the requests to be batched.
   * @returns whatever `fn` returns.
   */
  batch = <T>(fn: () => T): T => {
    if (this.batchQueue) {
      return fn();
    }

    const queue: BatchedRequest[] = [];
    this.batchQueue = queue;
    try {
      return fn();
    } finally {
      this.batchQueue = undefined;
      void this.flushBatch(queue);
    }
  };

  /**
   * Performs a PUT request against the API. Identical in-flight requests are
   * deduplicated and share a single response, except for multipart uploads.
   *
   * @param path the request path, relative to the base URL or an absolute URL.
   * @param data the request payload, sent as a JSON or multipart body.
   * @param options additional request options.
   * @returns the parsed JSON response.
   */
  // oxlint-disable-next-line no-explicit-any
  put = <T = any>(
    path: string,
    data?: JSONObject | FormData,
    options?: FetchOptions
  ): Promise<T> => this.deduplicate<T>(path, "PUT", data, options);

  /**
   * Sends a request, deduplicating identical in-flight requests so concurrent
   * callers share a single response. Multipart uploads are never deduplicated.
   *
   * @param path the request path, relative to the base URL or an absolute URL.
   * @param method the HTTP method to use.
   * @param data the request payload.
   * @param options additional request options.
   * @returns the parsed JSON response.
   */
  // oxlint-disable-next-line no-explicit-any
  private deduplicate = <T = any>(
    path: string,
    method: Method,
    data?: JSONObject | FormData,
    options?: FetchOptions
  ): Promise<T> => {
    if (data instanceof FormData) {
      return this.fetch<T>(path, method, data, options);
    }

    const key = `${method}:${path}:${JSON.stringify(data)}:${JSON.stringify(
      options
    )}`;
    const inflight = this.inflightRequests.get(key);
    if (inflight) {
      return inflight;
    }

    const promise = this.fetch<T>(path, method, data, options).finally(() => {
      this.inflightRequests.delete(key);
    });
    this.inflightRequests.set(key, promise);
    return promise;
  };

  /**
   * Dispatches the requests collected during a batch, splitting them into
   * serial `/batch` calls that respect the server's per-batch limit, and
   * settles each caller's promise with its corresponding sub-response — shaped
   * like a standard API envelope so callers need no special handling.
   *
   * @param queue The requests collected during a batch.
   */
  private flushBatch = async (queue: BatchedRequest[]): Promise<void> => {
    for (const group of chunk(queue, BatchMaxRequests)) {
      try {
        const res = await this.fetch<{ data: BatchSubResponse[] }>(
          "/batch",
          "POST",
          { requests: group.map(({ method, body }) => ({ method, body })) }
        );
        for (let index = 0; index < group.length; index++) {
          const request = group[index];
          const result = res?.data?.[index];
          if (result?.ok) {
            request.resolve({ data: result.data, policies: result.policies });
          } else {
            request.reject(
              await this.toError(
                result?.status ?? 500,
                result?.error,
                result?.message
              )
            );
          }
        }
      } catch (err) {
        group.forEach((request) => request.reject(err));
      }
    }
  };
}

/** Shared API client instance configured against the default base URL. */
export const client = new ApiClient();