diff --git a/src/ContextManager.ts b/src/ContextManager.ts index c9a897c..bee8a5d 100644 --- a/src/ContextManager.ts +++ b/src/ContextManager.ts @@ -1,7 +1,7 @@ import { Context, EditContextInput, ContextAllAttributes } from './types' import { isUrl } from './utils' import { prefixMessage } from '@sasjs/utils/error' -import { RequestClient } from './request/client' +import { RequestClient } from './request/RequestClient' export class ContextManager { private defaultComputeContexts = [ diff --git a/src/FileUploader.ts b/src/FileUploader.ts index 05f2453..0f41b49 100644 --- a/src/FileUploader.ts +++ b/src/FileUploader.ts @@ -1,7 +1,7 @@ import { isUrl } from './utils' import { UploadFile } from './types/UploadFile' import { ErrorResponse } from './types' -import { RequestClient } from './request/client' +import { RequestClient } from './request/RequestClient' export class FileUploader { constructor( @@ -52,7 +52,7 @@ export class FileUploader { } return this.requestClient - .post(uploadUrl, formData, undefined, headers) + .post(uploadUrl, formData, undefined, 'application/json', headers) .then((res) => res.result) .catch((err: Error) => { return Promise.reject( diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index 01c1350..1661e5d 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -17,7 +17,7 @@ import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time' import { Logger, LogLevel } from '@sasjs/utils/logger' import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired' import { parseAndSubmitAuthorizeForm } from './auth' -import { RequestClient } from './request/client' +import { RequestClient } from './request/RequestClient' /** * A client for interfacing with the SAS Viya REST API. diff --git a/src/SASjs.ts b/src/SASjs.ts index 945a7a1..3f4755c 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -1,30 +1,16 @@ -import { - convertToCSV, - compareTimestamps, - splitChunks, - parseSourceCode, - parseGeneratedCode, - parseWeboutResponse, - needsRetry, - asyncForEach, - isRelativePath -} from './utils' -import { - SASjsConfig, - SASjsRequest, - SASjsWaitingRequest, - CsrfToken, - UploadFile, - EditContextInput, - ErrorResponse, - PollOptions -} from './types' +import { compareTimestamps, asyncForEach } from './utils' +import { SASjsConfig, UploadFile, EditContextInput, PollOptions } from './types' import { SASViyaApiClient } from './SASViyaApiClient' import { SAS9ApiClient } from './SAS9ApiClient' import { FileUploader } from './FileUploader' -import { isLogInRequired, AuthManager } from './auth' +import { AuthManager } from './auth' import { ServerType } from '@sasjs/utils/types' -import { RequestClient } from './request/client' +import { RequestClient } from './request/RequestClient' +import { + JobExecutor, + WebJobExecutor, + ComputeJobExecutor +} from './job-execution' const defaultConfig: SASjsConfig = { serverUrl: '', @@ -37,8 +23,6 @@ const defaultConfig: SASjsConfig = { useComputeApi: false } -const requestRetryLimit = 5 - /** * SASjs is a JavaScript adapter for SAS. * @@ -46,17 +30,14 @@ const requestRetryLimit = 5 export default class SASjs { private sasjsConfig: SASjsConfig = new SASjsConfig() private jobsPath: string = '' - private csrfTokenWeb: CsrfToken | null = null - private retryCountWeb: number = 0 - private retryCountComputeApi: number = 0 - private retryCountJeseApi: number = 0 - private sasjsRequests: SASjsRequest[] = [] - private sasjsWaitingRequests: SASjsWaitingRequest[] = [] private sasViyaApiClient: SASViyaApiClient | null = null private sas9ApiClient: SAS9ApiClient | null = null private fileUploader: FileUploader | null = null private authManager: AuthManager | null = null private requestClient: RequestClient | null = null + private webJobExecutor: JobExecutor | null = null + private computeJobExecutor: JobExecutor | null = null + private jesJobExecutor: JobExecutor | null = null constructor(config?: any) { this.sasjsConfig = { @@ -260,6 +241,11 @@ export default class SASjs { debug?: boolean ) { this.isMethodSupported('executeScriptSASViya', ServerType.SasViya) + if (!contextName) { + throw new Error( + 'Context name is undefined. Please set a `contextName` in your SASjs or override config.' + ) + } return await this.sasViyaApiClient!.executeScript( fileName, @@ -514,8 +500,6 @@ export default class SASjs { loginRequiredCallback?: any, accessToken?: string ) { - let requestResponse - config = { ...this.sasjsConfig, ...config @@ -523,36 +507,30 @@ export default class SASjs { if (config.serverType === ServerType.SasViya && config.contextName) { if (config.useComputeApi) { - requestResponse = await this.executeJobViaComputeApi( + return await this.computeJobExecutor!.execute( sasJob, data, config, loginRequiredCallback, accessToken ) - - this.retryCountComputeApi = 0 } else { - requestResponse = await this.executeJobViaJesApi( + return await this.jesJobExecutor!.execute( sasJob, data, config, loginRequiredCallback, accessToken ) - - this.retryCountJeseApi = 0 } } else { - requestResponse = await this.executeJobViaWeb( + return await this.webJobExecutor!.execute( sasJob, data, config, loginRequiredCallback ) } - - return requestResponse } /** @@ -674,576 +652,10 @@ export default class SASjs { ) } - private async executeJobViaComputeApi( - sasJob: string, - data: any, - config: any, - loginRequiredCallback?: any, - accessToken?: string - ) { - const sasjsWaitingRequest: SASjsWaitingRequest = { - requestPromise: { - promise: null, - resolve: null, - reject: null - }, - SASjob: sasJob, - data - } - - sasjsWaitingRequest.requestPromise.promise = new Promise( - async (resolve, reject) => { - const waitForResult = true - const expectWebout = true - this.sasViyaApiClient - ?.executeComputeJob( - sasJob, - config.contextName, - config.debug, - data, - accessToken, - waitForResult, - expectWebout - ) - .then((response) => { - if (!config.debug) { - this.appendSasjsRequest(null, sasJob, null) - } else { - this.appendSasjsRequest(response, sasJob, null) - } - - let responseJson - - try { - if (typeof response!.result === 'string') { - responseJson = JSON.parse(response!.result) - } else { - responseJson = response!.result - } - } catch { - responseJson = JSON.parse(parseWeboutResponse(response!.result)) - } - - resolve(responseJson) - }) - .catch(async (response) => { - let error = response.error || response - - if (needsRetry(JSON.stringify(error))) { - if (this.retryCountComputeApi < requestRetryLimit) { - let retryResponse = await this.executeJobViaComputeApi( - sasJob, - data, - config, - loginRequiredCallback, - accessToken - ) - - this.retryCountComputeApi++ - - resolve(retryResponse) - } else { - this.retryCountComputeApi = 0 - reject( - new ErrorResponse('Compute API retry requests limit reached.') - ) - } - } - - if (response?.log) { - this.appendSasjsRequest(response.log, sasJob, null) - } - - if (error.toString().includes('Job was not found')) { - reject( - new ErrorResponse('Service not found on the server.', { - sasJob: sasJob - }) - ) - } - - if (error && error.status === 401) { - if (loginRequiredCallback) loginRequiredCallback(true) - sasjsWaitingRequest.requestPromise.resolve = resolve - sasjsWaitingRequest.requestPromise.reject = reject - sasjsWaitingRequest.config = config - this.sasjsWaitingRequests.push(sasjsWaitingRequest) - } else { - reject(new ErrorResponse('Job execution failed.', error)) - } - }) - } - ) - return sasjsWaitingRequest.requestPromise.promise - } - - private async executeJobViaJesApi( - sasJob: string, - data: any, - config: any, - loginRequiredCallback?: any, - accessToken?: string - ) { - const sasjsWaitingRequest: SASjsWaitingRequest = { - requestPromise: { - promise: null, - resolve: null, - reject: null - }, - SASjob: sasJob, - data - } - - sasjsWaitingRequest.requestPromise.promise = new Promise( - async (resolve, reject) => { - const session = await this.checkSession() - - if (!session.isLoggedIn && !accessToken) { - if (loginRequiredCallback) loginRequiredCallback(true) - sasjsWaitingRequest.requestPromise.resolve = resolve - sasjsWaitingRequest.requestPromise.reject = reject - sasjsWaitingRequest.config = config - this.sasjsWaitingRequests.push(sasjsWaitingRequest) - } else { - resolve( - await this.sasViyaApiClient - ?.executeJob( - sasJob, - config.contextName, - config.debug, - data, - accessToken - ) - .then((response) => { - if (!config.debug) { - this.appendSasjsRequest(null, sasJob, null) - } else { - this.appendSasjsRequest(response, sasJob, null) - } - - let responseJson - - try { - if (typeof response!.result === 'string') { - responseJson = JSON.parse(response!.result) - } else { - responseJson = response!.result - } - } catch { - responseJson = JSON.parse( - parseWeboutResponse(response!.result) - ) - } - - return responseJson - }) - .catch(async (response) => { - if (needsRetry(JSON.stringify(response))) { - if (this.retryCountJeseApi < requestRetryLimit) { - let retryResponse = await this.executeJobViaJesApi( - sasJob, - data, - config, - loginRequiredCallback, - accessToken - ) - - this.retryCountJeseApi++ - - resolve(retryResponse) - } else { - this.retryCountJeseApi = 0 - reject( - new ErrorResponse('Jes API retry requests limit reached.') - ) - } - } - - if (response?.log) { - this.appendSasjsRequest(response.log, sasJob, null) - } - - if (response.toString().includes('Job was not found')) { - reject( - new ErrorResponse('Service not found on the server.', { - sasJob: sasJob - }) - ) - } - - reject(new ErrorResponse('Job execution failed.', response)) - }) - ) - } - } - ) - return sasjsWaitingRequest.requestPromise.promise - } - - private async executeJobViaWeb( - sasJob: string, - data: any, - config: any, - loginRequiredCallback?: any - ) { - const sasjsWaitingRequest: SASjsWaitingRequest = { - requestPromise: { - promise: null, - resolve: null, - reject: null - }, - SASjob: sasJob, - data - } - const program = isRelativePath(sasJob) - ? config.appLoc - ? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '') - : sasJob - : sasJob - const jobUri = - config.serverType === ServerType.SasViya - ? await this.getJobUri(sasJob) - : '' - const apiUrl = `${config.serverUrl}${this.jobsPath}/?${ - jobUri.length > 0 - ? '__program=' + program + '&_job=' + jobUri - : '_program=' + program - }` - - const requestParams = { - ...this.getRequestParamsWeb(config) - } - - const formData = new FormData() - - let isError = false - let errorMsg = '' - - if (data) { - const stringifiedData = JSON.stringify(data) - if ( - config.serverType === ServerType.Sas9 || - stringifiedData.length > 500000 || - stringifiedData.includes(';') - ) { - // file upload approach - for (const tableName in data) { - if (isError) { - return - } - const name = tableName - const csv = convertToCSV(data[tableName]) - if (csv === 'ERROR: LARGE STRING LENGTH') { - isError = true - errorMsg = - 'The max length of a string value in SASjs is 32765 characters.' - } - - const file = new Blob([csv], { - type: 'application/csv' - }) - - formData.append(name, file, `${name}.csv`) - } - } else { - // param based approach - const sasjsTables = [] - let tableCounter = 0 - for (const tableName in data) { - if (isError) { - return - } - tableCounter++ - sasjsTables.push(tableName) - const csv = convertToCSV(data[tableName]) - if (csv === 'ERROR: LARGE STRING LENGTH') { - isError = true - errorMsg = - 'The max length of a string value in SASjs is 32765 characters.' - } - // if csv has length more then 16k, send in chunks - if (csv.length > 16000) { - const csvChunks = splitChunks(csv) - // append chunks to form data with same key - csvChunks.map((chunk) => { - formData.append(`sasjs${tableCounter}data`, chunk) - }) - } else { - requestParams[`sasjs${tableCounter}data`] = csv - } - } - requestParams['sasjs_tables'] = sasjsTables.join(' ') - } - } - - for (const key in requestParams) { - if (requestParams.hasOwnProperty(key)) { - formData.append(key, requestParams[key]) - } - } - - let isRedirected = false - - sasjsWaitingRequest.requestPromise.promise = new Promise( - (resolve, reject) => { - if (isError) { - reject(new ErrorResponse(errorMsg)) - } - const headers: any = {} - if (this.csrfTokenWeb) { - headers[this.csrfTokenWeb.headerName] = this.csrfTokenWeb.value - } - fetch(apiUrl, { - method: 'POST', - body: formData, - referrerPolicy: 'same-origin', - headers - }) - .then(async (response) => { - if (!response.ok) { - if (response.status === 403) { - const tokenHeader = response.headers.get('X-CSRF-HEADER') - - if (tokenHeader) { - const token = response.headers.get(tokenHeader) - this.csrfTokenWeb = { - headerName: tokenHeader, - value: token || '' - } - } - } - } - - if (response.redirected && config.serverType === ServerType.Sas9) { - isRedirected = true - } - - return response.text() - }) - .then((responseText) => { - if ( - (needsRetry(responseText) || isRedirected) && - !isLogInRequired(responseText) - ) { - if (this.retryCountWeb < requestRetryLimit) { - this.retryCountWeb++ - this.request(sasJob, data, config, loginRequiredCallback).then( - (res: any) => resolve(res), - (err: any) => reject(err) - ) - } else { - this.retryCountWeb = 0 - reject(responseText) - } - } else { - this.retryCountWeb = 0 - this.parseLogFromResponse(responseText, program) - - if (isLogInRequired(responseText)) { - if (loginRequiredCallback) loginRequiredCallback(true) - sasjsWaitingRequest.requestPromise.resolve = resolve - sasjsWaitingRequest.requestPromise.reject = reject - sasjsWaitingRequest.config = config - this.sasjsWaitingRequests.push(sasjsWaitingRequest) - } else { - if (config.serverType === ServerType.Sas9 && config.debug) { - const jsonResponseText = parseWeboutResponse(responseText) - - if (jsonResponseText !== '') { - resolve(JSON.parse(jsonResponseText)) - } else { - reject( - new ErrorResponse( - 'Job WEB execution failed.', - this.parseSAS9ErrorResponse(responseText) - ) - ) - } - } else if ( - config.serverType === ServerType.SasViya && - config.debug - ) { - try { - this.parseSASVIYADebugResponse(responseText).then( - (resText: any) => { - try { - resolve(JSON.parse(resText)) - } catch (e) { - reject( - new ErrorResponse( - 'Job WEB debug response parsing failed.', - { response: resText, exception: e } - ) - ) - } - }, - (err: any) => { - reject( - new ErrorResponse( - 'Job WEB debug response parsing failed.', - err - ) - ) - } - ) - } catch (e) { - reject( - new ErrorResponse( - 'Job WEB debug response parsing failed.', - { response: responseText, exception: e } - ) - ) - } - } else { - if ( - responseText.includes( - 'The requested URL /SASStoredProcess/do/ was not found on this server.' - ) || - responseText.includes('Stored process not found') - ) { - reject( - new ErrorResponse( - 'Service not found on the server.', - { service: sasJob }, - responseText - ) - ) - } - - try { - const parsedJson = JSON.parse(responseText) - resolve(parsedJson) - } catch (e) { - reject( - new ErrorResponse('Job WEB response parsing failed.', { - response: responseText, - exception: e - }) - ) - } - } - } - } - }) - .catch((e: Error) => { - reject(new ErrorResponse('Job WEB request failed.', e)) - }) - } - ) - - return sasjsWaitingRequest.requestPromise.promise - } - private resendWaitingRequests = async () => { - for (const sasjsWaitingRequest of this.sasjsWaitingRequests) { - this.request(sasjsWaitingRequest.SASjob, sasjsWaitingRequest.data).then( - (res: any) => { - sasjsWaitingRequest.requestPromise.resolve(res) - }, - (err: any) => { - sasjsWaitingRequest.requestPromise.reject(err) - } - ) - } - - this.sasjsWaitingRequests = [] - } - - private getRequestParamsWeb(config: any): any { - const requestParams: any = {} - - if (this.csrfTokenWeb) { - requestParams['_csrf'] = this.csrfTokenWeb.value - } - - if (config.debug) { - requestParams['_omittextlog'] = 'false' - requestParams['_omitsessionresults'] = 'false' - - requestParams['_debug'] = 131 - } - - return requestParams - } - - private parseSASVIYADebugResponse(response: string) { - return new Promise((resolve, reject) => { - const iframeStart = response.split( - '')[0] : null - - if (jsonUrl) { - fetch(this.sasjsConfig.serverUrl + jsonUrl) - .then((res) => res.text()) - .then((resText) => { - resolve(resText) - }) - } else { - reject('No debug info found in response.') - } - }) - } - - private async getJobUri(sasJob: string) { - if (!this.sasViyaApiClient) return '' - let uri = '' - - let folderPath - let jobName: string - if (isRelativePath(sasJob)) { - folderPath = sasJob.split('/')[0] - jobName = sasJob.split('/')[1] - } else { - const folderPathParts = sasJob.split('/') - jobName = folderPathParts.pop() || '' - folderPath = folderPathParts.join('/') - } - - const locJobs = await this.sasViyaApiClient.getJobsInFolder(folderPath) - if (locJobs) { - const job = locJobs.find( - (el: any) => el.name === jobName && el.contentType === 'jobDefinition' - ) - if (job) { - uri = job.uri - } - } - return uri - } - - private parseSAS9ErrorResponse(response: string) { - const logLines = response.split('\n') - const parsedLines: string[] = [] - let firstErrorLineIndex: number = -1 - - logLines.map((line: string, index: number) => { - if ( - line.toLowerCase().includes('error') && - !line.toLowerCase().includes('this request completed with errors.') && - firstErrorLineIndex === -1 - ) { - firstErrorLineIndex = index - } - }) - - for (let i = firstErrorLineIndex - 10; i <= firstErrorLineIndex + 10; i++) { - parsedLines.push(logLines[i]) - } - - return parsedLines.join(', ') - } - - private parseLogFromResponse(response: any, program: string) { - if (this.sasjsConfig.serverType === ServerType.Sas9) { - this.appendSasjsRequest(response, program, null) - } else { - if (!this.sasjsConfig.debug) { - this.appendSasjsRequest(null, program, null) - } else { - this.appendSasjsRequest(response, program, null) - } - } + await this.webJobExecutor?.resendWaitingRequests() + await this.computeJobExecutor?.resendWaitingRequests() + await this.jesJobExecutor?.resendWaitingRequests() } /** @@ -1267,89 +679,20 @@ export default class SASjs { }) } - private async appendSasjsRequest( - response: any, - program: string, - pgmData: any - ) { - let sourceCode = '' - let generatedCode = '' - let sasWork = null - - if (response && response.result && response.log) { - sourceCode = parseSourceCode(response.log) - generatedCode = parseGeneratedCode(response.log) - - if (this.sasjsConfig.debug) { - if (response.log) { - sasWork = response.log - } else { - sasWork = JSON.parse(parseWeboutResponse(response.result)).WORK - } - } else { - sasWork = JSON.parse(response.result).WORK - } - } else { - if (response) { - sourceCode = parseSourceCode(response) - generatedCode = parseGeneratedCode(response) - sasWork = await this.parseSasWork(response) - } - } - - this.sasjsRequests.push({ - logFile: (response && response.log) || response, - serviceLink: program, - timestamp: new Date(), - sourceCode, - generatedCode, - SASWORK: sasWork - }) - - if (this.sasjsRequests.length > 20) { - this.sasjsRequests.splice(0, 1) - } - } - - private async parseSasWork(response: any) { - if (this.sasjsConfig.debug) { - let jsonResponse - - if (this.sasjsConfig.serverType === ServerType.Sas9) { - try { - jsonResponse = JSON.parse(parseWeboutResponse(response)) - } catch (e) { - console.error(e) - } - } else { - await this.parseSASVIYADebugResponse(response).then( - (resText: any) => { - try { - jsonResponse = JSON.parse(resText) - } catch (e) { - console.error(e) - } - }, - (err: any) => { - console.error(err) - } - ) - } - - if (jsonResponse) { - return jsonResponse.WORK - } - } - return null - } - public getSasRequests() { - const sortedRequests = this.sasjsRequests.sort(compareTimestamps) + const requests = [ + ...this.webJobExecutor!.getRequests(), + ...this.computeJobExecutor!.getRequests(), + ...this.jesJobExecutor!.getRequests() + ] + const sortedRequests = requests.sort(compareTimestamps) return sortedRequests } public clearSasRequests() { - this.sasjsRequests = [] + this.webJobExecutor!.clearRequests() + this.computeJobExecutor!.clearRequests() + this.jesJobExecutor!.clearRequests() } private setupConfiguration() { @@ -1413,6 +756,19 @@ export default class SASjs { this.jobsPath, this.requestClient ) + + this.webJobExecutor = new WebJobExecutor( + this.sasjsConfig.serverUrl, + this.sasjsConfig.serverType!, + this.jobsPath, + this.requestClient, + this.sasViyaApiClient! + ) + + this.computeJobExecutor = new ComputeJobExecutor( + this.sasjsConfig.serverUrl, + this.sasViyaApiClient! + ) } private async createFoldersAndServices( diff --git a/src/SessionManager.ts b/src/SessionManager.ts index 54d1476..7b5bfcd 100644 --- a/src/SessionManager.ts +++ b/src/SessionManager.ts @@ -1,7 +1,7 @@ import { Session, Context, CsrfToken, SessionVariable } from './types' import { asyncForEach, isUrl } from './utils' import { prefixMessage } from '@sasjs/utils/error' -import { RequestClient } from './request/client' +import { RequestClient } from './request/RequestClient' const MAX_SESSION_COUNT = 1 const RETRY_LIMIT: number = 3 diff --git a/src/file/generateFileUploadForm.ts b/src/file/generateFileUploadForm.ts new file mode 100644 index 0000000..5e2cdcc --- /dev/null +++ b/src/file/generateFileUploadForm.ts @@ -0,0 +1,24 @@ +import { convertToCSV } from '../utils/convertToCsv' + +export const generateFileUploadForm = ( + formData: FormData, + data: any +): FormData => { + for (const tableName in data) { + const name = tableName + const csv = convertToCSV(data[tableName]) + if (csv === 'ERROR: LARGE STRING LENGTH') { + throw new Error( + 'The max length of a string value in SASjs is 32765 characters.' + ) + } + + const file = new Blob([csv], { + type: 'application/csv' + }) + + formData.append(name, file, `${name}.csv`) + } + + return formData +} diff --git a/src/file/generateTableUploadForm.ts b/src/file/generateTableUploadForm.ts new file mode 100644 index 0000000..140cae4 --- /dev/null +++ b/src/file/generateTableUploadForm.ts @@ -0,0 +1,31 @@ +import { convertToCSV } from '../utils/convertToCsv' +import { splitChunks } from '../utils/splitChunks' + +export const generateTableUploadForm = (formData: FormData, data: any) => { + const sasjsTables = [] + const requestParams: any = {} + let tableCounter = 0 + for (const tableName in data) { + tableCounter++ + sasjsTables.push(tableName) + const csv = convertToCSV(data[tableName]) + if (csv === 'ERROR: LARGE STRING LENGTH') { + throw new Error( + 'The max length of a string value in SASjs is 32765 characters.' + ) + } + // if csv has length more then 16k, send in chunks + if (csv.length > 16000) { + const csvChunks = splitChunks(csv) + // append chunks to form data with same key + csvChunks.map((chunk) => { + formData.append(`sasjs${tableCounter}data`, chunk) + }) + } else { + requestParams[`sasjs${tableCounter}data`] = csv + } + } + requestParams['sasjs_tables'] = sasjsTables.join(' ') + + return { formData, requestParams } +} diff --git a/src/job-execution/ComputeJobExecutor.ts b/src/job-execution/ComputeJobExecutor.ts new file mode 100644 index 0000000..9c56762 --- /dev/null +++ b/src/job-execution/ComputeJobExecutor.ts @@ -0,0 +1,132 @@ +import { ServerType } from '@sasjs/utils/types' +import { ErrorResponse } from '..' +import { SASViyaApiClient } from '../SASViyaApiClient' +import { JobExecutionError, LoginRequiredError, SASjsRequest } from '../types' +import { + asyncForEach, + parseGeneratedCode, + parseSourceCode, + parseWeboutResponse +} from '../utils' +import { ExecuteFunction, JobExecutor } from './JobExecutor' +import { parseSasWork } from './parseSasWork' + +export class ComputeJobExecutor implements JobExecutor { + waitingRequests: ExecuteFunction[] = [] + requests: SASjsRequest[] = [] + + constructor( + private serverUrl: string, + private sasViyaApiClient: SASViyaApiClient + ) {} + + async execute( + sasJob: string, + data: any, + config: any, + loginRequiredCallback?: any, + accessToken?: string + ) { + const loginCallback = loginRequiredCallback || (() => Promise.resolve()) + const waitForResult = true + const expectWebout = true + this.sasViyaApiClient + ?.executeComputeJob( + sasJob, + config.contextName, + config.debug, + data, + accessToken, + waitForResult, + expectWebout + ) + .then((response) => { + this.appendRequest(response, sasJob, config.debug) + + let responseJson + + try { + if (typeof response!.result === 'string') { + responseJson = JSON.parse(response!.result) + } else { + responseJson = response!.result + } + } catch { + responseJson = JSON.parse(parseWeboutResponse(response!.result)) + } + + return responseJson + }) + .catch(async (e: Error) => { + if (e instanceof JobExecutionError) { + this.appendRequest(e, sasJob, config.debug) + } + if (e instanceof LoginRequiredError) { + await loginCallback() + this.waitingRequests.push(() => + this.execute(sasJob, data, config, loginRequiredCallback) + ) + } + return Promise.reject(new ErrorResponse(e?.message, e)) + }) + } + + resendWaitingRequests = async () => { + await asyncForEach( + this.waitingRequests, + async (waitingRequest: ExecuteFunction) => { + await waitingRequest() + } + ) + + this.waitingRequests = [] + return + } + + getRequests = () => this.requests + + clearRequests = () => { + this.requests = [] + } + + private async appendRequest(response: any, program: string, debug: boolean) { + let sourceCode = '' + let generatedCode = '' + let sasWork = null + + if (debug) { + if (response?.result && response?.log) { + sourceCode = parseSourceCode(response.log) + generatedCode = parseGeneratedCode(response.log) + + if (response.log) { + sasWork = response.log + } else { + sasWork = JSON.parse(parseWeboutResponse(response.result)).WORK + } + } else if (response?.result) { + sourceCode = parseSourceCode(response.result) + generatedCode = parseGeneratedCode(response.result) + sasWork = await parseSasWork( + response.result, + debug, + this.serverUrl, + ServerType.SasViya + ) + } + } + + this.requests.push({ + logFile: response?.log || response?.result || response, + serviceLink: program, + timestamp: new Date(), + sourceCode, + generatedCode, + SASWORK: sasWork + }) + + if (this.requests.length > 20) { + this.requests.splice(0, 1) + } + } +} diff --git a/src/job-execution/JesJobExecutor.ts b/src/job-execution/JesJobExecutor.ts new file mode 100644 index 0000000..786da0a --- /dev/null +++ b/src/job-execution/JesJobExecutor.ts @@ -0,0 +1,122 @@ +import { ServerType } from '@sasjs/utils/types' +import { ErrorResponse } from '..' +import { SASViyaApiClient } from '../SASViyaApiClient' +import { JobExecutionError, LoginRequiredError, SASjsRequest } from '../types' +import { + asyncForEach, + parseGeneratedCode, + parseSourceCode, + parseWeboutResponse +} from '../utils' +import { ExecuteFunction, JobExecutor } from './JobExecutor' +import { parseSasWork } from './parseSasWork' + +export class JesJobExecutor implements JobExecutor { + waitingRequests: ExecuteFunction[] = [] + requests: SASjsRequest[] = [] + + constructor( + private serverUrl: string, + private sasViyaApiClient: SASViyaApiClient + ) {} + + async execute( + sasJob: string, + data: any, + config: any, + loginRequiredCallback?: any, + accessToken?: string + ) { + const loginCallback = loginRequiredCallback || (() => Promise.resolve()) + await this.sasViyaApiClient + ?.executeJob(sasJob, config.contextName, config.debug, data, accessToken) + .then((response) => { + this.appendRequest(response, sasJob, config.debug) + + let responseJson + + try { + if (typeof response!.result === 'string') { + responseJson = JSON.parse(response!.result) + } else { + responseJson = response!.result + } + } catch { + responseJson = JSON.parse(parseWeboutResponse(response!.result)) + } + + return responseJson + }) + .catch(async (e: Error) => { + if (e instanceof JobExecutionError) { + this.appendRequest(e, sasJob, config.debug) + } + if (e instanceof LoginRequiredError) { + await loginCallback() + this.waitingRequests.push(() => + this.execute(sasJob, data, config, loginRequiredCallback) + ) + } + return Promise.reject(new ErrorResponse(e?.message, e)) + }) + } + + resendWaitingRequests = async () => { + await asyncForEach( + this.waitingRequests, + async (waitingRequest: ExecuteFunction) => { + await waitingRequest() + } + ) + + this.waitingRequests = [] + return + } + + getRequests = () => this.requests + + clearRequests = () => { + this.requests = [] + } + + private async appendRequest(response: any, program: string, debug: boolean) { + let sourceCode = '' + let generatedCode = '' + let sasWork = null + + if (debug) { + if (response?.result && response?.log) { + sourceCode = parseSourceCode(response.log) + generatedCode = parseGeneratedCode(response.log) + + if (response.log) { + sasWork = response.log + } else { + sasWork = JSON.parse(parseWeboutResponse(response.result)).WORK + } + } else if (response?.result) { + sourceCode = parseSourceCode(response.result) + generatedCode = parseGeneratedCode(response.result) + sasWork = await parseSasWork( + response.result, + debug, + this.serverUrl, + ServerType.SasViya + ) + } + } + + this.requests.push({ + logFile: response?.log || response?.result || response, + serviceLink: program, + timestamp: new Date(), + sourceCode, + generatedCode, + SASWORK: sasWork + }) + + if (this.requests.length > 20) { + this.requests.splice(0, 1) + } + } +} diff --git a/src/job-execution/JobExecutor.ts b/src/job-execution/JobExecutor.ts new file mode 100644 index 0000000..5b8b784 --- /dev/null +++ b/src/job-execution/JobExecutor.ts @@ -0,0 +1,17 @@ +import { SASjsRequest } from '../types' + +export type ExecuteFunction = () => Promise + +export interface JobExecutor { + execute: ( + sasJob: string, + data: any, + config: any, + loginRequiredCallback?: any, + accessToken?: string + ) => Promise + waitingRequests: ExecuteFunction[] + resendWaitingRequests: () => Promise + getRequests: () => SASjsRequest[] + clearRequests: () => void +} diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts new file mode 100644 index 0000000..d43c32c --- /dev/null +++ b/src/job-execution/WebJobExecutor.ts @@ -0,0 +1,239 @@ +import { ServerType } from '@sasjs/utils/types' +import { ErrorResponse, JobExecutionError, LoginRequiredError } from '..' +import { generateFileUploadForm } from '../file/generateFileUploadForm' +import { generateTableUploadForm } from '../file/generateTableUploadForm' +import { RequestClient } from '../request/RequestClient' +import { SASViyaApiClient } from '../SASViyaApiClient' +import { SASjsRequest } from '../types' +import { + asyncForEach, + isRelativePath, + parseGeneratedCode, + parseSourceCode, + parseWeboutResponse +} from '../utils' +import { ExecuteFunction, JobExecutor } from './JobExecutor' +import { parseSasWork } from './parseSasWork' + +export class WebJobExecutor implements JobExecutor { + waitingRequests: ExecuteFunction[] = [] + requests: SASjsRequest[] = [] + + constructor( + private serverUrl: string, + private serverType: ServerType, + private jobsPath: string, + private requestClient: RequestClient, + private sasViyaApiClient: SASViyaApiClient + ) {} + + async execute( + sasJob: string, + data: any, + config: any, + loginRequiredCallback?: any + ) { + const loginCallback = loginRequiredCallback || (() => Promise.resolve()) + const program = isRelativePath(sasJob) + ? config.appLoc + ? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '') + : sasJob + : sasJob + const jobUri = + config.serverType === ServerType.SasViya + ? await this.getJobUri(sasJob) + : '' + const apiUrl = `${config.serverUrl}${this.jobsPath}/?${ + jobUri.length > 0 + ? '__program=' + program + '&_job=' + jobUri + : '_program=' + program + }` + + let requestParams = { + ...this.getRequestParams(config) + } + + let formData = new FormData() + + if (data) { + const stringifiedData = JSON.stringify(data) + if ( + config.serverType === ServerType.Sas9 || + stringifiedData.length > 500000 || + stringifiedData.includes(';') + ) { + // file upload approach + try { + formData = generateFileUploadForm(formData, data) + } catch (e) { + return Promise.reject(new ErrorResponse(e?.message, e)) + } + } else { + // param based approach + try { + const { + formData: newFormData, + requestParams: params + } = generateTableUploadForm(formData, data) + formData = newFormData + requestParams = { ...requestParams, ...params } + } catch (e) { + return Promise.reject(new ErrorResponse(e?.message, e)) + } + } + } + + for (const key in requestParams) { + if (requestParams.hasOwnProperty(key)) { + formData.append(key, requestParams[key]) + } + } + + return this.requestClient!.post( + apiUrl, + formData, + undefined, + 'application/json', + { + referrerPolicy: 'same-origin' + } + ) + .then(async (res) => { + this.appendRequest(res, sasJob, config.debug) + return res.result + }) + .catch(async (e: Error) => { + if (e instanceof JobExecutionError) { + this.appendRequest(e, sasJob, config.debug) + } + if (e instanceof LoginRequiredError) { + await loginCallback() + this.waitingRequests.push(() => + this.execute(sasJob, data, config, loginRequiredCallback) + ) + } + return Promise.reject(new ErrorResponse(e?.message, e)) + }) + } + + resendWaitingRequests = async () => { + await asyncForEach( + this.waitingRequests, + async (waitingRequest: ExecuteFunction) => { + await waitingRequest() + } + ) + + this.waitingRequests = [] + return + } + + getRequests = () => this.requests + + clearRequests = () => { + this.requests = [] + } + + private async getJobUri(sasJob: string) { + if (!this.sasViyaApiClient) return '' + let uri = '' + + let folderPath + let jobName: string + if (isRelativePath(sasJob)) { + folderPath = sasJob.split('/')[0] + jobName = sasJob.split('/')[1] + } else { + const folderPathParts = sasJob.split('/') + jobName = folderPathParts.pop() || '' + folderPath = folderPathParts.join('/') + } + + const locJobs = await this.sasViyaApiClient.getJobsInFolder(folderPath) + if (locJobs) { + const job = locJobs.find( + (el: any) => el.name === jobName && el.contentType === 'jobDefinition' + ) + if (job) { + uri = job.uri + } + } + return uri + } + + private getRequestParams(config: any): any { + const requestParams: any = {} + + if (config.debug) { + requestParams['_omittextlog'] = 'false' + requestParams['_omitsessionresults'] = 'false' + + requestParams['_debug'] = 131 + } + + return requestParams + } + + private async appendRequest(response: any, program: string, debug: boolean) { + let sourceCode = '' + let generatedCode = '' + let sasWork = null + + if (debug) { + if (response?.result && response?.log) { + sourceCode = parseSourceCode(response.log) + generatedCode = parseGeneratedCode(response.log) + + if (response.log) { + sasWork = response.log + } else { + sasWork = JSON.parse(parseWeboutResponse(response.result)).WORK + } + } else if (response?.result) { + sourceCode = parseSourceCode(response.result) + generatedCode = parseGeneratedCode(response.result) + sasWork = await parseSasWork( + response.result, + debug, + this.serverUrl, + this.serverType + ) + } + } + + this.requests.push({ + logFile: response?.log || response?.result || response, + serviceLink: program, + timestamp: new Date(), + sourceCode, + generatedCode, + SASWORK: sasWork + }) + + if (this.requests.length > 20) { + this.requests.splice(0, 1) + } + } + + private parseSAS9ErrorResponse(response: string) { + const logLines = response.split('\n') + const parsedLines: string[] = [] + let firstErrorLineIndex: number = -1 + + logLines.map((line: string, index: number) => { + if ( + line.toLowerCase().includes('error') && + !line.toLowerCase().includes('this request completed with errors.') && + firstErrorLineIndex === -1 + ) { + firstErrorLineIndex = index + } + }) + + for (let i = firstErrorLineIndex - 10; i <= firstErrorLineIndex + 10; i++) { + parsedLines.push(logLines[i]) + } + + return parsedLines.join(', ') + } +} diff --git a/src/job-execution/index.ts b/src/job-execution/index.ts new file mode 100644 index 0000000..f053824 --- /dev/null +++ b/src/job-execution/index.ts @@ -0,0 +1,5 @@ +export * from './ComputeJobExecutor' +export * from './JesJobExecutor' +export * from './JobExecutor' +export * from './parseSasWork' +export * from './WebJobExecutor' diff --git a/src/job-execution/parseSasWork.ts b/src/job-execution/parseSasWork.ts new file mode 100644 index 0000000..9057073 --- /dev/null +++ b/src/job-execution/parseSasWork.ts @@ -0,0 +1,61 @@ +import { ServerType } from '@sasjs/utils/types' +import { parseWeboutResponse } from '../utils' + +export const parseSasWork = async ( + response: any, + debug: boolean, + serverUrl: string, + serverType: ServerType +) => { + if (debug) { + let jsonResponse + + if (serverType === ServerType.Sas9) { + try { + jsonResponse = JSON.parse(parseWeboutResponse(response)) + } catch (e) { + console.error(e) + } + } else { + await parseSASVIYADebugResponse(response, serverUrl).then( + (resText: any) => { + try { + jsonResponse = JSON.parse(resText) + } catch (e) { + console.error(e) + } + }, + (err: any) => { + console.error(err) + } + ) + } + + if (jsonResponse) { + return jsonResponse.WORK + } + } + return null +} + +const parseSASVIYADebugResponse = async ( + response: string, + serverUrl: string +) => { + return new Promise((resolve, reject) => { + const iframeStart = response.split( + '')[0] : null + + if (jsonUrl) { + fetch(serverUrl + jsonUrl) + .then((res) => res.text()) + .then((resText) => { + resolve(resText) + }) + } else { + reject('No debug info found in response.') + } + }) +} diff --git a/src/request/client.ts b/src/request/RequestClient.ts similarity index 66% rename from src/request/client.ts rename to src/request/RequestClient.ts index a8a46eb..d609cac 100644 --- a/src/request/client.ts +++ b/src/request/RequestClient.ts @@ -1,5 +1,7 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' -import { CsrfToken } from '..' +import { CsrfToken, JobExecutionError } from '..' +import { LoginRequiredError } from '../types' +import { AuthorizeError } from '../types/AuthorizeError' export class RequestClient { private csrfToken: CsrfToken | undefined @@ -58,24 +60,30 @@ export class RequestClient { url: string, data: any, accessToken: string | undefined, + contentType = 'application/json', overrideHeaders: { [key: string]: string | number } = {} ): Promise<{ result: T; etag: string }> { const headers = { - ...this.getHeaders(accessToken, 'application/json'), + ...this.getHeaders(accessToken, contentType), ...overrideHeaders } return this.httpClient .post(url, data, { headers, withCredentials: true }) .then((response) => { + throwIfError(response) return { result: response.data as T, etag: response.headers['etag'] as string } }) - .catch((e) => { + .catch(async (e) => { const response = e.response as AxiosResponse - if (response.status === 403 || response.status === 449) { + if (e instanceof AuthorizeError) { + await this.post(e.confirmUrl, { value: true }, undefined) + return this.post(url, data, accessToken) + } + if (response?.status === 403 || response?.status === 449) { this.parseAndSetCsrfToken(response) if (this.csrfToken) { @@ -108,15 +116,19 @@ export class RequestClient { etag: response.headers['etag'] as string } } catch (e) { - const response_1 = e.response as AxiosResponse - if (response_1.status === 403 || response_1.status === 449) { - this.parseAndSetCsrfToken(response_1) + const response = e.response as AxiosResponse + if (response?.status === 403 || response?.status === 449) { + this.parseAndSetCsrfToken(response) if (this.csrfToken) { return this.put(url, data, accessToken) } throw e } + + if (response?.status === 401) { + throw new LoginRequiredError() + } throw e } } @@ -138,9 +150,9 @@ export class RequestClient { etag: response.headers['etag'] } } catch (e) { - const response_1 = e.response as AxiosResponse - if (response_1.status === 403 || response_1.status === 449) { - this.parseAndSetCsrfToken(response_1) + const response = e.response as AxiosResponse + if (response?.status === 403 || response?.status === 449) { + this.parseAndSetCsrfToken(response) if (this.csrfToken) { return this.delete(url, accessToken) @@ -168,9 +180,9 @@ export class RequestClient { etag: response.headers['etag'] as string } } catch (e) { - const response_1 = e.response as AxiosResponse - if (response_1.status === 403 || response_1.status === 449) { - this.parseAndSetCsrfToken(response_1) + const response = e.response as AxiosResponse + if (response?.status === 403 || response?.status === 449) { + this.parseAndSetCsrfToken(response) if (this.csrfToken) { return this.patch(url, accessToken) @@ -201,9 +213,9 @@ export class RequestClient { etag: response.headers['etag'] as string } } catch (e) { - const response_1 = e.response as AxiosResponse - if (response_1.status === 403 || response_1.status === 449) { - this.parseAndSetFileUploadCsrfToken(response_1) + 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) @@ -214,10 +226,17 @@ export class RequestClient { } } - private getHeaders(accessToken: string | undefined, contentType: string) { + private getHeaders = ( + accessToken: string | undefined, + contentType: string + ) => { const headers: any = { 'Content-Type': contentType } + + if (contentType === 'text/plain') { + headers.Accept = '*/*' + } if (accessToken) { headers.Authorization = `Bearer ${accessToken}` } @@ -228,7 +247,7 @@ export class RequestClient { return headers } - private parseAndSetFileUploadCsrfToken(response: AxiosResponse) { + private parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => { const token = this.parseCsrfToken(response) if (token) { @@ -236,7 +255,7 @@ export class RequestClient { } } - private parseAndSetCsrfToken(response: AxiosResponse) { + private parseAndSetCsrfToken = (response: AxiosResponse) => { const token = this.parseCsrfToken(response) if (token) { @@ -244,7 +263,7 @@ export class RequestClient { } } - private parseCsrfToken(response: AxiosResponse): CsrfToken | undefined { + private parseCsrfToken = (response: AxiosResponse): CsrfToken | undefined => { const tokenHeader = (response.headers[ 'x-csrf-header' ] as string)?.toLowerCase() @@ -260,3 +279,54 @@ export class RequestClient { } } } + +const throwIfError = (response: AxiosResponse) => { + if (response.data?.entityID?.includes('login')) { + throw new LoginRequiredError() + } + + if (response.data?.auth_request) { + throw new AuthorizeError( + response.data.message, + response.data.options.confirm.location + ) + } + + const error = parseError(response.data as string) + if (error) { + throw error + } +} + +const parseError = (data: string) => { + try { + const responseJson = JSON.parse(data?.replace(/[\n\r]/g, ' ')) + return responseJson.errorCode && responseJson.message + ? new JobExecutionError( + responseJson.errorCode, + responseJson.message, + data?.replace(/[\n\r]/g, ' ') + ) + : null + } catch (_) { + try { + const hasError = data?.includes('{"errorCode') + if (hasError) { + const parts = data.split('{"errorCode') + if (parts.length > 1) { + const error = '{"errorCode' + parts[1].split('"}')[0] + '"}' + const errorJson = JSON.parse(error.replace(/[\n\r]/g, ' ')) + return new JobExecutionError( + errorJson.errorCode, + errorJson.message, + data?.replace(/[\n\r]/g, '\n') + ) + } + return null + } + return null + } catch (_) { + return null + } + } +} diff --git a/src/types/AuthorizeError.ts b/src/types/AuthorizeError.ts new file mode 100644 index 0000000..694f84f --- /dev/null +++ b/src/types/AuthorizeError.ts @@ -0,0 +1,7 @@ +export class AuthorizeError extends Error { + constructor(public message: string, public confirmUrl: string) { + super(message) + this.name = 'AuthorizeError' + Object.setPrototypeOf(this, AuthorizeError.prototype) + } +} diff --git a/src/types/JobExecutionError.ts b/src/types/JobExecutionError.ts new file mode 100644 index 0000000..dc542b2 --- /dev/null +++ b/src/types/JobExecutionError.ts @@ -0,0 +1,11 @@ +export class JobExecutionError extends Error { + constructor( + public errorCode: number, + public errorMessage: string, + public result: string + ) { + super(`Error Code ${errorCode}: ${errorMessage}`) + this.name = 'JobExecutionError' + Object.setPrototypeOf(this, JobExecutionError.prototype) + } +} diff --git a/src/types/LoginRequiredError.ts b/src/types/LoginRequiredError.ts new file mode 100644 index 0000000..854c032 --- /dev/null +++ b/src/types/LoginRequiredError.ts @@ -0,0 +1,7 @@ +export class LoginRequiredError extends Error { + constructor() { + super('Auth error: You must be logged in to access this resource') + this.name = 'LoginRequiredError' + Object.setPrototypeOf(this, LoginRequiredError.prototype) + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 6d45331..2dc953d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,9 +3,11 @@ export * from './CsrfToken' export * from './ErrorResponse' export * from './Folder' export * from './Job' +export * from './JobExecutionError' export * from './JobDefinition' export * from './JobResult' export * from './Link' +export * from './LoginRequiredError' export * from './SASjsConfig' export * from './SASjsRequest' export * from './SASjsWaitingRequest'