s5-js/src/client.ts

317 lines
10 KiB
TypeScript
Raw Normal View History

2023-12-10 20:03:56 +00:00
import axios, {
AxiosError,
AxiosProgressEvent,
AxiosRequestConfig,
} from "axios";
import type { AxiosResponse, ResponseType, Method } from "axios";
import {
uploadFile,
uploadLargeFile,
uploadDirectory,
uploadDirectoryRequest,
uploadSmallFile,
uploadSmallFileRequest,
uploadLargeFileRequest,
2023-12-12 02:53:10 +00:00
uploadWebapp,
uploadWebappRequest,
} from "./methods/upload.js";
2023-12-11 04:33:55 +00:00
import {
downloadData,
downloadFile,
getCidUrl,
getMetadata,
} from "./methods/download.js";
2023-12-10 20:03:56 +00:00
2023-12-10 20:22:21 +00:00
import { defaultPortalUrl, ensureUrl } from "./utils/url.js";
2023-12-10 20:03:56 +00:00
import {
buildRequestHeaders,
buildRequestUrl,
ExecuteRequestError,
Headers,
2023-12-10 20:27:24 +00:00
} from "./request.js";
2023-12-12 03:44:43 +00:00
import {
createEntry,
getEntry,
2023-12-12 03:44:43 +00:00
publishEntry,
subscribeToEntry,
} from "./methods/registry.js";
2023-12-10 20:03:56 +00:00
/**
* Custom client options.
*
* @property [APIKey] - Authentication password to use for a single S5 node.
* @property [s5ApiKey] - Authentication API key to use for a S5 portal (sets the "S5-Api-Key" header).
* @property [customUserAgent] - Custom user agent header to set.
* @property [customCookie] - Custom cookie header to set. WARNING: the Cookie header cannot be set in browsers. This is meant for usage in server contexts.
* @property [onDownloadProgress] - Optional callback to track download progress.
* @property [onUploadProgress] - Optional callback to track upload progress.
* @property [loginFn] - A function that, if set, is called when a 401 is returned from the request before re-trying the request.
*/
export type CustomClientOptions = {
APIKey?: string;
s5ApiKey?: string;
customUserAgent?: string;
customCookie?: string;
onDownloadProgress?: (progress: number, event: ProgressEvent) => void;
onUploadProgress?: (progress: number, event: ProgressEvent) => void;
loginFn?: (config?: RequestConfig) => Promise<void>;
};
/**
* Config options for a single request.
*
* @property endpointPath - The endpoint to contact.
* @property [data] - The data for a POST request.
* @property [url] - The full url to contact. Will be computed from the portalUrl and endpointPath if not provided.
* @property [method] - The request method.
* @property [headers] - Any request headers to set.
* @property [subdomain] - An optional subdomain to add to the URL.
* @property [query] - Query parameters.
* @property [extraPath] - An additional path to append to the URL, e.g. a 46-character cid.
* @property [responseType] - The response type.
* @property [transformRequest] - A function that allows manually transforming the request.
* @property [transformResponse] - A function that allows manually transforming the response.
*/
export type RequestConfig = CustomClientOptions & {
endpointPath?: string;
data?: FormData | Record<string, unknown>;
url?: string;
method?: Method;
headers?: Headers;
subdomain?: string;
query?: { [key: string]: string | undefined };
extraPath?: string;
responseType?: ResponseType;
transformRequest?: (data: unknown) => string;
transformResponse?: (data: string) => Record<string, unknown>;
};
/**
* The S5 Client which can be used to access S5-net.
*/
export class S5Client {
customOptions: CustomClientOptions;
private httpClient = axios.create();
2023-12-10 20:03:56 +00:00
// The initial portal URL, the value of `defaultPortalUrl()` if `new
// S5Client` is called without a given portal. This initial URL is used to
// resolve the final portal URL.
protected initialPortalUrl: string;
// The resolved API portal URL. The request won't be made until needed, or
// `initPortalUrl()` is called. The request is only made once, for all S5
// Clients.
protected static resolvedPortalUrl?: Promise<string>;
// The custom portal URL, if one was passed in to `new S5Client()`.
protected customPortalUrl?: string;
// Set methods (defined in other files).
// Upload
uploadFile = uploadFile;
protected uploadSmallFile = uploadSmallFile;
protected uploadSmallFileRequest = uploadSmallFileRequest;
protected uploadLargeFile = uploadLargeFile;
protected uploadLargeFileRequest = uploadLargeFileRequest;
uploadDirectory = uploadDirectory;
protected uploadDirectoryRequest = uploadDirectoryRequest;
2023-12-12 02:53:10 +00:00
protected uploadWebappRequest = uploadWebappRequest;
uploadWebapp = uploadWebapp;
2023-12-10 20:03:56 +00:00
// Download
downloadFile = downloadFile;
2023-12-11 04:33:55 +00:00
downloadData = downloadData;
2023-12-10 20:03:56 +00:00
getCidUrl = getCidUrl;
getMetadata = getMetadata;
2023-12-11 02:06:43 +00:00
// Registry
subscribeToEntry = subscribeToEntry;
2023-12-12 03:26:42 +00:00
publishEntry = publishEntry;
2023-12-12 03:44:43 +00:00
createEntry = createEntry;
getEntry = getEntry;
2023-12-11 02:06:43 +00:00
2023-12-10 20:03:56 +00:00
/**
* The S5 Client which can be used to access S5-net.
*
* @class
* @param [initialPortalUrl] The initial portal URL to use to access S5, if specified. A request will be made to this URL to get the actual portal URL. To use the default portal while passing custom options, pass "".
* @param [customOptions] Configuration for the client.
*/
constructor(initialPortalUrl = "", customOptions: CustomClientOptions = {}) {
if (initialPortalUrl === "") {
// Portal was not given, use the default portal URL. We'll still make a request for the resolved portal URL.
initialPortalUrl = defaultPortalUrl();
} else {
// Portal was given, don't make the request for the resolved portal URL.
this.customPortalUrl = ensureUrl(initialPortalUrl);
}
this.initialPortalUrl = initialPortalUrl;
this.customOptions = customOptions;
// Add a response interceptor so that we always return an error of type
// `ExecuteResponseError`.
this.httpClient.interceptors.response.use(
function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger.
// Do something with response data.
return response;
},
function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error.
return Promise.reject(ExecuteRequestError.From(error as AxiosError));
},
);
2023-12-10 20:03:56 +00:00
}
/* istanbul ignore next */
/**
* Make the request for the API portal URL.
*
* @returns - A promise that resolves when the request is complete.
*/
async initPortalUrl(): Promise<void> {
if (this.customPortalUrl) {
// Tried to make a request for the API portal URL when a custom URL was already provided.
return;
}
// Try to resolve the portal URL again if it's never been called or if it
// previously failed.
if (!S5Client.resolvedPortalUrl) {
S5Client.resolvedPortalUrl = this.resolvePortalUrl();
} else {
try {
await S5Client.resolvedPortalUrl;
} catch (e) {
S5Client.resolvedPortalUrl = this.resolvePortalUrl();
}
}
// Wait on the promise and throw if it fails.
await S5Client.resolvedPortalUrl;
return;
}
/* istanbul ignore next */
/**
* Returns the API portal URL. Makes the request to get it if not done so already.
*
* @returns - the portal URL.
*/
async portalUrl(): Promise<string> {
if (this.customPortalUrl) {
return this.customPortalUrl;
}
// Make the request if needed and not done so.
await this.initPortalUrl();
return await S5Client.resolvedPortalUrl!; // eslint-disable-line
}
/**
* Creates and executes a request.
*
* @param config - Configuration for the request.
* @returns - The response from axios.
* @throws - Will throw `ExecuteRequestError` if the request fails. This error contains the original Axios error.
*/
async executeRequest(config: RequestConfig): Promise<AxiosResponse> {
const params = {
method: config.method,
data: config.data,
responseType: config.responseType,
transformRequest: config.transformRequest,
transformResponse: config.transformResponse,
maxContentLength: Infinity,
maxBodyLength: Infinity,
// Allow cross-site cookies.
withCredentials: true,
} as AxiosRequestConfig;
params["url"] = await buildRequestUrl(this, {
baseUrl: config.url,
endpointPath: config.endpointPath,
subdomain: config.subdomain,
extraPath: config.extraPath,
query: config.query,
});
// Build headers.
params["headers"] = buildRequestHeaders(
config.headers,
config.customUserAgent,
config.customCookie,
config.s5ApiKey,
);
params["auth"] = config.APIKey
? { username: "", password: config.APIKey }
: undefined;
if (config.onDownloadProgress) {
params.onDownloadProgress = function (event: AxiosProgressEvent) {
// Avoid NaN for 0-byte file.
/* istanbul ignore next: Empty file test doesn't work yet. */
const progress = event.total ? event.loaded / event.total : 1;
// @ts-expect-error TS complains even though we've ensured this is defined.
config.onDownloadProgress(progress, event);
};
}
if (config.onUploadProgress) {
params.onUploadProgress = function (event: AxiosProgressEvent) {
// Avoid NaN for 0-byte file.
/* istanbul ignore next: event.total is always 0 in Node. */
const progress = event.total ? event.loaded / event.total : 1;
// @ts-expect-error TS complains even though we've ensured this is defined.
config.onUploadProgress(progress, event);
};
}
// NOTE: The error type is `ExecuteRequestError`. We set up a response
// interceptor above that does the conversion from `AxiosError`.
try {
return await this.httpClient.request(params);
2023-12-10 20:03:56 +00:00
} catch (e) {
// If `loginFn` is set and we get an Unauthorized response...
if (config.loginFn && (e as ExecuteRequestError).responseStatus === 401) {
// Try logging in again.
await config.loginFn(config);
// Unset the login function on the recursive call so that we don't try
// to login again, avoiding infinite loops.
return await this.executeRequest({ ...config, loginFn: undefined });
}
throw e;
}
}
/**
* Make a request to resolve the provided `initialPortalUrl`.
*
* @returns - The portal URL.
*/
protected async resolvePortalUrl(): Promise<string> {
const response = await this.executeRequest({
...this.customOptions,
method: "head",
url: this.initialPortalUrl,
});
if (!response.headers) {
throw new Error(
"Did not get 'headers' in response despite a successful request. Please try again and report this issue to the devs if it persists.",
);
}
const portalUrl = response.headers["s5-portal-api"];
if (!portalUrl) {
throw new Error("Could not get portal URL for the given portal");
}
return portalUrl;
}
}