refactor: major refactor to use openapi via orval

This commit is contained in:
Derrick Hammer 2024-02-12 23:25:48 -05:00
parent dd98f51f4b
commit 10e41875e6
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
14 changed files with 3191 additions and 824 deletions

View File

@ -5,7 +5,8 @@
"config": {
"tsconfig": {
"compilerOptions": {
"module": "nodenext"
"module": "nodenext",
"moduleResolution": "node"
}
}
}

1953
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

18
orval.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from "orval";
export default defineConfig({
s5: {
input: "./swagger.yaml",
output: {
mode: "split",
workspace: "./src/generated",
target: "openapi.ts",
override: {
mutator: {
path: "../axios.ts",
name: "customInstance",
},
},
},
},
});

View File

@ -11,6 +11,7 @@
"devDependencies": {
"@lumeweb/node-library-preset": "^0.2.7",
"@types/ws": "^8.5.10",
"orval": "^6.24.0",
"presetter": "*"
},
"readme": "ERROR: No README data found!",

31
src/axios.ts Normal file
View File

@ -0,0 +1,31 @@
import Axios, { AxiosRequestConfig } from "axios";
export const customInstance = <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig,
): Promise<T> => {
const source = Axios.CancelToken.source();
/*
Hack to ensure that the data is passed to the request as an option.
*/
if (options?.data) {
config = config || {};
config.data = options.data;
delete config.data;
}
const instance = Axios.create({ baseURL: options?.baseURL });
const promise = instance({
...config,
...options,
cancelToken: source.token,
}).then(({ data }) => data);
// @ts-ignore
promise.cancel = () => {
source.cancel("Query was cancelled");
};
return promise;
};

View File

@ -23,294 +23,74 @@ import {
getMetadata,
} from "./methods/download.js";
import { defaultPortalUrl, ensureUrl } from "./utils/url.js";
import { ensureUrl } from "./utils/url.js";
import {
buildRequestHeaders,
buildRequestUrl,
ExecuteRequestError,
Headers,
} from "./request.js";
import {
createEntry,
getEntry,
publishEntry,
subscribeToEntry,
} from "./methods/registry.js";
/**
* 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>;
};
import { CustomClientOptions } from "#utils/options.js";
import { throwValidationError } from "#utils/validation.js";
/**
* The S5 Client which can be used to access S5-net.
*/
export class S5Client {
customOptions: CustomClientOptions;
private httpClient = axios.create();
// 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;
protected uploadWebappRequest = uploadWebappRequest;
// Set methods (defined in other files).
uploadWebapp = uploadWebapp;
// Download
downloadFile = downloadFile;
downloadData = downloadData;
getCidUrl = getCidUrl;
getMetadata = getMetadata;
// Registry
subscribeToEntry = subscribeToEntry;
publishEntry = publishEntry;
createEntry = createEntry;
getEntry = getEntry;
// Download
protected uploadSmallFile = uploadSmallFile;
protected uploadSmallFileRequest = uploadSmallFileRequest;
protected uploadLargeFile = uploadLargeFile;
protected uploadLargeFileRequest = uploadLargeFileRequest;
protected uploadDirectoryRequest = uploadDirectoryRequest;
protected uploadWebappRequest = uploadWebappRequest;
/**
* 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 [portalUrl] 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));
},
);
}
/* 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);
} 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"];
constructor(portalUrl: string, customOptions: CustomClientOptions = {}) {
if (!portalUrl) {
throw new Error("Could not get portal URL for the given portal");
throwValidationError("portalUrl", portalUrl, "parameter", "string");
}
return portalUrl;
this._portalUrl = ensureUrl(portalUrl);
this._customOptions = customOptions;
}
private _customOptions: CustomClientOptions;
get customOptions(): CustomClientOptions {
return this._customOptions;
}
private _portalUrl: string;
get portalUrl(): string {
return this._portalUrl;
}
public static create(
portalUrl: string,
customOptions: CustomClientOptions = {},
) {
return new S5Client(portalUrl, customOptions);
}
}

View File

@ -1,8 +1,10 @@
import { ResponseType } from "axios";
import { S5Client } from "../client.js";
import { BaseCustomOptions, DEFAULT_BASE_OPTIONS } from "../utils/options.js";
import { CustomClientOptions, optionsToConfig } from "../utils/options.js";
import path from "path";
import { DEFAULT_UPLOAD_OPTIONS } from "#methods/upload.js";
import { getS5DownloadCid, getS5MetadataCid } from "#generated/index.js";
/**
* Custom download options.
@ -14,18 +16,13 @@ import path from "path";
* @property [responseType] - The response type.
* @property [subdomain=false] - Whether to return the final cid in subdomain format.
*/
export type CustomDownloadOptions = BaseCustomOptions & {
endpointDownload?: string;
download?: boolean;
export type CustomDownloadOptions = CustomClientOptions & {
path?: string;
range?: string;
responseType?: ResponseType;
subdomain?: string;
};
export type CustomGetMetadataOptions = BaseCustomOptions & {
endpointGetMetadata?: string;
};
export type CustomGetMetadataOptions = CustomClientOptions & {};
/**
* The response for a get metadata request.
@ -34,24 +31,17 @@ export type CustomGetMetadataOptions = BaseCustomOptions & {
* @property portalUrl - The URL of the portal.
* @property cid - 46-character cid.
*/
export type GetMetadataResponse = {
export type MetadataResult = {
metadata: Record<string, unknown>;
};
export const DEFAULT_DOWNLOAD_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointDownload: "/",
download: false,
path: undefined,
range: undefined,
responseType: undefined,
subdomain: "",
};
const DEFAULT_GET_METADATA_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointGetMetadata: "/s5/metadata",
};
const DEFAULT_GET_METADATA_OPTIONS = {};
/**
* Initiates a download of the content of the cid within the browser.
@ -68,14 +58,7 @@ export async function downloadFile(
cid: string,
customOptions?: CustomDownloadOptions,
): Promise<string> {
const opts = {
...DEFAULT_DOWNLOAD_OPTIONS,
...this.customOptions,
...customOptions,
download: true,
};
const url = await this.getCidUrl(cid, opts);
const url = await this.getCidUrl(cid, customOptions);
// Download the url.
window.location.assign(url);
@ -96,18 +79,9 @@ export async function downloadFile(
export async function getCidUrl(
this: S5Client,
cid: string,
customOptions?: CustomDownloadOptions,
customOptions: CustomDownloadOptions = {},
): Promise<string> {
const opts = {
...DEFAULT_DOWNLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
console.log(opts);
const portalUrl = await this.portalUrl();
return path.join(portalUrl, cid);
return path.join(this.portalUrl, cid);
}
/**
@ -123,35 +97,30 @@ export async function getCidUrl(
export async function getMetadata(
this: S5Client,
cid: string,
customOptions?: CustomGetMetadataOptions,
): Promise<GetMetadataResponse> {
const opts = {
...DEFAULT_GET_METADATA_OPTIONS,
...this.customOptions,
...customOptions,
};
customOptions: CustomGetMetadataOptions = {},
): Promise<MetadataResult> {
const config = optionsToConfig(
this,
DEFAULT_GET_METADATA_OPTIONS,
customOptions,
);
const response = await this.executeRequest({
...opts,
method: "get",
endpointPath: opts.endpointGetMetadata,
extraPath: cid,
});
const response = await getS5MetadataCid(cid, config);
return { metadata: response.data };
return { metadata: response };
}
/**
* Downloads in-memory data from a S5 cid.
* @param this - S5Client
* @param cid - 46-character cid, or a valid cid URL. Can be followed by a path. Note that the cid will not be encoded, so if your path might contain special characters, consider using `customOptions.path`.
* @param cid - 46-character cid, or a valid cid URL.
* @param [customOptions] - Additional settings that can optionally be set.
* @returns - The data
*/
export async function downloadData(
this: S5Client,
cid: string,
customOptions?: CustomDownloadOptions,
customOptions: CustomDownloadOptions = {},
): Promise<ArrayBuffer> {
const opts = {
...DEFAULT_DOWNLOAD_OPTIONS,
@ -160,12 +129,12 @@ export async function downloadData(
download: true,
};
const response = await this.executeRequest({
...opts,
method: "get",
extraPath: cid,
responseType: "arraybuffer",
});
const config = optionsToConfig(this, DEFAULT_UPLOAD_OPTIONS, customOptions);
return response.data;
return await (
await getS5DownloadCid(cid, {
...config,
responseType: "arraybuffer",
})
).arrayBuffer();
}

View File

@ -1,9 +1,7 @@
import { DEFAULT_BASE_OPTIONS } from "../utils/options.js";
import { CustomClientOptions, S5Client } from "../client.js";
import { S5Client } from "../client.js";
import { ensureBytes, equalBytes } from "@noble/curves/abstract/utils";
import WS from "isomorphic-ws";
import { buildRequestUrl, ExecuteRequestError } from "#request.js";
import {
CID,
createKeyPair,
@ -22,27 +20,29 @@ import { throwValidationError } from "../utils/validation.js";
import { base64url } from "multiformats/bases/base64";
import { concatBytes } from "@noble/hashes/utils";
import { CID_HASH_TYPES } from "@lumeweb/libs5/lib/constants.js";
import { CustomClientOptions, optionsToConfig } from "#utils/options.js";
import { buildRequestUrl } from "#request.js";
import {
getS5Registry,
postS5Registry,
type RegistrySetRequest,
} from "#generated/index.js";
import { DEFAULT_UPLOAD_OPTIONS } from "#methods/upload.js";
import { AxiosError } from "axios";
export const DEFAULT_GET_ENTRY_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointGetEntry: "/s5/registry",
};
export const DEFAULT_GET_ENTRY_OPTIONS = {};
export const DEFAULT_SET_ENTRY_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointSetEntry: "/s5/registry",
deleteForever: false,
};
export const DEFAULT_SUBSCRIBE_ENTRY_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointSubscribeEntry: "/s5/registry/subscription",
};
} as CustomRegistryOptions;
export const DEFAULT_PUBLISH_ENTRY_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointPublishEntry: "/s5/registry",
};
} as CustomRegistryOptions;
export type BaseCustomOptions = CustomClientOptions;
@ -54,7 +54,7 @@ export type CustomRegistryOptions = BaseCustomOptions & {
export async function subscribeToEntry(
this: S5Client,
publicKey: Uint8Array,
customOptions?: CustomRegistryOptions,
customOptions: CustomRegistryOptions = {},
) {
const opts = {
...DEFAULT_SUBSCRIBE_ENTRY_OPTIONS,
@ -66,7 +66,7 @@ export async function subscribeToEntry(
publicKey = concatBytes(Uint8Array.from([CID_HASH_TYPES.ED25519]), publicKey);
const url = await buildRequestUrl(this, {
baseUrl: await this.portalUrl(),
baseUrl: await this.portalUrl,
endpointPath: opts.endpointSubscribeEntry,
});
@ -104,13 +104,13 @@ const base64urlDecode = (d: string) => base64url.decode(`u${d}`);
export async function publishEntry(
this: S5Client,
signedEntry: SignedRegistryEntry,
customOptions?: CustomRegistryOptions,
customOptions: CustomRegistryOptions = {},
) {
const opts = {
...DEFAULT_PUBLISH_ENTRY_OPTIONS,
...this.customOptions,
...customOptions,
};
const config = optionsToConfig(
this,
DEFAULT_PUBLISH_ENTRY_OPTIONS,
customOptions,
);
if (!verifyRegistryEntry(signedEntry)) {
throwValidationError(
@ -121,17 +121,15 @@ export async function publishEntry(
);
}
return await this.executeRequest({
...opts,
endpointPath: opts.endpointPublishEntry,
method: "post",
data: {
return postS5Registry(
{
pk: base64urlEncode(signedEntry.pk),
revision: signedEntry.revision,
data: base64urlEncode(signedEntry.data),
signature: base64urlEncode(signedEntry.signature),
},
});
config,
);
}
export async function createEntry(
@ -188,29 +186,27 @@ export async function createEntry(
export async function getEntry(
this: S5Client,
publicKey: Uint8Array,
customOptions?: CustomRegistryOptions,
customOptions: CustomRegistryOptions = {},
) {
const opts = {
...DEFAULT_GET_ENTRY_OPTIONS,
...this.customOptions,
...customOptions,
};
const config = optionsToConfig(
this,
DEFAULT_GET_ENTRY_OPTIONS,
customOptions,
);
try {
const ret = await this.executeRequest({
...opts,
endpointPath: opts.endpointGetEntry,
method: "get",
query: {
const ret = await getS5Registry(
{
pk: base64urlEncode(publicKey),
},
});
config,
);
const signedEntry = {
pk: base64urlDecode(ret.data.pk),
revision: ret.data.revision,
data: base64urlDecode(ret.data.data),
signature: base64urlDecode(ret.data.signature),
pk: base64urlDecode(<string>ret.pk),
revision: ret.revision,
data: base64urlDecode(<string>ret.data),
signature: base64urlDecode(<string>ret.signature),
} as SignedRegistryEntry;
if (!verifyRegistryEntry(signedEntry)) {
@ -224,7 +220,7 @@ export async function getEntry(
return signedEntry;
} catch (e) {
if ((e as ExecuteRequestError).responseStatus === 404) {
if ((e as AxiosError).response?.status === 404) {
return undefined;
}

View File

@ -5,11 +5,18 @@ import { blake3 } from "@noble/hashes/blake3";
import { Buffer } from "buffer";
import { getFileMimeType } from "../utils/file.js";
import { BaseCustomOptions, DEFAULT_BASE_OPTIONS } from "../utils/options.js";
import { S5Client } from "../client.js";
import type { JsonData } from "../utils/types.js";
import { buildRequestHeaders, buildRequestUrl } from "../request.js";
import { CID, CID_HASH_TYPES, CID_TYPES } from "@lumeweb/libs5";
import {
type BasicUploadResponse,
postS5Upload,
postS5UploadDirectory,
PostS5UploadDirectoryParams,
PostS5UploadResult,
} from "#generated/index.js";
import { BaseCustomOptions } from "#methods/registry.js";
import { optionsToConfig } from "#utils/options.js";
import { buildRequestUrl } from "#request.js";
/**
* The tus chunk size is (4MiB - encryptionOverhead) * dataPieces, set as default.
@ -27,25 +34,18 @@ const DEFAULT_TUS_RETRY_DELAYS = [0, 5_000, 15_000, 60_000, 300_000, 600_000];
*/
const PORTAL_FILE_FIELD_NAME = "file";
const TUS_ENDPOINT = "/s5/upload/tus";
/**
* Custom upload options.
*
* @property [endpointUpload] - The relative URL path of the portal endpoint to contact.
* @property [endpointDirectoryUpload] - The relative URL path of the portal endpoint to contact for Directorys.
* @property [endpointLargeUpload] - The relative URL path of the portal endpoint to contact for large uploads.
* @property [customFilename] - The custom filename to use when uploading files.
* @property [largeFileSize=32943040] - The size at which files are considered "large" and will be uploaded using the tus resumable upload protocol. This is the size of one chunk by default (32 mib). Note that this does not affect the actual size of chunks used by the protocol.
* @property [errorPages] - Defines a mapping of error codes and subfiles which are to be served in case we are serving the respective error code. All subfiles referred like this must be defined with absolute paths and must exist.
* @property [retryDelays=[0, 5_000, 15_000, 60_000, 300_000, 600_000]] - An array or undefined, indicating how many milliseconds should pass before the next attempt to uploading will be started after the transfer has been interrupted. The array's length indicates the maximum number of attempts.
* @property [tryFiles] - Allows us to set a list of potential subfiles to return in case the requested one does not exist or is a directory. Those subfiles might be listed with relative or absolute paths. If the path is absolute the file must exist.
*/
export type CustomUploadOptions = BaseCustomOptions & {
endpointUpload?: string;
endpointDirectoryUpload: string;
endpointLargeUpload?: string;
customFilename?: string;
errorPages?: JsonData;
errorPages?: Record<string, string>;
tryFiles?: string[];
// Large files.
@ -53,39 +53,18 @@ export type CustomUploadOptions = BaseCustomOptions & {
retryDelays?: number[];
};
/**
* The response to an upload request.
*
* @property cid - 46-character cid.
*/
export type UploadRequestResponse = {
cid: CID;
};
/**
* The response to an upload request.
*
* @property cid - 46-character cid.
*/
export type UploadTusRequestResponse = {
data: { cid: CID };
};
export const DEFAULT_UPLOAD_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointUpload: "/s5/upload",
endpointDirectoryUpload: "/s5/upload/directory",
endpointLargeUpload: "/s5/upload/tus",
customFilename: "",
errorPages: { 404: "/404.html" },
tryFiles: ["index.html"],
// Large files.
largeFileSize: TUS_CHUNK_SIZE,
retryDelays: DEFAULT_TUS_RETRY_DELAYS,
};
} as CustomUploadOptions;
export interface UploadResult {
cid: CID;
}
/**
* Uploads a file to S5-net.
@ -93,24 +72,21 @@ export const DEFAULT_UPLOAD_OPTIONS = {
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointUpload="/s5/upload"] - The relative URL path of the portal endpoint to contact for small uploads.
* @param [customOptions.endpointDirectoryUpload="/s5/upload/directory"] - The relative URL path of the portal endpoint to contact for Directory uploads.
* @param [customOptions.endpointLargeUpload="/s5/upload/tus"] - The relative URL path of the portal endpoint to contact for large uploads.
* @returns - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
export async function uploadFile(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
customOptions: CustomUploadOptions = {},
): Promise<any> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
} as CustomUploadOptions;
if (file.size < opts.largeFileSize) {
if (file.size < <number>opts?.largeFileSize) {
return this.uploadSmallFile(file, opts);
} else {
return this.uploadLargeFile(file, opts);
@ -123,18 +99,18 @@ export async function uploadFile(
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointUpload="/s5/upload"] - The relative URL path of the portal endpoint to contact.
* @returns - The returned cid.
* @returns UploadResult - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
export async function uploadSmallFile(
this: S5Client,
file: File,
customOptions: CustomUploadOptions,
): Promise<UploadRequestResponse> {
): Promise<UploadResult> {
const response = await this.uploadSmallFileRequest(file, customOptions);
return { cid: CID.decode(response.data.cid) };
return { cid: CID.decode(<string>response.CID) };
}
/**
@ -143,34 +119,24 @@ export async function uploadSmallFile(
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointPath="/s5/upload"] - The relative URL path of the portal endpoint to contact.
* @returns - The upload response.
* @returns PostS5UploadResult - The upload response.
*/
export async function uploadSmallFileRequest(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<AxiosResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
const formData = new FormData();
customOptions: CustomUploadOptions = {},
): Promise<PostS5UploadResult> {
const config = optionsToConfig(this, DEFAULT_UPLOAD_OPTIONS, customOptions);
file = ensureFileObjectConsistency(file);
if (opts.customFilename) {
formData.append(PORTAL_FILE_FIELD_NAME, file, opts.customFilename);
} else {
formData.append(PORTAL_FILE_FIELD_NAME, file);
}
return await this.executeRequest({
...opts,
endpointPath: opts.endpointUpload,
method: "post",
data: formData,
});
return postS5Upload(
{
file: file,
},
config,
);
}
/* istanbul ignore next */
@ -187,11 +153,9 @@ export async function uploadSmallFileRequest(
export async function uploadLargeFile(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
const response = await this.uploadLargeFileRequest(file, customOptions);
return response.data;
customOptions: CustomUploadOptions = {},
): Promise<UploadResult> {
return await this.uploadLargeFileRequest(file, customOptions);
}
/* istanbul ignore next */
@ -201,44 +165,32 @@ export async function uploadLargeFile(
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointLargeUpload="/s5/upload/tus"] - The relative URL path of the portal endpoint to contact.
* @returns - The upload response.
*/
export async function uploadLargeFileRequest(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<UploadTusRequestResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
customOptions: CustomUploadOptions = {},
): Promise<UploadResult> {
const config = optionsToConfig(this, DEFAULT_UPLOAD_OPTIONS, customOptions);
// Validation.
const url = await buildRequestUrl(this, {
endpointPath: opts.endpointLargeUpload,
endpointPath: TUS_ENDPOINT,
});
const headers = buildRequestHeaders(
undefined,
opts.customUserAgent,
opts.customCookie,
opts.s5ApiKey,
);
file = ensureFileObjectConsistency(file);
let filename = file.name;
if (opts.customFilename) {
filename = opts.customFilename;
}
const onProgress =
opts.onUploadProgress &&
config.onUploadProgress &&
function (bytesSent: number, bytesTotal: number) {
const progress = bytesSent / bytesTotal;
// @ts-expect-error TS complains.
opts.onUploadProgress(progress, { loaded: bytesSent, total: bytesTotal });
config.onUploadProgress(progress, {
loaded: bytesSent,
total: bytesTotal,
});
};
const hasher = blake3.create({});
@ -282,19 +234,21 @@ export async function uploadLargeFileRequest(
}
return new Promise((resolve, reject) => {
const filename = hash
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace("=", "");
const tusOpts = {
endpoint: url,
// retryDelays: opts.retryDelays,
metadata: {
hash: hash
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace("=", ""),
filename,
hash: filename,
filename: filename,
filetype: file.type,
},
headers,
config: config.headers,
onProgress,
onBeforeRequest: function (req: HttpRequest) {
const xhr = req.getUnderlyingObject();
@ -311,7 +265,6 @@ export async function uploadLargeFileRequest(
reject(new Error("'upload.url' was not set"));
return;
}
const resCid =
"u" +
cid
@ -319,8 +272,7 @@ export async function uploadLargeFileRequest(
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace("=", "");
const resolveData = { data: { cid: CID.decode(resCid) } };
resolve(resolveData);
resolve({ cid: CID.decode(resCid) });
},
};
@ -344,15 +296,15 @@ export async function uploadDirectory(
this: S5Client,
directory: Record<string, File>,
filename: string,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
customOptions: CustomUploadOptions = {},
): Promise<UploadResult> {
const response = await this.uploadDirectoryRequest(
directory,
filename,
customOptions,
);
return { cid: CID.decode(response.data.cid) };
return { cid: CID.decode(<string>response.CID) };
}
/**
@ -362,7 +314,6 @@ export async function uploadDirectory(
* @param directory - File objects to upload, indexed by their path strings.
* @param filename - The name of the directory.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointPath="/s5/upload/directory"] - The relative URL path of the portal endpoint to contact.
* @returns - The upload response.
* @throws - Will throw if the input filename is not a string.
*/
@ -370,45 +321,44 @@ export async function uploadDirectoryRequest(
this: S5Client,
directory: Record<string, File>,
filename: string,
customOptions?: CustomUploadOptions,
): Promise<AxiosResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
customOptions: CustomUploadOptions = {},
): Promise<BasicUploadResponse> {
const config = optionsToConfig(this, DEFAULT_UPLOAD_OPTIONS, customOptions);
const formData = new FormData();
Object.entries(directory).forEach(([path, file]) => {
file = ensureFileObjectConsistency(file as File);
formData.append(path, file as File, path);
});
const query: { [key: string]: string | undefined } = { filename };
if (opts.tryFiles) {
query.tryfiles = JSON.stringify(opts.tryFiles);
}
if (opts.errorPages) {
query.errorpages = JSON.stringify(opts.errorPages);
for (const entry in directory) {
const file = ensureFileObjectConsistency(directory[entry]);
formData.append(entry, file, entry);
}
return await this.executeRequest({
...opts,
endpointPath: opts.endpointDirectoryUpload,
method: "post",
data: formData,
query,
});
const params = {} as PostS5UploadDirectoryParams;
if (customOptions.tryFiles) {
params.tryFiles = customOptions.tryFiles;
}
if (customOptions.errorPages) {
params.errorPages = customOptions.errorPages;
}
params.name = filename;
/*
Hack to pass the data right since OpenAPI doesn't support variable file inputs without knowing the names ahead of time.
*/
config.data = formData;
return postS5UploadDirectory({}, params, config);
}
export async function uploadWebapp(
this: S5Client,
directory: Record<string, File>,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
customOptions: CustomUploadOptions = {},
): Promise<UploadResult> {
const response = await this.uploadWebappRequest(directory, customOptions);
return { cid: CID.decode(response.data.cid) };
return { cid: CID.decode(<string>response.CID) };
}
/**
@ -423,40 +373,9 @@ export async function uploadWebapp(
export async function uploadWebappRequest(
this: S5Client,
directory: Record<string, File>,
customOptions?: CustomUploadOptions,
): Promise<AxiosResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
const formData = new FormData();
Object.entries(directory).forEach(([path, file]) => {
file = ensureFileObjectConsistency(file as File);
formData.append(path, file as File, path);
});
const query: { [key: string]: string | undefined } = { name: "" };
if (opts.tryFiles) {
query.tryfiles = JSON.stringify(opts.tryFiles);
} else {
query.tryfiles = JSON.stringify(["index.html"]);
}
if (opts.errorPages) {
query.errorpages = JSON.stringify(opts.errorPages);
} else {
query.errorpages = JSON.stringify({ 404: "/404.html" });
}
return await this.executeRequest({
...opts,
endpointPath: opts.endpointDirectoryUpload,
method: "post",
data: formData,
query,
});
customOptions: CustomUploadOptions = {},
): Promise<BasicUploadResponse> {
return this.uploadDirectoryRequest(directory, "webapp", customOptions);
}
/**

View File

@ -10,35 +10,6 @@ import {
export type Headers = { [key: string]: string };
/**
* Helper function that builds the request headers.
*
* @param [baseHeaders] - Any base headers.
* @param [customUserAgent] - A custom user agent to set.
* @param [customCookie] - A custom cookie.
* @param [s5ApiKey] - Authentication key to use for a S5 portal.
* @returns - The built headers.
*/
export function buildRequestHeaders(
baseHeaders?: Headers,
customUserAgent?: string,
customCookie?: string,
s5ApiKey?: string,
): Headers {
const returnHeaders = { ...baseHeaders };
// Set some headers from common options.
if (customUserAgent) {
returnHeaders["User-Agent"] = customUserAgent;
}
if (customCookie) {
returnHeaders["Cookie"] = customCookie;
}
if (s5ApiKey) {
returnHeaders["S5-Api-Key"] = s5ApiKey;
}
return returnHeaders;
}
/**
* Helper function that builds the request URL. Ensures that the final URL
* always has a protocol prefix for consistency.
@ -66,7 +37,7 @@ export async function buildRequestUrl(
// Get the base URL, if not passed in.
if (!parts.baseUrl) {
url = await client.portalUrl();
url = await client.portalUrl;
} else {
url = parts.baseUrl;
}
@ -89,139 +60,3 @@ export async function buildRequestUrl(
return url;
}
/**
* The error type returned by the SDK whenever it makes a network request
* (internally, this happens in `executeRequest`). It implements, so is
* compatible with, `AxiosError`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ExecuteRequestError<T = any, D = any>
extends Error
implements AxiosError
{
originalError: AxiosError;
responseStatus: number | null;
responseMessage: string | null;
// Properties required by `AxiosError`.
config: InternalAxiosRequestConfig<D>;
code?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
request?: any;
response?: AxiosResponse<T, D>;
isAxiosError: boolean;
// eslint-disable-next-line @typescript-eslint/ban-types
toJSON: () => object;
/**
* Creates an `ExecuteRequestError`.
*
* @param message - The error message.
* @param axiosError - The original Axios error.
* @param responseStatus - The response status, if found in the original error.
* @param responseMessage - The response message, if found in the original error.
*/
constructor(
message: string,
axiosError: AxiosError<T, D>,
responseStatus: number | null,
responseMessage: string | null,
) {
// Include this check since `ExecuteRequestError` implements `AxiosError`,
// but we only expect original errors from Axios here. Anything else
// indicates a likely developer/logic bug.
if (axiosError instanceof ExecuteRequestError) {
throw new Error(
"Could not instantiate an `ExecuteRequestError` from an `ExecuteRequestError`, an original error from axios was expected",
);
}
// Set `Error` fields.
super(message);
this.name = "ExecuteRequestError";
// Set `ExecuteRequestError` fields.
this.originalError = axiosError;
this.responseStatus = responseStatus;
this.responseMessage = responseMessage;
// Set properties required by `AxiosError`.
//
// NOTE: `Object.assign` doesn't work because Typescript can't detect that
// required fields are set in this constructor.
if (!axiosError.config) {
throw new Error("axiosError.config is undefined");
}
this.config = axiosError.config;
this.code = axiosError.code;
this.request = axiosError.request;
this.response = axiosError.response;
this.isAxiosError = axiosError.isAxiosError;
this.toJSON = axiosError.toJSON;
// Required for `instanceof` to work.
Object.setPrototypeOf(this, ExecuteRequestError.prototype);
}
/**
* Gets the full, descriptive error response returned from the portal.
*
* @param err - The Axios error.
* @returns - A new error if the error response is malformed, or the error message otherwise.
*/
static From(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
err: AxiosError<any, any>,
): ExecuteRequestError {
/* istanbul ignore next */
if (!err.response) {
return new ExecuteRequestError(
`Error response did not contain expected field 'response'.`,
err,
null,
null,
);
}
/* istanbul ignore next */
if (!err.response.status) {
return new ExecuteRequestError(
`Error response did not contain expected field 'response.status'.`,
err,
null,
null,
);
}
const status = err.response.status;
// If we don't get an error message, just return the status code.
/* istanbul ignore next */
if (!err.response.data) {
return new ExecuteRequestError(
`Request failed with status code ${status}`,
err,
status,
null,
);
}
/* istanbul ignore next */
if (!err.response.data.message) {
return new ExecuteRequestError(
`Request failed with status code ${status}`,
err,
status,
null,
);
}
// Return the error message. Pass along the original Axios error.
return new ExecuteRequestError(
`Request failed with status code ${err.response.status}: ${err.response.data.message}`,
err,
status,
err.response.data.message,
);
}
}

View File

@ -1,21 +1,80 @@
import { CustomClientOptions } from "../client.js";
import { AxiosProgressEvent, AxiosRequestConfig } from "axios";
import { S5Client } from "../client.js";
// TODO: Unnecessary, remove.
/**
* Base custom options for methods hitting the API.
* Custom client options.
*
* @property [ApiKey] - Authentication password to use for a single S5 node/portal.
* @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.
*/
export type BaseCustomOptions = CustomClientOptions;
// TODO: Move to client.ts.
/**
* The default base custom options.
*/
export const DEFAULT_BASE_OPTIONS = {
APIKey: "",
s5ApiKey: "",
customUserAgent: "",
customCookie: "",
onDownloadProgress: undefined,
onUploadProgress: undefined,
loginFn: undefined,
export type CustomClientOptions = {
ApiKey?: string;
customUserAgent?: string;
customCookie?: string;
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
};
export function optionsToConfig(
client: S5Client,
def: CustomClientOptions,
...options: CustomClientOptions[]
): AxiosRequestConfig {
const config: AxiosRequestConfig = {};
config.baseURL = client.portalUrl;
const extraOptions = options.reduce((acc, val) => {
return {
...acc,
...val,
};
}, options);
const finalOptions = {
...def,
...client.customOptions,
...extraOptions,
};
if (finalOptions?.onDownloadProgress) {
config.onDownloadProgress = finalOptions?.onDownloadProgress;
}
if (finalOptions?.onUploadProgress) {
config.onUploadProgress = finalOptions?.onUploadProgress;
}
if (finalOptions?.customCookie) {
config.headers = {
Cookie: finalOptions?.customCookie,
};
}
if (finalOptions?.customUserAgent) {
config.headers = {
...config.headers,
"User-Agent": finalOptions?.customUserAgent,
};
}
if (finalOptions?.ApiKey) {
config.headers = {
...config.headers,
Authorization: `Bearer ${finalOptions?.ApiKey}`,
};
config.withCredentials = true;
config.params = {
...config.params,
auth_token: finalOptions?.ApiKey,
};
}
return config;
}

View File

@ -1 +0,0 @@
export type JsonData = Record<string, unknown>;

View File

@ -4,31 +4,8 @@ import parse from "url-parse";
import { trimSuffix } from "./string.js";
import { throwValidationError } from "./validation.js";
export const DEFAULT_S5_PORTAL_URL = "https://localhost:5522";
/**
* @deprecated please use DEFAULT_S5_PORTAL_URL.
*/
export const defaultS5PortalUrl = DEFAULT_S5_PORTAL_URL;
export const URI_S5_PREFIX = "s5://";
/**
* @deprecated please use URI_S5_PREFIX.
*/
export const uriS5Prefix = URI_S5_PREFIX;
/**
* Returns the default portal URL.
*
* @returns - The portal URL.
*/
export function defaultPortalUrl(): string {
/* istanbul ignore next */
if (typeof window === "undefined") return "/"; // default to path root on ssr
return window.location.origin;
}
/**
* Adds a subdomain to the given URL.
*

1003
swagger.yaml Normal file

File diff suppressed because it is too large Load Diff