import { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios' import * as https from 'https' import { CsrfToken } from '..' import { isAuthorizeFormRequired, isLogInRequired } from '../auth' import { AuthorizeError, LoginRequiredError, NotFoundError, InternalServerError, JobExecutionError, CertificateError } from '../types/errors' import { SASjsRequest, HttpClient, VerboseMode } from '../types' import { parseWeboutResponse } from '../utils/parseWeboutResponse' import { prefixMessage } from '@sasjs/utils/error' import { SAS9AuthError } from '../types/errors/SAS9AuthError' import { parseGeneratedCode, parseSourceCode, createAxiosInstance } from '../utils' import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError' import { inspect } from 'util' export class RequestClient implements HttpClient { private requests: SASjsRequest[] = [] private requestsLimit: number = 10 private httpInterceptor?: number private verboseMode: VerboseMode = false protected csrfToken: CsrfToken = { headerName: '', value: '' } protected fileUploadCsrfToken: CsrfToken | undefined protected httpClient!: AxiosInstance constructor( protected baseUrl: string, httpsAgentOptions?: https.AgentOptions, requestsLimit?: number, verboseMode?: VerboseMode ) { this.createHttpClient(baseUrl, httpsAgentOptions) if (requestsLimit) this.requestsLimit = requestsLimit if (verboseMode) { this.setVerboseMode(verboseMode) this.enableVerboseMode() } } public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) { this.createHttpClient(baseUrl, httpsAgentOptions) } public saveLocalStorageToken(accessToken: string, refreshToken: string) { localStorage.setItem('accessToken', accessToken) localStorage.setItem('refreshToken', refreshToken) } public getCsrfToken(type: 'general' | 'file' = 'general') { return type === 'file' ? this.fileUploadCsrfToken : this.csrfToken } public clearCsrfTokens() { this.csrfToken = { headerName: '', value: '' } this.fileUploadCsrfToken = { headerName: '', value: '' } } public clearLocalStorageTokens() { localStorage.setItem('accessToken', '') localStorage.setItem('refreshToken', '') } public getBaseUrl() { return this.httpClient.defaults.baseURL || '' } /** * this method returns all requests, an array of SASjsRequest type * @returns SASjsRequest[] */ public getRequests = () => this.requests /** * this method clears the requests array, i.e set to empty */ public clearRequests = () => { this.requests = [] } /** * this method appends the response from sasjs request to requests array * @param response - response from sasjs request * @param program - name of program * @param debug - a boolean that indicates whether debug was enabled or not */ public appendRequest(response: any, program: string, debug: boolean) { let sourceCode = '' let generatedCode = '' let sasWork = null if (debug) { if (response?.log) { sourceCode = parseSourceCode(response.log) generatedCode = parseGeneratedCode(response.log) if (response?.result) { sasWork = response.result.WORK } else { sasWork = response.log } } else if (response?.result) { // We parse only if it's a string, otherwise it would throw error if (typeof response.result === 'string') { sourceCode = parseSourceCode(response.result) generatedCode = parseGeneratedCode(response.result) } sasWork = response.result.WORK } } const stringifiedResult = typeof response?.result === 'string' ? response?.result : JSON.stringify(response?.result, null, 2) this.requests.push({ logFile: response?.log || stringifiedResult || response, serviceLink: program, timestamp: new Date(), sourceCode, generatedCode, SASWORK: sasWork }) if (this.requests.length > this.requestsLimit) { this.requests.splice(0, 1) } } public async get( url: string, accessToken: string | undefined, contentType: string = 'application/json', overrideHeaders: { [key: string]: string | number } = {}, debug: boolean = false ): Promise<{ result: T; etag: string; status: number }> { const headers = { ...this.getHeaders(accessToken, contentType), ...overrideHeaders } const requestConfig: AxiosRequestConfig = { headers, responseType: contentType === 'text/plain' ? 'text' : 'json', withXSRFToken: true } if (contentType === 'text/plain') { requestConfig.transformResponse = undefined } return this.httpClient .get(url, requestConfig) .then((response) => { throwIfError(response) return this.parseResponse(response) }) .catch(async (e: any) => { return await this.handleError( e, () => this.get(url, accessToken, contentType, overrideHeaders).catch( (err) => { throw prefixMessage( err, 'Error while executing handle error callback. ' ) } ), debug ) }) } /** * @param contentType Newer version of Axios is more strict so if you don't * set the contentType to `form data` while sending a FormData object * application/json will be used by default, axios won’t treat it as FormData. * Instead, it serializes data as JSON—resulting in a payload like * {"sometable":{}} and we lose the multipart/form-data formatting. */ public async post( url: string, data: any, accessToken: string | undefined, contentType = 'application/json', overrideHeaders: { [key: string]: string | number } = {}, additionalSettings: { [key: string]: string | number } = {} ): Promise<{ result: T; etag: string }> { const headers = { ...this.getHeaders(accessToken, contentType), ...overrideHeaders } return this.httpClient .post(url, data, { headers, withXSRFToken: true, ...additionalSettings }) .then((response) => { throwIfError(response) return this.parseResponse(response) }) .catch(async (e: any) => { return await this.handleError(e, () => this.post(url, data, accessToken, contentType, overrideHeaders) ) }) } public async put( url: string, data: any, accessToken: string | undefined, overrideHeaders: { [key: string]: string | number } = {} ): Promise<{ result: T; etag: string }> { const headers = { ...this.getHeaders(accessToken, 'application/json'), ...overrideHeaders } return this.httpClient .put(url, data, { headers, withXSRFToken: true }) .then((response) => { throwIfError(response) return this.parseResponse(response) }) .catch(async (e: any) => { return await this.handleError(e, () => this.put(url, data, accessToken, overrideHeaders) ) }) } public async delete( url: string, accessToken?: string ): Promise<{ result: T; etag: string }> { const headers = this.getHeaders(accessToken, 'application/json') return this.httpClient .delete(url, { headers, withXSRFToken: true }) .then((response) => { throwIfError(response) return this.parseResponse(response) }) .catch(async (e: any) => { return await this.handleError(e, () => this.delete(url, accessToken)) }) } public async patch( url: string, data: any = {}, accessToken?: string, overrideHeaders: { [key: string]: string | number } = {} ): Promise<{ result: T; etag: string }> { const headers = { ...this.getHeaders(accessToken, 'application/json'), ...overrideHeaders } return this.httpClient .patch(url, data, { headers, withXSRFToken: true }) .then((response) => { throwIfError(response) return this.parseResponse(response) }) .catch(async (e: any) => { return await this.handleError(e, () => this.patch(url, data, accessToken) ) }) } public async uploadFile( url: string, content: string, accessToken?: string ): Promise { const headers = this.getHeaders(accessToken, 'application/json') if (this.fileUploadCsrfToken?.value) { headers[this.fileUploadCsrfToken.headerName] = this.fileUploadCsrfToken.value } try { const response = await this.httpClient.post(url, content, { headers, transformRequest: (requestBody) => requestBody }) return { result: response.data, etag: response.headers['etag'] as string } } catch (e: any) { const response = e.response as AxiosResponse if (response?.status === 403 || response?.status === 449) { this.parseAndSetFileUploadCsrfToken(response) if (this.fileUploadCsrfToken) { return this.uploadFile(url, content, accessToken) } throw e } throw e } } public authorize = async (response: string) => { let authUrl: string | null = null const params: any = {} const responseBody = response.split('')[1].split('')[0] const bodyElement = document.createElement('div') bodyElement.innerHTML = responseBody const form = bodyElement.querySelector('#application_authorization') authUrl = form ? this.baseUrl + form.getAttribute('action') : null const inputs: any = form?.querySelectorAll('input') for (const input of inputs) { if (input.name === 'user_oauth_approval') { input.value = 'true' } params[input.name] = input.value } const csrfTokenKey = Object.keys(params).find((k) => k?.toLowerCase().includes('csrf') ) if (csrfTokenKey) { this.csrfToken.value = params[csrfTokenKey] this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token' } const formData = new FormData() for (const key in params) { if (params.hasOwnProperty(key)) { formData.append(key, params[key]) } } if (!authUrl) { throw new Error('Auth Form URL is null or undefined.') } return await this.httpClient .post(authUrl, formData, { responseType: 'text', headers: { Accept: '*/*', 'Content-Type': 'text/plain' } }) .then((res) => res.data) .catch((error) => { const logger = process.logger || console logger.error(error) }) } /** * Adds colors to the string. * If verboseMode is set to 'bleached', colors should be disabled * @param str - string to be prettified. * @returns - prettified string */ private prettifyString = (str: any) => inspect(str, { colors: this.verboseMode !== 'bleached' }) /** * Formats HTTP request/response body. * @param body - HTTP request/response body. * @returns - formatted string. */ private parseInterceptedBody = (body: any) => { if (!body) return '' let parsedBody // Tries to parse body into JSON object. if (typeof body === 'string') { try { parsedBody = JSON.parse(body) } catch (error) { parsedBody = body } } else { parsedBody = body } const bodyLines = this.prettifyString(parsedBody).split('\n') // Leaves first 50 lines if (bodyLines.length > 51) { bodyLines.splice(50) bodyLines.push('...') } return bodyLines.join('\n') } private handleAxiosResponse = (response: AxiosResponse) => { const { status, config, request, data } = response const reqHeaders = request?._header ?? 'Not provided\n' const rawHeaders = request?.res?.rawHeaders ?? ['Not provided'] const resHeaders = this.formatHeaders(rawHeaders) const parsedResBody = this.parseInterceptedBody(data) process.logger?.info(`HTTP Request (first 50 lines): ${reqHeaders}${this.parseInterceptedBody(config.data)} HTTP Response Code: ${this.prettifyString(status)} HTTP Response (first 50 lines): ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''} `) return response } private handleAxiosError = (error: AxiosError) => { // Message indicating absent value. const noValueMessage = 'Not provided' const { response, request, config } = error // Fallback request object that can be safely used to form request summary. // _header is not present in responses with status 1** // rawHeaders are not present in responses with status 1** let fallbackRequest = { _header: `${noValueMessage}\n`, res: { rawHeaders: [noValueMessage] } } if (request) { fallbackRequest = { _header: request._header ?? request._currentRequest?._header ?? noValueMessage, res: { rawHeaders: request.res?.rawHeaders ?? [noValueMessage] } } } // Fallback response object that can be safely used to form response summary. let fallbackResponse = response || { status: noValueMessage, request: fallbackRequest, config: config || { data: noValueMessage, headers: {} as AxiosRequestHeaders }, data: noValueMessage } const { status, request: req, data: resData } = fallbackResponse const { _header: reqHeaders, res } = req const resHeaders = this.formatHeaders(res.rawHeaders) const parsedResBody = this.parseInterceptedBody(resData) process.logger?.info(`HTTP Request (first 50 lines): ${reqHeaders}${this.parseInterceptedBody(config?.data)} HTTP Response Code: ${this.prettifyString(status)} HTTP Response (first 50 lines): ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''} `) return error } // Converts an array of strings into a single string with the following format: // : private formatHeaders = (rawHeaders: string[]): string => { return rawHeaders.reduce((acc, value, i) => { if (i % 2 === 0) { acc += `${i === 0 ? '' : '\n'}${value}` } else { acc += `: ${value}` } return acc }, '') } /** * Sets verbose mode. * @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors). */ public setVerboseMode = (verboseMode: VerboseMode) => { this.verboseMode = verboseMode if (this.verboseMode) this.enableVerboseMode() else this.disableVerboseMode() } /** * Turns on verbose mode to log every HTTP response. * @param successCallBack - function that should be triggered on every HTTP response with the status 2**. * @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**. */ public enableVerboseMode = ( successCallBack = this.handleAxiosResponse, errorCallBack = this.handleAxiosError ) => { this.httpInterceptor = this.httpClient.interceptors.response.use( successCallBack, errorCallBack ) } /** * Turns off verbose mode to log every HTTP response. */ public disableVerboseMode = () => { if (this.httpInterceptor) { this.httpClient.interceptors.response.eject(this.httpInterceptor) } } protected getHeaders = ( accessToken: string | undefined, contentType: string ) => { const headers: any = {} if (contentType !== 'application/x-www-form-urlencoded') { headers['Content-Type'] = contentType } if (contentType === 'application/json') { headers.Accept = 'application/json' } else { headers.Accept = '*/*' } if (accessToken) { headers.Authorization = `Bearer ${accessToken}` } if (this.csrfToken.headerName && this.csrfToken.value) { headers[this.csrfToken.headerName] = this.csrfToken.value } return headers } protected parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => { const token = this.parseCsrfToken(response) if (token) { this.fileUploadCsrfToken = token } } protected parseAndSetCsrfToken = (response: AxiosResponse) => { const token = this.parseCsrfToken(response) if (token) { this.csrfToken = token } } private parseCsrfToken = (response: AxiosResponse): CsrfToken | undefined => { const tokenHeader = ( response.headers['x-csrf-header'] as string )?.toLowerCase() if (tokenHeader) { const token = response.headers[tokenHeader] const csrfToken = { headerName: tokenHeader, value: token || '' } return csrfToken } } protected handleError = async ( e: any, callback: any, debug: boolean = false ) => { const response = e.response as AxiosResponse if (e instanceof AuthorizeError) { const res = await this.httpClient .get(e.confirmUrl, { responseType: 'text', headers: { 'Content-Type': 'text/plain', Accept: '*/*' } }) .catch((err) => { throw prefixMessage(err, 'Error while getting error confirmUrl. ') }) if (isAuthorizeFormRequired(res?.data as string)) { await this.authorize(res.data as string).catch((err) => { throw prefixMessage(err, 'Error while authorizing request. ') }) } return await callback().catch((err: any) => { throw prefixMessage( err, 'Error while executing callback in handleError. ' ) }) } if (e instanceof LoginRequiredError) { this.clearCsrfTokens() throw e } if (e instanceof InvalidSASjsCsrfError) { // Fetching root and creating CSRF cookie await this.httpClient .get('/', { withXSRFToken: true }) .then((response) => { const cookie = /