upend/sdks/js/src/api.ts

543 lines
15 KiB
TypeScript

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<string, UpListing>({ max: 128 });
private inFlightRequests: { [key: string]: Promise<UpListing> | 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<UpObject> {
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<UpListing> {
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<UpListing>); // TODO?
} else {
dbg(`Returning cached: ${queryStr}`);
return cacheResult;
}
}
public async putEntry(
input: PutInput,
options?: ApiFetchOptions,
): Promise<PutResult> {
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<Address> {
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<Address> {
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<void> {
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 += `?auth_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<ListingResult> {
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<IJob[]> {
// dbg("Fetching jobs...");
const response = await this.fetch(`${this.apiUrl}/jobs`, options);
return await response.json();
}
public async fetchAllAttributes(
options?: ApiFetchOptions,
): Promise<AttributeListingResult> {
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<VaultInfo> {
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<VaultOptions> {
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<string> {
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<AddressComponents> {
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<string> {
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<VaultOptions> {
const response = await this.fetch(`${this.apiUrl}/options`, options);
return await response.json();
}
public async setVaultOptions(
options: VaultOptions,
apiOptions?: ApiFetchOptions,
): Promise<void> {
const payload: Record<string, unknown> = {};
if (options.blob_mode) {
const blob_mode: Record<string, unknown> = {};
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<void>;
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<void> {
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<void>;
public async resetAuth(
mode?: "cookie",
options?: ApiFetchOptions,
): Promise<void>;
public async resetAuth(
mode?: "key" | "cookie",
options?: ApiFetchOptions,
): Promise<void> {
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<string, string> },
): Promise<Response> {
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;
}