Files
outline/app/utils/ApiClient.ts
T

314 lines
8.2 KiB
TypeScript

import retry from "fetch-retry";
import trim from "lodash/trim";
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 stores from "~/stores";
import Logger from "./Logger";
import download from "./download";
import {
AuthorizationError,
BadGatewayError,
BadRequestError,
NetworkError,
NotFoundError,
OfflineError,
PaymentRequiredError,
RateLimitExceededError,
RequestError,
ServiceUnavailableError,
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
type Options = {
baseUrl?: string;
};
interface FetchOptions {
download?: boolean;
retry?: boolean;
credentials?: "omit" | "same-origin" | "include";
headers?: Record<string, string>;
baseUrl?: string;
}
const fetchWithRetry = retry(fetch);
class ApiClient {
baseUrl: string;
shareId?: string;
/** Map of in-flight POST requests for deduplication, keyed by path + body. */
// oxlint-disable-next-line no-explicit-any
private inflightRequests = new Map<string, Promise<any>>();
constructor(options: Options = {}) {
this.baseUrl = options.baseUrl || "/api";
}
setShareId = (shareId: string | undefined) => {
this.shareId = shareId;
};
// oxlint-disable-next-line no-explicit-any
fetch = async <T = any>(
path: string,
method: string,
data: JSONObject | FormData | undefined,
options: FetchOptions = {}
): Promise<T> => {
let body: string | FormData | undefined;
let modifiedPath;
let urlToFetch;
let isJson;
if (this.shareId) {
// add to data
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,
};
// Add CSRF token to headers for mutating requests
const isModifyingRequest = ["POST", "PUT", "PATCH", "DELETE"].includes(
method
);
const canAccessWithReadOnly = AuthenticationHelper.canAccess(path, [
Scope.Read,
]);
if (isModifyingRequest && !canAccessWithReadOnly) {
const csrfToken = getCookie(CSRF.cookieName);
if (csrfToken) {
headerOptions[CSRF.headerName] = csrfToken;
}
}
// 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);
const timeStart = window.performance.now();
let response;
try {
response = await (options?.retry === false ? fetch : fetchWithRetry)(
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();
}
// Handle 401, log out user
if (response.status === 401) {
if (!this.shareId) {
await stores.auth.logout({
savePath: true,
clearCache: false,
revokeToken: false,
});
}
throw new AuthorizationError();
}
if (response.status === 502) {
const text = await response.text();
const err = new BadGatewayError(text);
Logger.error("BadGatewayError", err, {
url: urlToFetch,
requestTime: Math.round(timeEnd - timeStart),
responseText: text,
responseHeaders: Object.fromEntries(response.headers.entries()),
});
throw err;
}
// Handle failed responses
const error: {
message?: string;
error?: string;
data?: Record<string, unknown>;
} = {};
try {
const parsed = 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
}
if (response.status === 400 && error.error === "editor_update_required") {
window.location.reload();
throw new UpdateRequiredError(error.message);
}
if (response.status === 400) {
throw new BadRequestError(error.message);
}
if (response.status === 402) {
throw new PaymentRequiredError(error.message);
}
if (response.status === 403) {
if (error.error === "user_suspended") {
await stores.auth.logout({
savePath: false,
revokeToken: false,
clearCache: true,
});
}
if (error.error === "csrf_error") {
throw new AuthorizationError(
"CSRF token invalid, please try reloading."
);
}
throw new AuthorizationError(error.message);
}
if (response.status === 404) {
throw new NotFoundError(error.message);
}
if (response.status === 503) {
throw new ServiceUnavailableError(error.message);
}
if (response.status === 422) {
throw new UnprocessableEntityError(error.message);
}
if (response.status === 429) {
throw new RateLimitExceededError(
`Too many requests, try again in a minute.`
);
}
const err = new RequestError(`Error ${response.status}`);
Logger.error("Request failed", err, {
...error,
url: urlToFetch,
});
// Still need to throw to trigger retry
throw err;
};
// oxlint-disable-next-line no-explicit-any
get = <T = any>(
path: string,
data: JSONObject | undefined,
options?: FetchOptions
) => this.fetch<T>(path, "GET", data, options);
// oxlint-disable-next-line no-explicit-any
post = <T = any>(
path: string,
data?: JSONObject | FormData,
options?: FetchOptions
): Promise<T> => {
if (data instanceof FormData) {
return this.fetch<T>(path, "POST", data, options);
}
const key = `${path}:${JSON.stringify(data)}:${JSON.stringify(options)}`;
const inflight = this.inflightRequests.get(key);
if (inflight) {
return inflight;
}
const promise = this.fetch<T>(path, "POST", data, options).finally(() => {
this.inflightRequests.delete(key);
});
this.inflightRequests.set(key, promise);
return promise;
};
}
export const client = new ApiClient();