Refactor API fetch helper into controller (#1415)

### Context

Components currently can't access `LiteElement` utility methods without
being rendered into the light DOM. This is an initial step towards
breaking out parts of `LiteElement` into composable units. (see
https://github.com/webrecorder/browsertrix-cloud/issues/1380)

### Changes

Moves `apiFetch` from `LiteElement` into a reactive controller. New
components should use `APIController` directly instead of extending
`LiteElement`. We'll also work to move existing uses of `LiteElement`
off of it with time.

### Manual testing

No visible changes, skim through the app to verify that that backend API
fetches work as expected.
This commit is contained in:
sua yoo 2023-11-30 12:00:43 -08:00 committed by GitHub
parent 106fe5dd61
commit 26636f5386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 146 additions and 110 deletions

View File

@ -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: "^_",

View File

@ -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<T = unknown>(
path: string,
auth: Auth,
options?: RequestInit
): Promise<T> {
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,
});
}
}

View File

@ -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<T = unknown>(
path: string,
auth: Auth,
options?: {
method?: string;
headers?: any;
body?: any;
signal?: AbortSignal;
duplex?: string;
}
): Promise<T> {
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<T = unknown>(...args: Parameters<APIController["fetch"]>) {
return this.api.fetch<T>(...args);
}
}