import LRU from "lru-cache"; import type { Query, UpObject } from "./index"; import { UpListing } from "./index"; import type { Address, ADDRESS_TYPE, AttributeListingResult, EntityListing, IJob, IValue, ListingResult, PutInput, PutResult, StoreInfo, VaultInfo, } from "./types"; import type { AddressComponents, UpEndWasmExtensions } from "./wasm"; import debug from "debug"; import { browser } from "./util"; const dbg = debug("upend:api"); export type { AddressComponents }; export type UpendApiError = { kind: "Unauthorized" | "HttpError" | "FetchError" | "Unknown"; message?: string; error?: Error; }; export class UpEndApi { private instanceUrl = ""; private readonly wasmExtensions: UpEndWasmExtensions | undefined = undefined; public readonly timeout: number; private queryOnceLRU = new LRU({ max: 128 }); private inFlightRequests: { [key: string]: Promise | null } = {}; private key: string | undefined; private readonly onError: ((error: UpendApiError) => void) | undefined; constructor(config?: { instanceUrl?: string; wasmExtensions?: UpEndWasmExtensions; timeout?: number; authKey?: string; onError?: (error: UpendApiError) => void; }) { this.setInstanceUrl(config?.instanceUrl || "http://localhost:8093"); this.wasmExtensions = config?.wasmExtensions; this.timeout = config?.timeout || 30_000; this.key = config?.authKey; this.onError = config?.onError; } public setInstanceUrl(apiUrl: string) { this.instanceUrl = apiUrl.replace(/\/+$/g, ""); } public get apiUrl() { return this.instanceUrl + "/api"; } public async fetchEntity( address: string, options?: ApiFetchOptions, ): Promise { dbg("Fetching Entity %s", address); const entityFetch = await this.fetch( `${this.apiUrl}/obj/${address}`, options, ); const entityResult = (await entityFetch.json()) as EntityListing; const entityListing = new UpListing(entityResult.entries); return entityListing.getObject(address); } public async fetchEntry(address: string, options?: ApiFetchOptions) { dbg("Fetching entry %s", address); const response = await this.fetch(`${this.apiUrl}/raw/${address}`, options); const data = await response.json(); const listing = new UpListing({ address: data }); return listing.entries[0]; } public async query( query: string | Query, options?: ApiFetchOptions, ): Promise { const queryStr = query.toString(); const cacheResult = this.queryOnceLRU.get(queryStr); if (!cacheResult) { if (!this.inFlightRequests[queryStr]) { dbg(`Querying: ${query}`); this.inFlightRequests[queryStr] = new Promise((resolve, reject) => { this.fetch(`${this.apiUrl}/query`, options, { method: "POST", body: queryStr, keepalive: true, }) .then(async (response) => { if (!response.ok) { reject( `Query ${queryStr} failed: ${response.status} ${ response.statusText }: ${await response.text()}}`, ); } resolve(new UpListing(await response.json())); this.inFlightRequests[queryStr] = null; }) .catch((err) => reject(err)); }); } else { dbg(`Chaining request for ${queryStr}...`); } return await (this.inFlightRequests[queryStr] as Promise); // TODO? } else { dbg(`Returning cached: ${queryStr}`); return cacheResult; } } public async putEntry( input: PutInput, options?: ApiFetchOptions, ): Promise { dbg("Putting %O", input); const response = await this.fetch(`${this.apiUrl}/obj`, options, { headers: { "Content-Type": "application/json" }, method: "PUT", body: JSON.stringify(input), }); return await response.json(); } public async putEntityAttribute( entity: Address, attribute: string, value: IValue, provenance?: string, options?: ApiFetchOptions, ): Promise
{ dbg("Putting %s = %o for %s (%s)", attribute, value, entity, provenance); let url = `${this.apiUrl}/obj/${entity}/${attribute}`; if (provenance) { url += `?provenance=${provenance}`; } const response = await this.fetch(url, options, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(value), }); return await response.json(); } public async putBlob( fileOrUrl: File | URL, options?: ApiFetchOptions & { onProgress?: (ev: ProgressEvent) => void }, ): Promise
{ dbg("Putting Blob: %O", fileOrUrl); const formData = new FormData(); if (fileOrUrl instanceof File) { formData.append(fileOrUrl.name, fileOrUrl); } else { formData.append("@url", fileOrUrl.toString()); } const signal = this.getAbortSignal(options); if (browser && fileOrUrl instanceof File) { dbg("Using XHR for file upload"); const xhrdbg = debug("upend:api:xhr"); const xhr = new XMLHttpRequest(); signal.addEventListener("abort", () => xhr.abort()); for (const event of [ "loadstart", "load", "loadend", "progress", "abort", "error", ] as const) { xhr.addEventListener(event, (ev) => xhrdbg(`XHR ${event}: %O`, ev)); xhr.upload.addEventListener(event, (ev) => xhrdbg(`XHR upload ${event}: %O`, ev), ); if (options?.onProgress) { xhr.upload.addEventListener(event, options.onProgress); } } return new Promise((resolve, reject) => { xhr.open("PUT", `${this.apiUrl}/blob`, true); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(e); } } else { reject(xhr.statusText); } }; xhr.send(formData); }); } else { const response = await this.fetch(`${this.apiUrl}/blob`, options, { method: "PUT", body: formData, }); if (!response.ok) { throw Error(await response.text()); } return await response.json(); } } public async deleteEntry( address: Address, options?: ApiFetchOptions, ): Promise { dbg("Deleting entry %s", address); await this.fetch(`${this.apiUrl}/obj/${address}`, options, { method: "DELETE", }); } public getRaw( address: Address, config?: { preview?: boolean; authenticated?: boolean }, ) { let result = `${this.apiUrl}/${config?.preview ? "thumb" : "raw"}/${address}`; if (config?.authenticated) { result += `?key=${this.key}`; } return result; } public async fetchRaw( address: Address, preview = false, options?: ApiFetchOptions, ) { dbg("Getting %s raw (preview = %s)", address, preview); return await this.fetch(this.getRaw(address, { preview }), options); } public async refreshVault(options?: ApiFetchOptions) { dbg("Triggering vault refresh"); return await this.fetch(`${this.apiUrl}/refresh`, options, { method: "POST", }); } public async nativeOpen(address: Address, options?: ApiFetchOptions) { dbg("Opening %s natively", address); return this.fetch(`${this.apiUrl}/raw/${address}?native=1`, options); } public async fetchRoots(options?: ApiFetchOptions): Promise { dbg("Fetching hierarchical roots..."); const response = await this.fetch(`${this.apiUrl}/hier_roots`, options); const roots = await response.json(); dbg("Hierarchical roots: %O", roots); return roots; } public async fetchJobs(options?: ApiFetchOptions): Promise { // dbg("Fetching jobs..."); const response = await this.fetch(`${this.apiUrl}/jobs`, options); return await response.json(); } public async fetchAllAttributes( options?: ApiFetchOptions, ): Promise { dbg("Fetching all attributes..."); const response = await this.fetch(`${this.apiUrl}/all/attributes`, options); const result = await response.json(); dbg("All attributes: %O", result); return await result; } public async fetchInfo(options?: ApiFetchOptions): Promise { dbg("Fetching vault info..."); const response = await this.fetch(`${this.apiUrl}/info`, options); const result = await response.json(); dbg("Vault info: %O", result); return result; } public async fetchOptions(options?: ApiFetchOptions): Promise { dbg("Fetching vault options..."); const response = await this.fetch(`${this.apiUrl}/options`, options); const result = await response.json(); dbg("Vault options: %O", result); return result; } public async fetchStoreInfo( options?: ApiFetchOptions, ): Promise<{ [key: string]: StoreInfo }> { dbg("Fetching store info..."); const response = await this.fetch(`${this.apiUrl}/stats/store`, options); const result = await response.json(); dbg("Store info: %O"); return await result; } public async getAddress( input: { urlContent: string } | ADDRESS_TYPE, options?: ApiFetchOptions, ): Promise { let response: Response; if (typeof input === "string") { if (this.wasmExtensions) { await this.wasmExtensions.init(); return this.wasmExtensions.AddressTypeConstants[input]; } response = await this.fetch( `${this.apiUrl}/address?type=${input}`, options, ); } else { if ("urlContent" in input) { response = await this.fetch( `${this.apiUrl}/address?url_content=${input.urlContent}`, options, ); } else { throw new Error("Input cannot be empty."); } } const result = await response.json(); dbg("Address for %o = %s", input, result); return result; } public async addressToComponents( address: string, ): Promise { if (!this.wasmExtensions) { throw new Error("WASM extensions not supplied."); } await this.wasmExtensions.init(); return this.wasmExtensions.addr_to_components(address); } public async componentsToAddress( components: AddressComponents, ): Promise { if (!this.wasmExtensions) { throw new Error("WASM extensions not initialized."); } await this.wasmExtensions.init(); return this.wasmExtensions.components_to_addr(components); } public async getVaultOptions( options?: ApiFetchOptions, ): Promise { const response = await this.fetch(`${this.apiUrl}/options`, options); return await response.json(); } public async setVaultOptions( options: VaultOptions, apiOptions?: ApiFetchOptions, ): Promise { const payload: Record = {}; if (options.blob_mode) { const blob_mode: Record = {}; blob_mode[options.blob_mode] = null; payload["blob_mode"] = blob_mode; } const response = await this.fetch(`${this.apiUrl}/options`, apiOptions, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { throw Error(await response.text()); } } public async authenticate( credentials: { username: string; password: string; }, mode: "key", options?: ApiFetchOptions, ): Promise<{ key: string }>; public async authenticate( credentials: { username: string; password: string; }, mode?: "cookie", options?: ApiFetchOptions, ): Promise; public async authenticate( credentials: { username: string; password: string; }, mode: "key" | "cookie" | undefined, options?: ApiFetchOptions, ): Promise<{ key: string } | void> { const via = mode || "cookie"; const response = await this.fetch( `${this.apiUrl}/auth/login?via=${via}`, options, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentials), }, ); if (!response.ok) { throw Error(await response.text()); } if (mode === "key") { const data = await response.json(); if (!data.key) { throw Error("No key returned from server."); } this.key = data.key; return data.key; } } public async register(credentials: { username: string; password: string; }): Promise { await this.fetch(`${this.apiUrl}/auth/register`, undefined, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentials), }); } public async authStatus( options?: ApiFetchOptions, ): Promise<{ user: string } | undefined> { const response = await this.fetch(`${this.apiUrl}/auth/whoami`, options); return await response.json(); } public async resetAuth(mode: "key"): Promise; public async resetAuth( mode?: "cookie", options?: ApiFetchOptions, ): Promise; public async resetAuth( mode?: "key" | "cookie", options?: ApiFetchOptions, ): Promise { if (mode === "key") { this.key = undefined; } else { await this.fetch(`${this.apiUrl}/auth/logout`, options, { method: "POST", }); } } private getAbortSignal(options: ApiFetchOptions | undefined) { const controller = options?.abortController || new AbortController(); const timeout = options?.timeout || this.timeout; if (timeout > 0) { setTimeout(() => controller.abort(), timeout); } return controller.signal; } private async fetch( url: string, options: ApiFetchOptions | undefined, requestInit?: RequestInit & { headers?: Record }, ): Promise { const signal = this.getAbortSignal(options); const headers = requestInit?.headers || {}; if (this.key) { headers["Authorization"] = `Bearer ${this.key}`; } let result: Response; let error: UpendApiError | undefined; try { result = await fetch(url, { ...requestInit, signal, headers, }); if (!result.ok) { if (result.status === 401) { error = { kind: "Unauthorized", message: await result.text() }; } else { error = { kind: "HttpError", message: `HTTP Error ${result.status}: ${result.statusText}`, }; } } } catch (e) { error = { kind: "FetchError", error: e as Error }; } if (error) { if (this.onError) { this.onError(error); } throw error; } return result!; } } export interface ApiFetchOptions { timeout?: number; abortController?: AbortController; } export type VaultBlobMode = "Flat" | "Mirror" | "Incoming"; export interface VaultOptions { blob_mode: VaultBlobMode; }