From 26ade31bb0d77f3b1ee35104addcce44839ab8d3 Mon Sep 17 00:00:00 2001 From: Wojciech Duda <69160975+wojd0@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:36:31 +0100 Subject: [PATCH] AAE-30882 Replace superagent with ofetch --- lib/js-api/src/superagentHttpClient.ts | 305 +++++++++---------- lib/js-api/test/superagentHttpClient.spec.ts | 105 ++++--- 2 files changed, 194 insertions(+), 216 deletions(-) diff --git a/lib/js-api/src/superagentHttpClient.ts b/lib/js-api/src/superagentHttpClient.ts index d9ec757954..c37f08c669 100644 --- a/lib/js-api/src/superagentHttpClient.ts +++ b/lib/js-api/src/superagentHttpClient.ts @@ -15,19 +15,13 @@ * limitations under the License. */ -import ee, { Emitter } from 'event-emitter'; -import superagent, { Response, SuperAgentRequest } from 'superagent'; +import { FetchOptions, FetchResponse, ofetch } from 'ofetch'; import { Authentication } from './authentication/authentication'; import { RequestOptions, HttpClient, SecurityOptions, Emitters } from './api-clients/http-client.interface'; import { Oauth2 } from './authentication/oauth2'; import { BasicAuth } from './authentication/basicAuth'; import { isBrowser, paramToString } from './utils'; -declare const Blob: any; -declare const Buffer: any; - -const isProgressEvent = (event: ProgressEvent | unknown): event is ProgressEvent => (event as ProgressEvent)?.lengthComputable; - export class SuperagentHttpClient implements HttpClient { /** * The default HTTP timeout for all API calls. @@ -54,7 +48,7 @@ export class SuperagentHttpClient implements HttpClient { const { httpMethod, queryParams, headerParams, formParams, bodyParam, contentType, accept, responseType, returnType } = options; const { eventEmitter, apiClientEmitter } = emitters; - let request = this.buildRequest( + const { urlWithParams, fetchOptions } = this.buildRequest({ httpMethod, url, queryParams, @@ -64,201 +58,181 @@ export class SuperagentHttpClient implements HttpClient { contentType, accept, responseType, - eventEmitter, returnType, securityOptions - ); - - if (returnType === 'Binary') { - request = request.buffer(true).parse(superagent.parse['application/octet-stream']); - } - - const promise: any = new Promise((resolve, reject) => { - request.on('abort', () => { - eventEmitter.emit('abort'); - }); - request.end((error: any, response: Response) => { - if (error) { - apiClientEmitter.emit('error', error); - eventEmitter.emit('error', error); - - if (error.status === 401) { - apiClientEmitter.emit('unauthorized'); - eventEmitter.emit('unauthorized'); - } - - if (response?.text) { - error = error || {}; - reject(Object.assign(error, { message: response.text })); - } else { - // eslint-disable-next-line prefer-promise-reject-errors - reject({ error }); - } - } else { - if (securityOptions.isBpmRequest) { - const hasSetCookie = Object.prototype.hasOwnProperty.call(response.header, 'set-cookie'); - if (response.header && hasSetCookie) { - // mutate the passed value from AlfrescoApiClient class for backward compatibility - securityOptions.authentications.cookie = response.header['set-cookie'][0]; - } - } - let data = {}; - if (response.type === 'text/html') { - data = SuperagentHttpClient.deserialize(response); - } else { - data = SuperagentHttpClient.deserialize(response, returnType); - } - - eventEmitter.emit('success', data); - resolve(data); - } - }); }); - promise.abort = function () { - request.abort(); - return this; - }; + const controller = new AbortController(); + fetchOptions.signal = controller.signal; + + const promise = new Promise((resolve, reject) => { + ofetch(urlWithParams, fetchOptions) + .then(async (response: Response) => { + if (response.ok) { + if (securityOptions.isBpmRequest) { + const hasSetCookie = Object.prototype.hasOwnProperty.call(response.headers, 'set-cookie'); + if (response.headers && hasSetCookie) { + securityOptions.authentications.cookie = response.headers.get('set-cookie'); + } + } + let data: T; + if (response.headers.get('content-type') === 'text/html') { + data = await SuperagentHttpClient.deserialize(response); + } else { + data = await SuperagentHttpClient.deserialize(response, returnType); + } + + eventEmitter.emit('success', data); + resolve(data); + } else { + apiClientEmitter.emit('error', response); + eventEmitter.emit('error', response); + + if (response.status === 401) { + apiClientEmitter.emit('unauthorized'); + eventEmitter.emit('unauthorized'); + } + + response.text().then((text) => { + reject(Object.assign(new Error(text), { status: response.status })); + }); + } + }) + .catch((error: any) => { + apiClientEmitter.emit('error', error); + eventEmitter.emit('error', error); + reject(error); + }); + }); return promise; } - private buildRequest( - httpMethod: string, - url: string, - queryParams: { [key: string]: any }, - headerParams: { [key: string]: any }, - formParams: { [key: string]: any }, - // eslint-disable-next-line @typescript-eslint/ban-types - bodyParam: string | Object, - contentType: string, - accept: string, - responseType: string, - eventEmitter: ee.Emitter, - returnType: string, - securityOptions: SecurityOptions - ) { - const request = superagent(httpMethod, url); + private buildRequest({ + httpMethod, + url, + queryParams, + headerParams, + formParams, + bodyParam, + contentType, + accept, + responseType, + returnType, + securityOptions + }: { + httpMethod: string; + url: string; + queryParams: { [key: string]: any }; + headerParams: { [key: string]: any }; + formParams: { [key: string]: any }; + bodyParam: string | object; + contentType: string; + accept: string; + responseType: string; + returnType: string; + securityOptions: SecurityOptions; + }) { + const urlWithParams = new URL(url); + urlWithParams.search = new URLSearchParams(SuperagentHttpClient.normalizeParams(queryParams)).toString(); - const { isBpmRequest, authentications, defaultHeaders = {}, enableCsrf, withCredentials = false } = securityOptions; + // Create a Headers object and add default and normalized header params + const headers = new Headers(); + for (const key in securityOptions.defaultHeaders) { + if (Object.prototype.hasOwnProperty.call(securityOptions.defaultHeaders, key)) { + headers.append(key, securityOptions.defaultHeaders[key]); + } + } + const normHeaders = SuperagentHttpClient.normalizeParams(headerParams); + for (const key in normHeaders) { + if (Object.prototype.hasOwnProperty.call(normHeaders, key)) { + headers.append(key, normHeaders[key]); + } + } - // apply authentications - this.applyAuthToRequest(request, authentications); + const fetchOptions: FetchOptions = { method: httpMethod }; - // set query parameters - request.query(SuperagentHttpClient.normalizeParams(queryParams)); - - // set header parameters - request.set(defaultHeaders).set(SuperagentHttpClient.normalizeParams(headerParams)); + const { isBpmRequest, authentications, enableCsrf, withCredentials = false } = securityOptions; + this.applyAuthToRequest(headers, authentications); if (isBpmRequest && enableCsrf) { - this.setCsrfToken(request); + this.setCsrfToken(headers); } if (withCredentials) { - request.withCredentials(); + fetchOptions.credentials = 'include'; } - - // add cookie for activiti - if (isBpmRequest) { - request.withCredentials(); - if (securityOptions.authentications.cookie) { - if (!isBrowser()) { - request.set('Cookie', securityOptions.authentications.cookie); - } + if (isBpmRequest && securityOptions.authentications.cookie) { + if (!isBrowser()) { + headers.set('Cookie', securityOptions.authentications.cookie); } } - - // set request timeout - request.timeout(this.timeout); - if (contentType && contentType !== 'multipart/form-data') { - request.type(contentType); - } else if (!(request as any).header['Content-Type'] && contentType !== 'multipart/form-data') { - request.type('application/json'); + headers.set('Content-Type', contentType); + } else if (!headers.has('Content-Type') && contentType !== 'multipart/form-data') { + headers.set('Content-Type', 'application/json'); } if (contentType === 'application/x-www-form-urlencoded') { - request.send(SuperagentHttpClient.normalizeParams(formParams)).on('progress', (event: any) => { - this.progress(event, eventEmitter); - }); + fetchOptions.body = new URLSearchParams(SuperagentHttpClient.normalizeParams(formParams)).toString(); } else if (contentType === 'multipart/form-data') { - const _formParams = SuperagentHttpClient.normalizeParams(formParams); - for (const key in _formParams) { - if (Object.prototype.hasOwnProperty.call(_formParams, key)) { - if (SuperagentHttpClient.isFileParam(_formParams[key])) { - // file field - request.attach(key, _formParams[key]).on('progress', (event: ProgressEvent) => { - // jshint ignore:line - this.progress(event, eventEmitter); - }); - } else { - request.field(key, _formParams[key]).on('progress', (event: ProgressEvent) => { - // jshint ignore:line - this.progress(event, eventEmitter); - }); - } + const formData = new FormData(); + const normalizedParams = SuperagentHttpClient.normalizeParams(formParams); + for (const key in normalizedParams) { + if (Object.prototype.hasOwnProperty.call(normalizedParams, key)) { + formData.append(key, normalizedParams[key]); } } + fetchOptions.body = formData; } else if (bodyParam) { - request.send(bodyParam).on('progress', (event: any) => { - this.progress(event, eventEmitter); - }); + fetchOptions.body = JSON.stringify(bodyParam); } if (accept) { - request.accept(accept); + headers.set('Accept', accept); } - if (returnType === 'blob' || returnType === 'Blob' || responseType === 'blob' || responseType === 'Blob') { - request.responseType('blob'); + fetchOptions.responseType = 'blob'; } else if (returnType === 'String') { - request.responseType('string'); + fetchOptions.responseType = 'text'; } - return request; - } + const parsedHeaders: Record = {}; - setCsrfToken(request: SuperAgentRequest): void { - const token = SuperagentHttpClient.createCSRFToken(); - request.set('X-CSRF-TOKEN', token); + headers.forEach((value, key) => { + parsedHeaders[key] = value; + }); - if (!isBrowser()) { - request.set('Cookie', 'CSRF-TOKEN=' + token + ';path=/'); - } + fetchOptions.headers = parsedHeaders; - try { - document.cookie = 'CSRF-TOKEN=' + token + ';path=/'; - } catch (err) { - /* continue regardless of error */ - } + return { urlWithParams: urlWithParams.toString(), fetchOptions }; } /** * Applies authentication headers to the request. - * @param request The request object created by a superagent() call. + * @param fetchOptions The fetch options object. * @param authentications authentications */ - private applyAuthToRequest(request: SuperAgentRequest, authentications: Authentication) { + private applyAuthToRequest(headers: Headers, authentications: Authentication) { if (authentications) { switch (authentications.type) { case 'basic': { const basicAuth: BasicAuth = authentications.basicAuth; if (basicAuth.username || basicAuth.password) { - request.auth(basicAuth.username || '', basicAuth.password || ''); + headers.set('Authorization', 'Basic ' + btoa(basicAuth.username + ':' + basicAuth.password)); } break; } case 'activiti': { if (authentications.basicAuth.ticket) { - request.set({ Authorization: authentications.basicAuth.ticket }); + headers.set('Authorization', authentications.basicAuth.ticket); } break; } case 'oauth2': { const oauth2: Oauth2 = authentications.oauth2; if (oauth2.accessToken) { - request.set({ Authorization: 'Bearer ' + oauth2.accessToken }); + headers.set('Authorization', 'Bearer ' + oauth2.accessToken); } break; } @@ -268,17 +242,18 @@ export class SuperagentHttpClient implements HttpClient { } } - private progress(event: ProgressEvent | unknown, eventEmitter: Emitter): void { - if (isProgressEvent(event)) { - const percent = Math.round((event.loaded / event.total) * 100); + setCsrfToken(headers: Headers): void { + const token = SuperagentHttpClient.createCSRFToken(); + headers.set('X-CSRF-TOKEN', token); - const progress = { - total: event.total, - loaded: event.loaded, - percent - }; + if (!isBrowser()) { + headers.set('Cookie', 'CSRF-TOKEN=' + token + ';path=/'); + } - eventEmitter.emit('progress', progress); + try { + document.cookie = 'CSRF-TOKEN=' + token + ';path=/'; + } catch (err) { + /* continue regardless of error */ } } @@ -290,37 +265,41 @@ export class SuperagentHttpClient implements HttpClient { /** * Deserializes an HTTP response body into a value of the specified type. - * @param response A SuperAgent response object. + * @param response A fetch response object. * @param returnType The type to return. Pass a string for simple types * or the constructor function for a complex type. Pass an array containing the type name to return an array of that type. To * return an object, pass an object with one property whose name is the key type and whose value is the corresponding value type: * all properties on data will be converted to this type. * @returns A value of the specified type. */ - private static deserialize(response: Response, returnType?: any): any { + private static async deserialize(response: FetchResponse, returnType?: any): Promise { if (response === null) { return null; } - let data = response.body; + let parsedBody: unknown; - if (data === null) { - data = response.text; + try { + parsedBody = await response.json(); + } catch (error) { + parsedBody = await response.text(); } if (returnType) { if (returnType === 'blob' && isBrowser()) { - data = new Blob([data], { type: response.header['content-type'] }); + return response.blob(); } else if (returnType === 'blob' && !isBrowser()) { - data = new Buffer.from(data, 'binary'); - } else if (Array.isArray(data)) { - data = data.map((element) => new returnType(element)); + return response.arrayBuffer(); } else { - data = new returnType(data); + if (Array.isArray(parsedBody)) { + return parsedBody.map((element) => new returnType(element)); + } else { + return new returnType(await parsedBody); + } } } - return data; + return parsedBody; } /** diff --git a/lib/js-api/test/superagentHttpClient.spec.ts b/lib/js-api/test/superagentHttpClient.spec.ts index d13b4c599d..c995340033 100644 --- a/lib/js-api/test/superagentHttpClient.spec.ts +++ b/lib/js-api/test/superagentHttpClient.spec.ts @@ -17,7 +17,7 @@ import assert from 'assert'; import { SuperagentHttpClient } from '../src/superagentHttpClient'; -import { Response } from 'superagent'; +import { FetchResponse } from 'ofetch'; describe('SuperagentHttpClient', () => { describe('buildRequest', () => { @@ -27,10 +27,10 @@ describe('SuperagentHttpClient', () => { const headerParams = {}; const formParams = {}; - const contentTypes = 'application/json'; - const accepts = 'application/json'; + const contentType = 'application/json'; + const accept = 'application/json'; const responseType = 'blob'; - const url = '/fake-api/enterprise/process-instances/'; + const url = 'http://fake-api/enterprise/process-instances/'; const httpMethod = 'GET'; const securityOptions = { isBpmRequest: false, @@ -45,76 +45,78 @@ describe('SuperagentHttpClient', () => { defaultHeaders: {} }; - const response: any = client['buildRequest']( + const request = client['buildRequest']({ httpMethod, url, queryParams, headerParams, formParams, - null, - contentTypes, - accepts, + contentType, + accept, responseType, - null, - null, + bodyParam: null, + returnType: null, securityOptions - ); + }); - assert.equal(response.url, '/fake-api/enterprise/process-instances/'); - assert.equal(response.header.Accept, 'application/json'); - assert.equal(response.header['Content-Type'], 'application/json'); - assert.equal(response._responseType, 'blob'); + assert.equal(request.urlWithParams, 'http://fake-api/enterprise/process-instances/'); + const { fetchOptions } = request; + + assert.equal(fetchOptions.headers['accept'], 'application/json'); + assert.equal(fetchOptions.headers['content-type'], 'application/json'); + assert.equal(fetchOptions.responseType, 'blob'); }); }); describe('deserialize', () => { - it('should deserialize to an array when the response body is an array', () => { - const data = { - body: [ - { - id: '1', - name: 'test1' - }, - { - id: '2', - name: 'test2' - } - ] - } as Response; - const result = SuperagentHttpClient['deserialize'](data); + it('should deserialize to an array when the response body is an array', async () => { + const data = [ + { + id: '1', + name: 'test1' + }, + { + id: '2', + name: 'test2' + } + ]; + const response = { + json() { + return Promise.resolve(data); + } + } as FetchResponse; + const result = await SuperagentHttpClient['deserialize'](response); + const isArray = Array.isArray(result); assert.equal(isArray, true); }); it('should deserialize to an object when the response body is an object', () => { - const data = { - body: { - id: '1', - name: 'test1' - } - } as Response; - const result = SuperagentHttpClient['deserialize'](data); + const response = { + json: () => Promise.resolve({ id: '1', name: 'test1' }) + } as FetchResponse; + const result = SuperagentHttpClient['deserialize'](response); + const isArray = Array.isArray(result); assert.equal(isArray, false); }); - it('should return null when response is null', () => { - const result = SuperagentHttpClient['deserialize'](null); + it('should return null when response is null', async () => { + const result = await SuperagentHttpClient['deserialize'](null); assert.equal(result, null); }); - it('should fallback to text property when body is null', () => { + it('should fallback to text property when body cant be parsed', async () => { const data = { - text: '{"id": "1", "name": "test1"}', - header: { - 'content-type': 'application/json' - } - } as any as Response; - const result = SuperagentHttpClient['deserialize'](data, 'blob'); - assert.deepEqual(result, new Blob([data.text], { type: data.header['content-type'] })); + text: () => Promise.resolve('mock-response-text') + } as FetchResponse; + + const result = await SuperagentHttpClient['deserialize'](data); + + assert.equal(result, 'mock-response-text'); }); - it('should convert to returnType when provided', () => { + it('should convert to returnType when provided', async () => { class Dummy { id: string; name: string; @@ -124,12 +126,9 @@ describe('SuperagentHttpClient', () => { } } const data = { - body: { - id: '1', - name: 'test1' - } - } as Response; - const result = SuperagentHttpClient['deserialize'](data, Dummy); + json: () => Promise.resolve({ id: '1', name: 'test1' }) + } as FetchResponse; + const result = await SuperagentHttpClient['deserialize'](data, Dummy); assert.ok(result instanceof Dummy); assert.equal(result.id, '1'); assert.equal(result.name, 'test1');