bananas-commerce-admin
Version:
What's this, an admin for apes?
337 lines (293 loc) • 10.3 kB
text/typescript
import SwaggerParser from "@apidevtools/swagger-parser";
import { OpenAPI } from "openapi-types";
import { User } from "./contexts/UserContext";
import { getCookie } from "./util/get_cookie";
import { PageContribComponent } from "./types";
export const BODY_APPLICATION_TYPES = ["application/json", "multipart/form-data"] as const;
export type BodyApplicationType = (typeof BODY_APPLICATION_TYPES)[number];
export class ApiLoadFailedError extends Error {
readonly response: Response;
constructor(response: Response, message?: string) {
super(message);
this.response = response;
}
}
export interface Request extends OpenAPI.Request {
headers?: Record<string, string>;
params?: Record<string, string | number>;
query?: URLSearchParams;
}
export class ApiOperation {
readonly id: string;
readonly tags: string[];
readonly server: string;
readonly endpoint: string;
readonly method: string;
readonly request: OpenAPI.Request;
readonly app: string;
readonly summary?: string;
readonly description?: string;
readonly contribs?: string[];
readonly bodyType?: BodyApplicationType;
component?: PageContribComponent;
order?: number;
constructor(
id: string,
tags: string[],
server: string,
endpoint: string,
method: string,
request: OpenAPI.Request,
app: string,
summary?: string,
description?: string,
bodyType?: BodyApplicationType,
contribs?: string[],
component?: PageContribComponent,
order?: number,
) {
this.id = id;
this.tags = tags;
this.server = server;
this.endpoint = endpoint;
this.method = method;
this.request = request;
this.app = app;
this.summary = summary;
this.description = description;
this.bodyType = bodyType;
this.contribs = contribs ?? [];
this.component = component;
this.order = order;
}
/**
* Return the `URL` for `endpoint`, possibly enriched with `request` params.
* Useful for operations that generate a static url, like a file download.
*/
url(request?: OpenAPI.Request) {
let endpoint = this.endpoint;
// Add params to url
if (request?.params !== undefined) {
for (const [name, value] of Object.entries(request.params)) {
endpoint = endpoint.replace(`{${name}}`, value);
}
}
const url = new URL(this.server + endpoint);
// Add query to url
if (request?.query) {
let query;
try {
// @ts-expect-error - TODO
query = new URLSearchParams(request.query);
} catch {
query = request?.query;
}
url.search = query.toString();
}
return url;
}
async call(request?: OpenAPI.Request, init?: RequestInit) {
const csrftoken = getCookie("csrftoken");
init ??= {};
init.method ??= this.method;
init.headers = new Headers(init.headers);
if (csrftoken !== undefined) init.headers.append("X-CSRFToken", csrftoken);
init.credentials = "include";
// Add body to request
if (request?.body !== undefined) {
// "multipart/form-data" includes information about the FormData Boundry.
// This is a bit arbitrary, so we need to have it set automatically.
// Don't try to be smart when dealing with forms.
if (this.bodyType && this.bodyType !== "multipart/form-data") {
init.headers.set("Content-Type", this.bodyType);
}
switch (this.bodyType) {
case "application/json":
init.body ??= JSON.stringify(request?.body);
break;
case "multipart/form-data":
if (init.body) {
break;
} else if (request.body instanceof FormData) {
init.body = request.body;
break;
} else if (request.body instanceof HTMLFormElement) {
new FormData(request.body);
break;
} else {
throw TypeError(
`Wrong type passed to ApiOperation.Call. Expected multipart/form-data as FormData or HTMLFormElement.`,
);
}
default:
init.body ??= request.body;
break;
}
}
return await fetch(this.url(request), init);
}
}
export class ApiClient {
readonly document: OpenAPI.Document;
readonly operations: Record<string, ApiOperation> = {};
readonly contrib: Record<string, Record<string, ApiOperation>> = {};
static async load(source: string | URL, server?: string | URL) {
const response = await fetch(source);
if (!response.ok) {
throw new ApiLoadFailedError(response, `Could not load schema from ${source}`);
}
const schema = await response.json().catch((e) => {
throw new ApiLoadFailedError(response, `Could not parse schema from ${source}: ${e}`);
});
const document = await SwaggerParser.dereference(schema);
return new ApiClient(document, server);
}
constructor(document: OpenAPI.Document, server?: string | URL) {
this.document = document;
// If the document uses servers and it is not manually set this will be prepended to path later
if (
"servers" in this.document &&
this.document.servers !== undefined &&
this.document.servers.length >= 1
) {
server ??= this.document.servers.pop()!.url;
}
// Make sure the server variable is an URL
if (server !== undefined && !(server instanceof URL)) {
server = new URL(server);
}
// Build operations
for (const [path, definition] of Object.entries(this.document.paths ?? {}) as [
string,
Record<string, OpenAPI.Operation>,
][]) {
for (const [method, operation] of Object.entries(definition)) {
let appName: string | undefined;
const contribs: string[] = [];
// All operations require an operation id
if (!operation.operationId) {
break;
}
// Check if an tag starting with `app:` exists on the operation
operation.tags?.forEach((tag) => {
if (tag.startsWith("app:")) {
appName = tag.replace(/^app:/, "");
} else if (tag.startsWith("contrib:")) {
contribs.push(tag.replace(/^contrib:/, ""));
}
});
if (appName === undefined) {
break;
}
const request: OpenAPI.Request = {};
// Fill the request object with `path`, `query` and `body` data which may be provided
for (const parameter of operation.parameters ?? ([] as OpenAPI.Parameter[])) {
if ("in" in parameter) {
switch (parameter.in) {
case "body": {
request["body"] = {
required: parameter.required ?? false,
schema: parameter.schema,
};
break;
}
case "path": {
request["params"] ??= {};
// eslint-disable-next-line
// @ts-ignore
request["params"][parameter.name] = {
// Path parameters are always required: https://swagger.io/docs/specification/describing-parameters/#path-parameters
required: true,
schema: parameter.schema,
};
break;
}
case "query": {
request["query"] ??= {};
// eslint-disable-next-line
// @ts-ignore
request["query"][parameter.name] = {
required: parameter.required ?? false,
schema: parameter.schema,
};
}
}
}
}
let bodyType: BodyApplicationType | undefined;
// If `requestBody` exists then add it to the request objects body object
if ("requestBody" in operation && operation.requestBody !== undefined) {
if ("content" in operation.requestBody) {
const bodyTypes = Object.keys(operation.requestBody.content).map((bodyType) =>
bodyType.toLowerCase(),
);
for (const type of bodyTypes) {
// @ts-expect-error this is actually totally fine
if (BODY_APPLICATION_TYPES.includes(type)) {
const schema = operation.requestBody.content[type].schema;
const required = operation.requestBody.required ?? false;
bodyType = type as BodyApplicationType;
request["body"] = {
schema,
required,
};
break;
}
}
}
}
// If no server is set, fall back to the current origin
server = server ? server.toString() : globalThis.origin;
// Combine base url and path
if (server.endsWith("/")) {
server = server.slice(0, -1);
}
const endpoint = path.startsWith("/") ? path : "/" + path;
// Create a new `ApiOperation` with the relevant information
this.operations[operation.operationId] = new ApiOperation(
operation.operationId,
operation.tags || [],
server,
endpoint,
method.toUpperCase(),
request,
appName,
operation.summary,
operation.description,
bodyType,
contribs,
undefined,
undefined,
);
for (const contribName of contribs) {
this.contrib[contribName] ??= {};
this.contrib[contribName][appName] = this.operations[operation.operationId];
}
}
}
}
async getAuthenticatedUser() {
try {
const response = await this.operations["bananas.me:list"].call();
return response.ok ? (response.json() as Promise<User>) : null;
} catch {
return null;
}
}
findContrib(prefix: string) {
const results = [];
for (const [tag, operations] of Object.entries(this.contrib)) {
for (const [_, operation] of Object.entries(operations)) {
if (tag.startsWith(prefix) && operation.component !== undefined) {
results.push(operation);
}
}
}
return results.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
return 0;
});
}
}