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:
parent
106fe5dd61
commit
26636f5386
@ -22,7 +22,7 @@ module.exports = {
|
|||||||
"no-restricted-globals": [2, "event", "error"],
|
"no-restricted-globals": [2, "event", "error"],
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"warn",
|
||||||
{
|
{
|
||||||
argsIgnorePattern: "^_",
|
argsIgnorePattern: "^_",
|
||||||
varsIgnorePattern: "^_",
|
varsIgnorePattern: "^_",
|
||||||
|
137
frontend/src/controllers/api.ts
Normal file
137
frontend/src/controllers/api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,7 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import { msg } from "@lit/localize";
|
|
||||||
|
|
||||||
import type { Auth } from "./AuthService";
|
import { APIController } from "@/controllers/api";
|
||||||
import AuthService from "./AuthService";
|
|
||||||
import { APIError } from "./api";
|
|
||||||
import appState, { use } from "./state";
|
import appState, { use } from "./state";
|
||||||
|
|
||||||
export interface NavigateEvent extends CustomEvent {
|
export interface NavigateEvent extends CustomEvent {
|
||||||
@ -50,6 +47,8 @@ export default class LiteElement extends LitElement {
|
|||||||
@use()
|
@use()
|
||||||
appState = appState;
|
appState = appState;
|
||||||
|
|
||||||
|
private api = new APIController(this);
|
||||||
|
|
||||||
protected get orgBasePath() {
|
protected get orgBasePath() {
|
||||||
const slug = this.appState.orgSlug;
|
const slug = this.appState.orgSlug;
|
||||||
if (slug) {
|
if (slug) {
|
||||||
@ -115,110 +114,10 @@ export default class LiteElement extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async apiFetch<T = unknown>(
|
/**
|
||||||
path: string,
|
* @deprecated New components should use APIController directly
|
||||||
auth: Auth,
|
*/
|
||||||
options?: {
|
async apiFetch<T = unknown>(...args: Parameters<APIController["fetch"]>) {
|
||||||
method?: string;
|
return this.api.fetch<T>(...args);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user