import { AxiosInstance, AxiosPromise, RawAxiosRequestConfig, RawAxiosRequestHeaders } from 'axios'; import { AssetsApi, Configuration, ExportApi, OutputApi, ReqCache, ReqCustomization, ReqExport, ResAssetUploadHeaders, ResComputeExports, ResComputeOutputs, ResExport, ResGetCachedExports, ResGetCachedOutputs, ResOutput, } from './client'; import { BaseAPI, RequestArgs } from './client/base'; import { createRequestFunction, serializeDataIfNeeded, setBearerAuthToObject, } from './client/common'; import { IllegalArgumentError, ResponseError, TimeoutError } from './error'; import { contentDispositionFromFilename, sleep } from './utils'; /* Regex patterns for different asset types targeting the ShapeDiver API. */ const apiAssetExportUri = /.+\/session\/.+\/export\/.+/; const apiAssetOutputUri = /.+\/session\/.+\/output\/.+/; const apiAssetTextureUri = /.+\/session\/.+\/texture\/.+/; /* Regex patterns for different asset types targeting the ShapeDiver CDN. */ const cdnAssetUri = /.+\/cdn-asset-(exports|outputs|textures)\/.+/; const cdnAssetExportUri = /.+\/cdn-asset-exports\/.+/; const cdnAssetOutputUri = /.+\/cdn-asset-outputs\/.+/; const cdnAssetTextureUri = /.+\/cdn-asset-textures\/.+/; /* Regex patterns for direct download URIs. */ const directDownloadUri = /^(http[s]?:\/\/)?(viewer|textures|downloads)\.shapediver\.com(\/.*)?$/; /* Regex pattern for ShapeDiver no-CDN servers. */ const sdNoCdnOrigin = /-nocdn.[\w-]+.shapediver.com$/; export class UtilsApi extends BaseAPI { constructor(configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { super(configuration, basePath, axios); } /** * Upload the given file to the specified URL. * * _Note: This method does not use the `UtilsApi`'s base configuration._ * @param {string} url The target URL of the upload request. * @param {*} data The data that should be uploaded. * @param {string} contentType Indicate the original media type of the resource. * @param {string} [filename] The name of the file to be uploaded. When a filename has been specified in the request-upload call, then the same filename has to be specified for the upload as well. * @param {*} [options] Override http request option. */ public upload( url: string, data: any, contentType: string, filename?: string, options?: RawAxiosRequestConfig ): AxiosPromise { // Prepare headers for the upload. const reqHeaders: RawAxiosRequestHeaders = { Authorization: undefined, // Disable by default to avoid accidental token exposure. 'Content-Type': contentType, /* Override custom ShapeDiver headers to avoid CORS issues. */ 'X-ShapeDiver-Origin': undefined, 'X-ShapeDiver-SessionEngineId': undefined, 'X-ShapeDiver-BuildVersion': undefined, 'X-ShapeDiver-BuildDate': undefined, 'X-ShapeDiver-UserAgent': undefined, }; if (filename) reqHeaders['Content-Disposition'] = contentDispositionFromFilename(filename); const reqOptions: RawAxiosRequestConfig = { ...options }; reqOptions.headers = { ...reqHeaders, ...options?.headers }; const request = this.buildRequest('PUT', url, data, reqOptions)(); return request(); } /** * Upload the given asset to the specified ShapeDiver URL. * * _Note: This method does not use the `UtilsApi`'s base configuration._ * @param {string} url The target URL of the upload request. * @param {*} data The data that should be uploaded. * @param {ResAssetUploadHeaders} headers The headers object that was returned from the request-upload call. * @param {*} [options] Override http request option. */ public uploadAsset( url: string, data: any, headers: ResAssetUploadHeaders, options?: RawAxiosRequestConfig ): AxiosPromise { // Prepare headers for the upload. const reqHeaders: RawAxiosRequestHeaders = { Authorization: undefined, // Disable by default to avoid accidental token exposure. 'Content-Type': headers.contentType, /* Override custom ShapeDiver headers to avoid CORS issues. */ 'X-ShapeDiver-Origin': undefined, 'X-ShapeDiver-SessionEngineId': undefined, 'X-ShapeDiver-BuildVersion': undefined, 'X-ShapeDiver-BuildDate': undefined, 'X-ShapeDiver-UserAgent': undefined, }; if (headers.contentDisposition) reqHeaders['Content-Disposition'] = headers.contentDisposition; const reqOptions: RawAxiosRequestConfig = { ...options }; reqOptions.headers = { ...reqHeaders, ...options?.headers }; const request = this.buildRequest('PUT', url, data, reqOptions)(); return request(); } /** * Downloads data from the specified URL. * * You can set the response type using the `responseType` property in the `options` object. * @param {string} url The URL to download from. * @param {*} [options] Optional HTTP request overrides. * @throws {ResponseError} if the response type cannot be handled, such as when JSON parsing * fails. */ public download( url: string, options: { responseType: 'arraybuffer' } & RawAxiosRequestConfig ): AxiosPromise; public download( url: string, options: { responseType: 'json' } & RawAxiosRequestConfig ): AxiosPromise>; public download( url: string, options: { responseType: 'text' } & RawAxiosRequestConfig ): AxiosPromise; public download(url: string, options?: RawAxiosRequestConfig): AxiosPromise; public async download(url: string, options?: RawAxiosRequestConfig): AxiosPromise { const localRequestOptions = await this.buildRequestOptions(url, options ?? {}); this.disableAuthHeaderForShapeDiverUris(url, localRequestOptions); const request = this.buildRequest('GET', url, undefined, localRequestOptions)(); const res = request(); // Convert Buffer to ArrayBuffer in Node.js when responseType is 'arraybuffer' if (options?.responseType === 'arraybuffer') { return res.then((response) => { const data = response.data; // In Node.js, Axios returns a Buffer for arraybuffer responseType // Convert it to ArrayBuffer for consistency with browser behavior if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) { // Convert Buffer to ArrayBuffer by copying to a new Uint8Array const uint8Array = new Uint8Array(data); response.data = uint8Array.buffer; } return response; }); } // Validate JSON responses when responseType is 'json' if (options?.responseType === 'json') { return res.then((response) => { const data = response.data; // Throw a single error if the response is not valid JSON (object or array) if ( data === null || data === undefined || typeof data === 'string' || data instanceof ArrayBuffer || (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) || (typeof Blob !== 'undefined' && data instanceof Blob) ) { throw new ResponseError( response.status, 'Invalid JSON response: Could not parse response as JSON', 'Expected valid JSON but received incompatible response' ); } return response; }); } return res; } /** * Downloads a ShapeDiver export, output, or texture asset from the specified URL. The type of * the asset is determined by the URL and returned with the promise. * * The response type can be controlled by setting the `responseType` in the `options` object. * @param {string} url The URL of the asset to download. * @param {*} [options] Override http request option. * @throws {IllegalArgumentError} in case the URL is not a valid ShapeDiver asset URL. */ public downloadAsset( url: string, options: { responseType: 'arraybuffer' } & RawAxiosRequestConfig ): [AxiosPromise, 'export' | 'output' | 'texture']; public downloadAsset( url: string, options?: RawAxiosRequestConfig ): [AxiosPromise, 'export' | 'output' | 'texture']; public downloadAsset( url: string, options?: RawAxiosRequestConfig ): [AxiosPromise, 'export' | 'output' | 'texture'] { let type: 'output' | 'export' | 'texture'; // Check if the given URL is a valid API or CDN asset URL if (apiAssetExportUri.test(url) || cdnAssetExportUri.test(url)) type = 'export'; else if (apiAssetOutputUri.test(url) || cdnAssetOutputUri.test(url)) type = 'output'; else if (apiAssetTextureUri.test(url) || cdnAssetTextureUri.test(url)) type = 'texture'; else { throw new IllegalArgumentError( `Cannot fetch asset: Invalid URL '${url}' - Only ShapeDiver asset URLs are allowed.` ); } return [this.download(url, options) as any, type]; } /** * Helper function that downloads all ShapeDiver texture URLs directly, and redirects all other * URLs to the `AssetsApi.downloadImage` endpoint to avoid CORS issues. * * The response type can be controlled by setting the `responseType` in the `options` object. * @param {string} sessionId The session ID. * @param {string} url The URL of the image to download. * @param {*} [options] Override http request option. */ public downloadImage( sessionId: string, url: string, options: { responseType: 'arraybuffer' } & RawAxiosRequestConfig ): AxiosPromise; public downloadImage( sessionId: string, url: string, options?: RawAxiosRequestConfig ): AxiosPromise; public downloadImage( sessionId: string, url: string, options?: RawAxiosRequestConfig ): AxiosPromise { if ( apiAssetTextureUri.test(url) || cdnAssetTextureUri.test(url) || directDownloadUri.test(url) ) { // Call ShapeDiver texture-asset URLs directly return this.download(url, options) as any; } else { /* All other source URLs are called via the download-image endpoint */ const localOptions: RawAxiosRequestConfig = options ?? {}; this.disableAuthHeaderForShapeDiverUris(url, localOptions); // Use a universal base64 encoder for browser and Node.js environments const encodedUrl = typeof window !== 'undefined' && window.btoa ? window.btoa( encodeURIComponent(url).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)) ) ) : Buffer.from(url, 'utf-8').toString('base64'); return new AssetsApi(this.configuration).downloadImage( sessionId, encodedUrl, localOptions ); } } /** * Submit a customization request and wait for the result to be finished. * @param {string} sessionId The session ID. * @param {ReqCustomization} body The body of the customization request. * @param {number} [maxWaitMsec=-1] Maximum duration to wait for result (in milliseconds), pass value < 0 to disable limit. * @param {boolean} [ignoreUnknownParams=false] Allow relaxed validation of parameter * identifiers. When set to `true`, unrecognized parameters will be ignored rather than causing * an error. Defaults to `false`. * @param {*} [options] Override http request option. * @throws {TimeoutError} in case a maximum duration has been specified and is exceeded. */ public async submitAndWaitForOutput( sessionId: string, body: ReqCustomization, maxWaitMsec = -1, ignoreUnknownParams?: boolean, options?: RawAxiosRequestConfig ): Promise { const startMsec = Date.now(); const dto = ( await new OutputApi(this.configuration).computeOutputs( sessionId, body, ignoreUnknownParams, options ) ).data; const waitMsec = Date.now() - startMsec; // Reduce the total max waiting time by the amount the customization-request took maxWaitMsec = maxWaitMsec < 0 ? maxWaitMsec : Math.max(0, maxWaitMsec - waitMsec); return this.waitForOutputResult(sessionId, dto, maxWaitMsec, options); } /** * Submit an export request and wait for the result to be finished. * @param {string} sessionId The session ID. * @param {ReqExport} body The body of the export request. * @param {number} [maxWaitMsec=-1] Maximum duration to wait for result (in milliseconds), pass value < 0 to disable limit. * @param {boolean} [ignoreUnknownParams=false] Allow relaxed validation of parameter * identifiers. When set to `true`, unrecognized parameters will be ignored rather than causing * an error. Defaults to `false`. * @param {*} [options] Override http request option. * @throws {TimeoutError} in case a maximum duration has been specified and is exceeded. */ public async submitAndWaitForExport( sessionId: string, body: ReqExport, maxWaitMsec = -1, ignoreUnknownParams?: boolean, options?: RawAxiosRequestConfig ): Promise { const startMsec = Date.now(); const dto = ( await new ExportApi(this.configuration).computeExports( sessionId, body, ignoreUnknownParams, options ) ).data; const waitMsec = Date.now() - startMsec; // Reduce the total max waiting time by the amount the compute-request took maxWaitMsec = maxWaitMsec < 0 ? maxWaitMsec : Math.max(0, maxWaitMsec - waitMsec); return this.waitForExportResult(sessionId, body, dto, maxWaitMsec, options); } /** * Given a DTO resulting from a customization request, wait for the results to be finished. * @param {string} sessionId The session ID. * @param {ResComputeOutputs} dto The DTO resulting from a customization request. * @param {number} [maxWaitMsec=-1] Maximum duration to wait for result (in milliseconds), pass value < 0 to disable limit. * @param {*} [options] Override http request option. * @throws {TimeoutError} in case a maximum duration has been specified and is exceeded. */ private async waitForOutputResult( sessionId: string, dto: ResComputeOutputs, maxWaitMsec: number, options?: RawAxiosRequestConfig ): Promise { if (!dto.outputs) return dto; // Build new cache request const outputVersions: ReqCache = {}; Object.keys(dto.outputs).forEach( (id) => (outputVersions[id] = (dto.outputs![id] as ResOutput).version) ); let delay = this.getMaxOutputDelay(dto); const startMsec = Date.now(); while (delay > 0) { // Check whether maxWaitMsec has been reached if (maxWaitMsec >= 0) { const waitMsec = Date.now() - startMsec; if (waitMsec >= maxWaitMsec) { throw new TimeoutError(`Maximum wait time of ${maxWaitMsec} ms reached`); } if (waitMsec + delay > maxWaitMsec) { delay = maxWaitMsec - waitMsec; } } await sleep(delay); // Send cache request dto = ( await new OutputApi(this.configuration).getCachedOutputs( sessionId, outputVersions, options ) ).data; delay = this.getMaxOutputDelay(dto); } return dto; } /** * Given a DTO resulting from an export request, wait for the result to be finished. * @param {string} sessionId The session ID. * @param {ReqExport} body The body of the export request. * @param {ResComputeExports} dto The DTO resulting from an export request. * @param {number} [maxWaitMsec=-1] Maximum duration to wait for result (in milliseconds), pass value < 0 to disable limit. * @param {*} [options] Override http request option. * @throws {TimeoutError} in case a maximum duration has been specified and is exceeded. */ private async waitForExportResult( sessionId: string, body: ReqExport, dto: ResComputeExports, maxWaitMsec: number, options?: RawAxiosRequestConfig ): Promise { let delay = this.getMaxExportDelay(body, dto); const startMsec = Date.now(); while (delay > 0) { // Check whether maxWaitMsec has been reached if (maxWaitMsec >= 0) { const waitMsec = Date.now() - startMsec; if (waitMsec >= maxWaitMsec) { throw new TimeoutError(`Maximum wait time of ${maxWaitMsec} ms reached`); } if (waitMsec + delay > maxWaitMsec) { delay = maxWaitMsec - waitMsec; } } await sleep(delay); // Send cache request dto = ( await new ExportApi(this.configuration).getCachedExports(sessionId, body, options) ).data; delay = this.getMaxExportDelay(body, dto); } return dto; } /** * Get the maximum delay that was reported for output versions. * @param {ResComputeOutputs | ResGetCachedOutputs} dto The DTO resulting from a customization request. * @returns maximum delay, -1 in case no delay was reported */ private getMaxOutputDelay(dto: ResComputeOutputs | ResGetCachedOutputs): number { return Math.max( ...Object.values(dto.outputs ?? {}).map((output) => (output as ResOutput).delay ?? -1), -1 ); } /** * Get the maximum delay that was reported for the exports. If outputs have been reported as * well, their delay time is included too. * @param {ResComputeExports | ResGetCachedExports} dto The DTO resulting from an export request. * @returns delay, -1 in case no delay was reported */ private getMaxExportDelay( body: ReqExport, dto: ResComputeExports | ResGetCachedExports ): number { const exports = body.exports ?? []; const outputs = body.outputs ?? []; return Math.max( ...Object.values(dto.exports ?? {}) .filter((e) => exports.includes(e.id)) .map((e) => (e as ResExport).delay ?? -1), ...Object.values(dto.outputs ?? {}) .filter((o) => outputs.includes(o.id)) .map((o) => (o as ResOutput).delay ?? -1), -1 ); } /** * Builds an Axios request from the given configuration, the API's base configuration, and the * optional http request option. * @param {string} method The HTTP method to use. * @param {string} url The URL to send the request to. * @param {*} data The data to send with the request. * @param {*} [options={}] Optional http request options. */ private buildRequest( method: string, url: string, data: any, options: RawAxiosRequestConfig = {} ) { return (basePath: string = '') => { const baseOptions = this.configuration?.baseOptions ?? {}; const baseHeaders = baseOptions && baseOptions.headers ? baseOptions.headers : {}; const reqOptions = { method, ...baseOptions, ...options }; reqOptions.headers = { ...baseHeaders, ...options.headers }; if (!reqOptions.data && data) { // Create a new configuration object if necessary to enable mime type detection // in the serialization process. const configuration = this.configuration ?? new Configuration(); reqOptions.data = this.serializeDataSafely(data, reqOptions, configuration); } // Remove the base path configuration if the URL is a full URL. const configuration = this.configuration && url.startsWith('http') ? new Configuration({ ...this.configuration, basePath: undefined }) : this.configuration; const axiosArgs: RequestArgs = { url, options: reqOptions }; return createRequestFunction(axiosArgs, this.axios, basePath, configuration).bind( this.axios ); }; } /** * Builds the Axios request options by merging the API's base configuration with the given * options, and conditionally setting or overrides the Authorization header. * * The authorization header is set, when the URL is targeting the same server as the one * specified in the API's base path configuration, or if it is a ShapeDiver no-CDN server. In * this case, the Bearer token from the API configuration will be used. * @param url The URL to send the request to. * @param options The Axios request options. * @returns The merged Axios request options. */ private async buildRequestOptions( url: string, options: RawAxiosRequestConfig ): Promise { let baseOptions; if (this.configuration) baseOptions = this.configuration.baseOptions; const localRequestOptions = { ...baseOptions, ...options }, localHeaderParameter = {} as any; if (this.isTargetingInternalOrNoCdnServer(url)) await setBearerAuthToObject(localHeaderParameter, this.configuration); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localRequestOptions.headers = { ...localHeaderParameter, ...headersFromBaseOptions, ...options.headers, }; return localRequestOptions; } /** * Checks whether the given URL is targeting the same server as the one specified in the API's * base path configuration or if it is a ShapeDiver no-CDN server. */ private isTargetingInternalOrNoCdnServer(url: string): boolean { const basePath = this.configuration?.basePath; if (!basePath) return false; try { const targetUrl = new URL(url, basePath); const baseUrl = new URL(basePath); return targetUrl.origin === baseUrl.origin || sdNoCdnOrigin.test(targetUrl.origin); } catch { return false; } } /** * A wrapper around the auto-generated `serializeDataIfNeeded` that safely serializes data for * Axios. The idea of the base function is to stringify objects for application-json requests. * However, this does not work on all data types. Thus, this wrapper passes through all data * types that can be handled natively by Axios, and only calls the base function for the rest. * @param value The data to serialize. * @param requestOptions The request options. * @param configuration The API configuration. * @returns The serialized data. */ private serializeDataSafely( value: any, requestOptions: any, configuration?: Configuration ): any { // Helper: detect Node.js stream function isStream(val: any): boolean { return val !== null && typeof val === 'object' && typeof val.pipe === 'function'; } // Pass through raw binary types that Axios can handle if ( value instanceof ArrayBuffer || ArrayBuffer.isView(value) || // TypedArray (Uint8Array, Int16Array, etc.) and DataView isStream(value) || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) || (typeof Blob !== 'undefined' && value instanceof Blob) || (typeof File !== 'undefined' && value instanceof File) || (typeof FormData !== 'undefined' && value instanceof FormData) || (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) ) { return value; } // Fallback to the generated serializer return serializeDataIfNeeded(value, requestOptions, configuration); } /** Disable the Authorization header for ShapeDiver URIs if not explicitly set. */ private disableAuthHeaderForShapeDiverUris(url: string, options: RawAxiosRequestConfig): void { // When an authorization header is set, it will override anything that is set later if (options.headers?.Authorization) return; let targetUrl: URL; try { targetUrl = new URL(url); } catch { return; } if (directDownloadUri.test(targetUrl.origin) || cdnAssetUri.test(url)) options.headers = { Authorization: undefined, ...options.headers }; } }