diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 8c3d9a9a..12ed4774 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -22,7 +22,7 @@ module.exports = { "no-restricted-globals": [2, "event", "error"], "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ - "error", + "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", diff --git a/frontend/src/controllers/api.ts b/frontend/src/controllers/api.ts new file mode 100644 index 00000000..af811dba --- /dev/null +++ b/frontend/src/controllers/api.ts @@ -0,0 +1,137 @@ +import type { + LitElement, + ReactiveController, + ReactiveControllerHost, +} from "lit"; +import { msg } from "@lit/localize"; + +import type { Auth } from "@/utils/AuthService"; +import AuthService from "@/utils/AuthService"; +import { APIError } from "@/utils/api"; + +/** + * Utilities for interacting with the Browsertrix backend API + * + * @example Usage: + * ```ts + * class MyComponent extends LitElement { + * private api = new APIController(this); + * + * async getSomething() { + * await this.api.fetch("/path", this.authState) + * } + * } + * ``` + */ +export class APIController implements ReactiveController { + host: ReactiveControllerHost & EventTarget; + + constructor(host: APIController["host"]) { + this.host = host; + host.addController(this); + } + + hostConnected() {} + hostDisconnected() {} + + async fetch( + path: string, + auth: Auth, + options?: RequestInit + ): Promise { + const { headers, ...opts } = options || {}; + const resp = await fetch("/api" + path, { + headers: { + "Content-Type": "application/json", + ...headers, + ...auth.headers, + }, + ...opts, + }); + + if (resp.ok) { + const body = await resp.json(); + const storageQuotaReached = body.storageQuotaReached; + const executionMinutesQuotaReached = body.executionMinutesQuotaReached; + if (typeof storageQuotaReached === "boolean") { + this.host.dispatchEvent( + new CustomEvent("storage-quota-update", { + detail: { reached: storageQuotaReached }, + bubbles: true, + }) + ); + } + if (typeof executionMinutesQuotaReached === "boolean") { + this.host.dispatchEvent( + new CustomEvent("execution-minutes-quota-update", { + detail: { reached: executionMinutesQuotaReached }, + bubbles: true, + }) + ); + } + + return body; + } + + let errorDetail; + try { + errorDetail = (await resp.json()).detail; + } catch {} + + let errorMessage: string = msg("Unknown API error"); + + switch (resp.status) { + case 401: { + this.host.dispatchEvent(AuthService.createNeedLoginEvent()); + errorMessage = msg("Need login"); + break; + } + case 403: { + if (errorDetail === "storage_quota_reached") { + this.host.dispatchEvent( + new CustomEvent("storage-quota-update", { + detail: { reached: true }, + bubbles: true, + }) + ); + errorMessage = msg("Storage quota reached"); + break; + } + if (errorDetail === "exec_minutes_quota_reached") { + this.host.dispatchEvent( + new CustomEvent("execution-minutes-quota-update", { + detail: { reached: true }, + bubbles: true, + }) + ); + errorMessage = msg("Monthly execution minutes quota reached"); + break; + } + } + case 404: { + errorMessage = msg("Not found"); + break; + } + default: { + if (typeof errorDetail === "string") { + errorMessage = errorDetail; + } else if (Array.isArray(errorDetail) && errorDetail.length) { + const fieldDetail = errorDetail[0] || {}; + const { loc, msg } = fieldDetail; + + const fieldName = loc + .filter((v: any) => v !== "body" && typeof v === "string") + .join(" "); + errorMessage = `${fieldName} ${msg}`; + } + break; + } + } + + throw new APIError({ + message: errorMessage, + status: resp.status, + details: errorDetail, + }); + } +} diff --git a/frontend/src/utils/LiteElement.ts b/frontend/src/utils/LiteElement.ts index c1219b85..803cd9ef 100644 --- a/frontend/src/utils/LiteElement.ts +++ b/frontend/src/utils/LiteElement.ts @@ -1,10 +1,7 @@ import { LitElement, html } from "lit"; import type { TemplateResult } from "lit"; -import { msg } from "@lit/localize"; -import type { Auth } from "./AuthService"; -import AuthService from "./AuthService"; -import { APIError } from "./api"; +import { APIController } from "@/controllers/api"; import appState, { use } from "./state"; export interface NavigateEvent extends CustomEvent { @@ -50,6 +47,8 @@ export default class LiteElement extends LitElement { @use() appState = appState; + private api = new APIController(this); + protected get orgBasePath() { const slug = this.appState.orgSlug; if (slug) { @@ -115,110 +114,10 @@ export default class LiteElement extends LitElement { ); } - async apiFetch( - path: string, - auth: Auth, - options?: { - method?: string; - headers?: any; - body?: any; - signal?: AbortSignal; - duplex?: string; - } - ): Promise { - const { headers, ...opts } = options || {}; - const resp = await fetch("/api" + path, { - headers: { - "Content-Type": "application/json", - ...headers, - ...auth.headers, - }, - ...opts, - }); - - if (resp.ok) { - const body = await resp.json(); - const storageQuotaReached = body.storageQuotaReached; - const executionMinutesQuotaReached = body.executionMinutesQuotaReached; - if (typeof storageQuotaReached === "boolean") { - this.dispatchEvent( - new CustomEvent("storage-quota-update", { - detail: { reached: storageQuotaReached }, - bubbles: true, - }) - ); - } - if (typeof executionMinutesQuotaReached === "boolean") { - this.dispatchEvent( - new CustomEvent("execution-minutes-quota-update", { - detail: { reached: executionMinutesQuotaReached }, - bubbles: true, - }) - ); - } - - return body; - } - - let errorDetail; - try { - errorDetail = (await resp.json()).detail; - } catch {} - - let errorMessage: string = msg("Unknown API error"); - - switch (resp.status) { - case 401: { - this.dispatchEvent(AuthService.createNeedLoginEvent()); - errorMessage = msg("Need login"); - break; - } - case 403: { - if (errorDetail === "storage_quota_reached") { - this.dispatchEvent( - new CustomEvent("storage-quota-update", { - detail: { reached: true }, - bubbles: true, - }) - ); - errorMessage = msg("Storage quota reached"); - break; - } - if (errorDetail === "exec_minutes_quota_reached") { - this.dispatchEvent( - new CustomEvent("execution-minutes-quota-update", { - detail: { reached: true }, - bubbles: true, - }) - ); - errorMessage = msg("Monthly execution minutes quota reached"); - break; - } - } - case 404: { - errorMessage = msg("Not found"); - break; - } - default: { - if (typeof errorDetail === "string") { - errorMessage = errorDetail; - } else if (Array.isArray(errorDetail) && errorDetail.length) { - const fieldDetail = errorDetail[0] || {}; - const { loc, msg } = fieldDetail; - - const fieldName = loc - .filter((v: any) => v !== "body" && typeof v === "string") - .join(" "); - errorMessage = `${fieldName} ${msg}`; - } - break; - } - } - - throw new APIError({ - message: errorMessage, - status: resp.status, - details: errorDetail, - }); + /** + * @deprecated New components should use APIController directly + */ + async apiFetch(...args: Parameters) { + return this.api.fetch(...args); } }