UNPKG

bananas-commerce-admin

Version:

What's this, an admin for apes?

337 lines (293 loc) 10.3 kB
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; }); } }