diff --git a/sasjs-tests/public/config.json b/sasjs-tests/public/config.json index 1bd77e0..070b6ac 100644 --- a/sasjs-tests/public/config.json +++ b/sasjs-tests/public/config.json @@ -6,6 +6,7 @@ "appLoc": "/Public/app", "serverType": "SASVIYA", "debug": false, - "contextName": "SAS Job Execution compute context" + "contextName": "SharedCompute", + "useComputeApi": true } } diff --git a/sasjs-tests/src/Login.tsx b/sasjs-tests/src/Login.tsx index 158f908..38f05f2 100644 --- a/sasjs-tests/src/Login.tsx +++ b/sasjs-tests/src/Login.tsx @@ -11,7 +11,7 @@ const Login = (): ReactElement<{}> => { const handleSubmit = useCallback( (e) => { e.preventDefault(); - appContext.adapter.logIn(username, password).then(() => { + appContext.adapter.logIn(username, password).then((res) => { appContext.setIsLoggedIn(true); }); }, diff --git a/sasjs-tests/src/testSuites/Basic.ts b/sasjs-tests/src/testSuites/Basic.ts index 4dbf4fe..ee0be3d 100644 --- a/sasjs-tests/src/testSuites/Basic.ts +++ b/sasjs-tests/src/testSuites/Basic.ts @@ -9,6 +9,7 @@ const defaultConfig: SASjsConfig = { serverType: ServerType.SASViya, debug: true, contextName: "SAS Job Execution compute context", + useComputeApi: false, }; const customConfig = { diff --git a/sasjs-tests/src/testSuites/RequestData.ts b/sasjs-tests/src/testSuites/RequestData.ts index f31a1b2..2ed3672 100644 --- a/sasjs-tests/src/testSuites/RequestData.ts +++ b/sasjs-tests/src/testSuites/RequestData.ts @@ -74,9 +74,8 @@ export const sendArrTests = (adapter: SASjs): TestSuite => ({ description: "Should error out with long string values over 32765 characters", test: () => { - return adapter - .request("common/sendArr", getLongStringData(32767)) - .catch((e) => e); + const data = getLongStringData(32767); + return adapter.request("common/sendArr", data).catch((e) => e); }, assertion: (error: any) => { return !!error && !!error.MESSAGE; diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index c10b389..06b503c 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -7,6 +7,8 @@ import { import * as NodeFormData from "form-data"; import * as path from "path"; import { Job, Session, Context, Folder, CsrfToken } from "./types"; +import { JobDefinition } from "./types/JobDefinition"; +import { formatDataForRequest } from "./utils/formatDataForRequest"; /** * A client for interfacing with the SAS Viya REST API @@ -68,7 +70,7 @@ export class SASViyaApiClient { if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } - const contexts = await this.request<{ items: Context[] }>( + const { result: contexts } = await this.request<{ items: Context[] }>( `${this.serverUrl}/compute/contexts`, { headers } ); @@ -93,7 +95,7 @@ export class SASViyaApiClient { if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } - const contexts = await this.request<{ items: Context[] }>( + const { result: contexts } = await this.request<{ items: Context[] }>( `${this.serverUrl}/compute/contexts`, { headers } ); @@ -153,7 +155,7 @@ export class SASViyaApiClient { headers.Authorization = `Bearer ${accessToken}`; } - const contexts = await this.request<{ items: Context[] }>( + const { result: contexts } = await this.request<{ items: Context[] }>( `${this.serverUrl}/compute/contexts`, { headers } ); @@ -172,7 +174,7 @@ export class SASViyaApiClient { "Content-Type": "application/json", }, }; - const createdSession = this.request( + const { result: createdSession } = await this.request( `${this.serverUrl}/compute/contexts/${executionContext.id}/sessions`, createSessionRequest ); @@ -190,12 +192,14 @@ export class SASViyaApiClient { * @param silent - optional flag to turn of logging. */ public async executeScript( - fileName: string, + jobName: string, linesOfCode: string[], contextName: string, accessToken?: string, sessionId = "", - silent = false + silent = false, + data = null, + debug = false ) { const headers: any = { "Content-Type": "application/json", @@ -206,7 +210,7 @@ export class SASViyaApiClient { if (this.csrfToken) { headers[this.csrfToken.headerName] = this.csrfToken.value; } - const contexts = await this.request<{ items: Context[] }>( + const { result: contexts } = await this.request<{ items: Context[] }>( `${this.serverUrl}/compute/contexts`, { headers } ); @@ -225,13 +229,52 @@ export class SASViyaApiClient { method: "POST", headers, }; - const createdSession = await this.request( + const { result: createdSession, etag } = await this.request( `${this.serverUrl}/compute/contexts/${executionContext.id}/sessions`, createSessionRequest ); + await this.waitForSession(createdSession, etag); + executionSessionId = createdSession.id; } + + let jobArguments: { [key: string]: any } = { + _contextName: contextName, + _OMITJSONLISTING: true, + _OMITJSONLOG: true, + _OMITSESSIONRESULTS: true, + _OMITTEXTLISTING: true, + _OMITTEXTLOG: true, + }; + + if (debug) { + jobArguments["_OMITTEXTLOG"] = false; + jobArguments["_OMITSESSIONRESULTS"] = false; + jobArguments["_DEBUG"] = 131; + } + + const fileName = `exec-${ + jobName.includes("/") ? jobName.split("/")[1] : jobName + }`; + + let jobVariables: any = { SYS_JES_JOB_URI: "", _program: jobName }; + let files: any[] = []; + if (data) { + if (JSON.stringify(data).includes(";")) { + files = await this.uploadTables(data, accessToken); + jobVariables["_webin_file_count"] = files.length; + files.forEach((fileInfo, index) => { + jobVariables[ + `_webin_fileuri${index + 1}` + ] = `/files/files/${fileInfo.file.id}`; + jobVariables[`_webin_name${index + 1}`] = fileInfo.tableName; + }); + } else { + jobVariables = { ...jobVariables, ...formatDataForRequest(data) }; + } + } + // Execute job in session const postJobRequest = { method: "POST", @@ -240,9 +283,11 @@ export class SASViyaApiClient { name: fileName, description: "Powered by SASjs", code: linesOfCode, + variables: jobVariables, + arguments: jobArguments, }), }; - const postedJob = await this.request( + const { result: postedJob, etag } = await this.request( `${this.serverUrl}/compute/sessions/${executionSessionId}/jobs`, postJobRequest ); @@ -255,18 +300,42 @@ export class SASViyaApiClient { ); } - const jobStatus = await this.pollJobState(postedJob, accessToken, silent); - const logLink = postedJob.links.find((l: any) => l.rel === "log"); - if (logLink) { - const log = await this.request( - `${this.serverUrl}${logLink.href}?limit=100000`, + const jobStatus = await this.pollJobState( + postedJob, + etag, + accessToken, + silent + ); + const { result: currentJob } = await this.request( + `${this.serverUrl}/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`, + { headers } + ); + + let jobResult, log; + if (jobStatus === "failed" || jobStatus === "error") { + return Promise.reject(currentJob.error); + } + const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`; + const logLink = currentJob.links.find((l) => l.rel === "log"); + if (resultLink) { + jobResult = await this.request( + `${this.serverUrl}${resultLink}`, + { headers }, + "text" + ); + } + + if (true && logLink) { + log = await this.request( + `${this.serverUrl}${logLink.href}/content`, { headers, } + ).then((res: any) => + res.result.items.map((i: any) => i.line).join("\n") ); - - return { jobStatus, log }; } + return { result: jobResult?.result, log }; } else { console.error( `Unable to find execution context ${contextName}.\nPlease check the contextName in the tgtDeployVars and try again.` @@ -334,7 +403,7 @@ export class SASViyaApiClient { createFolderRequest.headers.Authorization = `Bearer ${accessToken}`; } - const createFolderResponse = await this.request( + const { result: createFolderResponse } = await this.request( `${this.serverUrl}/folders/folders?parentFolderUri=${parentFolderUri}`, createFolderRequest ); @@ -556,6 +625,71 @@ export class SASViyaApiClient { return deleteResponse; } + /** + * Executes a job via the SAS Viya Compute API + * @param sasJob - the relative path to the job. + * @param contextName - the name of the context where the job is to be executed. + * @param debug - sets the _debug flag in the job arguments. + * @param data - any data to be passed in as input to the job. + * @param accessToken - an optional access token for an authorized user. + */ + public async executeComputeJob( + sasJob: string, + contextName: string, + debug: boolean, + data?: any, + accessToken?: string + ) { + if (!this.rootFolder) { + await this.populateRootFolder(accessToken); + } + + if (!this.rootFolder) { + throw new Error("Root folder was not found"); + } + if (!this.rootFolderMap.size) { + await this.populateRootFolderMap(accessToken); + } + if (!this.rootFolderMap.size) { + throw new Error( + `The job ${sasJob} was not found in ${this.rootFolderName}` + ); + } + + const headers: any = { "Content-Type": "application/json" }; + if (!!accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + const folderName = sasJob.split("/")[0]; + const jobName = sasJob.split("/")[1]; + const jobFolder = this.rootFolderMap.get(folderName); + const jobToExecute = jobFolder?.find((item) => item.name === jobName); + const jobDefinitionLink = jobToExecute?.links.find( + (l) => l.rel === "getResource" + ); + if (!jobDefinitionLink) { + throw new Error("Job definition URI was not found."); + } + const { result: jobDefinition } = await this.request( + `${this.serverUrl}${jobDefinitionLink.href}`, + headers + ); + const linesToExecute = jobDefinition.code + .replace(/\r\n/g, "\n") + .split("\n"); + return await this.executeScript( + sasJob, + linesToExecute, + contextName, + accessToken, + "", + false, + data, + debug + ); + } + /** * Executes a job via the SAS Viya Job Execution API * @param sasJob - the relative path to the job. @@ -590,7 +724,6 @@ export class SASViyaApiClient { let files: any[] = []; if (data && Object.keys(data).length) { files = await this.uploadTables(data, accessToken); - console.log("Uploaded table files: ", files); } const jobName = path.basename(sasJob); const jobFolder = sasJob.replace(`/${jobName}`, ""); @@ -608,7 +741,7 @@ export class SASViyaApiClient { headers.Authorization = `Bearer ${accessToken}`; } requestInfo.headers = headers; - const jobDefinition = await this.request( + const { result: jobDefinition } = await this.request( `${this.serverUrl}${jobDefinitionLink}`, requestInfo ); @@ -647,24 +780,29 @@ export class SASViyaApiClient { arguments: jobArguments, }), }; - const postedJob = await this.request( + const { result: postedJob, etag } = await this.request( `${this.serverUrl}/jobExecution/jobs?_action=wait`, postJobRequest ); - const jobStatus = await this.pollJobState(postedJob, accessToken, true); - const currentJob = await this.request( + const jobStatus = await this.pollJobState( + postedJob, + etag, + accessToken, + true + ); + const { result: currentJob } = await this.request( `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, { headers } ); - let result, log; + let jobResult, log; if (jobStatus === "failed") { return Promise.reject(currentJob.error); } const resultLink = currentJob.results["_webout.json"]; const logLink = currentJob.links.find((l) => l.rel === "log"); if (resultLink) { - result = await this.request( + jobResult = await this.request( `${this.serverUrl}${resultLink}/content`, { headers }, "text" @@ -676,9 +814,11 @@ export class SASViyaApiClient { { headers, } - ).then((res: any) => res.items.map((i: any) => i.line).join("\n")); + ).then((res: any) => + res.result.items.map((i: any) => i.line).join("\n") + ); } - return { result, log }; + return { result: jobResult?.result, log }; } else { throw new Error( `The job ${sasJob} was not found at the location ${this.rootFolderName}` @@ -695,14 +835,14 @@ export class SASViyaApiClient { if (accessToken) { requestInfo.headers = { Authorization: `Bearer ${accessToken}` }; } - const folder = await this.request( + const { result: folder } = await this.request( `${this.serverUrl}${url}`, requestInfo ); if (!folder) { throw new Error("Cannot populate RootFolderMap unless rootFolder exists"); } - const members = await this.request<{ items: any[] }>( + const { result: members } = await this.request<{ items: any[] }>( `${this.serverUrl}/folders/folders/${folder.id}/members`, requestInfo ); @@ -717,7 +857,7 @@ export class SASViyaApiClient { this.rootFolderName + "/" + member.name; - const memberDetail = await this.request( + const { result: memberDetail } = await this.request( `${this.serverUrl}${subFolderUrl}`, requestInfo ); @@ -726,7 +866,7 @@ export class SASViyaApiClient { (l: any) => l.rel === "members" ); - const memberContents = await this.request<{ items: any[] }>( + const { result: memberContents } = await this.request<{ items: any[] }>( `${this.serverUrl}${membersLink!.href}`, requestInfo ); @@ -752,26 +892,28 @@ export class SASViyaApiClient { requestInfo ).catch(() => null); - this.rootFolder = rootFolder; + this.rootFolder = rootFolder?.result || null; } private async pollJobState( postedJob: any, + etag: string | null, accessToken?: string, silent = false ) { const MAX_POLL_COUNT = 1000; - const POLL_INTERVAL = 300; + const POLL_INTERVAL = 100; let postedJobState = ""; let pollCount = 0; const headers: any = { "Content-Type": "application/json", + "If-None-Match": etag, }; if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } const stateLink = postedJob.links.find((l: any) => l.rel === "state"); - return new Promise((resolve, _) => { + return new Promise(async (resolve, _) => { const interval = setInterval(async () => { if ( postedJobState === "running" || @@ -782,8 +924,8 @@ export class SASViyaApiClient { if (!silent) { console.log("Polling job status... \n"); } - const jobState = await this.request( - `${this.serverUrl}${stateLink.href}?wait=30`, + const { result: jobState } = await this.request( + `${this.serverUrl}${stateLink.href}?_action=wait&wait=30`, { headers, }, @@ -807,6 +949,49 @@ export class SASViyaApiClient { }); } + private async waitForSession( + session: Session, + etag: string | null, + accessToken?: string, + silent = false + ) { + let sessionState = session.state; + let pollCount = 0; + const headers: any = { + "Content-Type": "application/json", + "If-None-Match": etag, + }; + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + const stateLink = session.links.find((l: any) => l.rel === "state"); + return new Promise(async (resolve, _) => { + if (sessionState === "pending") { + if (stateLink) { + if (!silent) { + console.log("Polling session status... \n"); + } + const { result: state } = await this.request( + `${this.serverUrl}${stateLink.href}?wait=30`, + { + headers, + }, + "text" + ); + + sessionState = state.trim(); + if (!silent) { + console.log(`Current state: ${sessionState}\n`); + } + pollCount++; + resolve(sessionState); + } + } else { + resolve(sessionState); + } + }); + } + private async uploadTables(data: any, accessToken?: string) { const uploadedFiles = []; const headers: any = { @@ -830,7 +1015,7 @@ export class SASViyaApiClient { headers, }; - const file = await this.request( + const { result: file } = await this.request( `${this.serverUrl}/files/files#rawUpload`, createFileRequest ); @@ -848,7 +1033,7 @@ export class SASViyaApiClient { if (accessToken) { requestInfo.headers = { Authorization: `Bearer ${accessToken}` }; } - const folder = await this.request( + const { result: folder } = await this.request( `${this.serverUrl}${url}`, requestInfo ); diff --git a/src/SASjs.ts b/src/SASjs.ts index 707ff05..2434b98 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -32,6 +32,7 @@ const defaultConfig: SASjsConfig = { serverType: ServerType.SASViya, debug: true, contextName: "SAS Job Execution compute context", + useComputeApi: false, }; const requestRetryLimit = 5; @@ -390,239 +391,30 @@ export default class SASjs { this.sasjsConfig.serverType === ServerType.SASViya && this.sasjsConfig.contextName ) { - return await this.executeViaJesApi( + if (this.sasjsConfig.useComputeApi) { + return await this.executeJobViaComputeApi( + sasJob, + data, + params, + loginRequiredCallback, + accessToken + ); + } else { + return await this.executeJobViaJesApi( + sasJob, + data, + params, + loginRequiredCallback, + accessToken + ); + } + } else { + return await this.executeJobViaJes( sasJob, data, params, - loginRequiredCallback, - accessToken + loginRequiredCallback ); - } else { - const sasjsWaitingRequest: SASjsWaitingRequest = { - requestPromise: { - promise: null, - resolve: null, - reject: null, - }, - SASjob: sasJob, - data, - params, - }; - const program = this.sasjsConfig.appLoc - ? this.sasjsConfig.appLoc.replace(/\/?$/, "/") + - sasJob.replace(/^\//, "") - : sasJob; - const jobUri = - this.sasjsConfig.serverType === "SASVIYA" - ? await this.getJobUri(sasJob) - : ""; - const apiUrl = `${this.sasjsConfig.serverUrl}${this.jobsPath}/?${ - jobUri.length > 0 - ? "__program=" + program + "&_job=" + jobUri - : "_program=" + program - }`; - - const inputParams = params ? params : {}; - const requestParams = { - ...inputParams, - ...this.getRequestParams(), - }; - - const formData = new FormData(); - - let isError = false; - let errorMsg = ""; - - if (data) { - console.log("Input data", data); - const stringifiedData = JSON.stringify(data); - if ( - this.sasjsConfig.serverType === ServerType.SAS9 || - stringifiedData.length > 500000 || - stringifiedData.includes(";") - ) { - // file upload approach - for (const tableName in data) { - console.log("TableName: ", tableName); - if (isError) { - return; - } - const name = tableName; - const csv = convertToCSV(data[tableName]); - console.log("Converted CSV", csv); - if (csv === "ERROR: LARGE STRING LENGTH") { - console.log("String too long"); - isError = true; - errorMsg = - "The max length of a string value in SASjs is 32765 characters."; - } - - const file = new Blob([csv], { - type: "application/csv", - }); - console.log("File", file); - - 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]); - } - } - - console.log("Form data", formData); - - let isRedirected = false; - - sasjsWaitingRequest.requestPromise.promise = new Promise( - (resolve, reject) => { - if (isError) { - reject({ MESSAGE: errorMsg }); - } - const headers: any = {}; - if (this._csrfHeader && this._csrf) { - headers[this._csrfHeader] = this._csrf; - } - 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._csrfHeader = tokenHeader; - this._csrf = token; - } - } - } - - if ( - response.redirected && - this.sasjsConfig.serverType === ServerType.SAS9 - ) { - isRedirected = true; - } - - return response.text(); - }) - .then((responseText) => { - if ( - (needsRetry(responseText) || isRedirected) && - !isLogInRequired(responseText) - ) { - if (this.retryCount < requestRetryLimit) { - this.retryCount++; - this.request(sasJob, data, params).then( - (res: any) => resolve(res), - (err: any) => reject(err) - ); - } else { - this.retryCount = 0; - reject(responseText); - } - } else { - this.retryCount = 0; - this.parseLogFromResponse(responseText, program); - - if (isLogInRequired(responseText)) { - if (loginRequiredCallback) loginRequiredCallback(true); - sasjsWaitingRequest.requestPromise.resolve = resolve; - sasjsWaitingRequest.requestPromise.reject = reject; - this.sasjsWaitingRequests.push(sasjsWaitingRequest); - } else { - if ( - this.sasjsConfig.serverType === ServerType.SAS9 && - this.sasjsConfig.debug - ) { - this.updateUsername(responseText); - const jsonResponseText = this.parseSAS9Response( - responseText - ); - - if (jsonResponseText !== "") { - resolve(JSON.parse(jsonResponseText)); - } else { - reject({ - MESSAGE: this.parseSAS9ErrorResponse(responseText), - }); - } - } else if ( - this.sasjsConfig.serverType === ServerType.SASViya && - this.sasjsConfig.debug - ) { - try { - this.parseSASVIYADebugResponse(responseText).then( - (resText: any) => { - this.updateUsername(resText); - try { - resolve(JSON.parse(resText)); - } catch (e) { - reject({ MESSAGE: resText }); - } - }, - (err: any) => { - reject({ MESSAGE: err }); - } - ); - } catch (e) { - reject({ MESSAGE: responseText }); - } - } else { - this.updateUsername(responseText); - try { - const parsedJson = JSON.parse(responseText); - resolve(parsedJson); - } catch (e) { - reject({ MESSAGE: responseText }); - } - } - } - } - }) - .catch((e: Error) => { - reject(e); - }); - } - ); - - return sasjsWaitingRequest.requestPromise.promise; } } @@ -682,7 +474,62 @@ export default class SASjs { ); } - private async executeViaJesApi( + private async executeJobViaComputeApi( + sasJob: string, + data: any, + params?: any, + loginRequiredCallback?: any, + accessToken?: string + ) { + const sasjsWaitingRequest: SASjsWaitingRequest = { + requestPromise: { + promise: null, + resolve: null, + reject: null, + }, + SASjob: sasJob, + data, + params, + }; + + sasjsWaitingRequest.requestPromise.promise = new Promise( + async (resolve, reject) => { + const session = await this.checkSession(); + + if (!session.isLoggedIn) { + if (loginRequiredCallback) loginRequiredCallback(true); + sasjsWaitingRequest.requestPromise.resolve = resolve; + sasjsWaitingRequest.requestPromise.reject = reject; + this.sasjsWaitingRequests.push(sasjsWaitingRequest); + } else { + resolve( + await this.sasViyaApiClient + ?.executeComputeJob( + sasJob, + this.sasjsConfig.contextName, + this.sasjsConfig.debug, + data, + accessToken + ) + .then((response) => { + if (!this.sasjsConfig.debug) { + this.appendSasjsRequest(null, sasJob, null); + } else { + this.appendSasjsRequest(response, sasJob, null); + } + return JSON.parse(response!.result); + }) + .catch((e) => + reject({ MESSAGE: (e && e.message) || "Job execution failed" }) + ) + ); + } + } + ); + return sasjsWaitingRequest.requestPromise.promise; + } + + private async executeJobViaJesApi( sasJob: string, data: any, params?: any, @@ -725,9 +572,11 @@ export default class SASjs { } else { this.appendSasjsRequest(response, sasJob, null); } - return JSON.parse(response.result); + return JSON.parse(response!.result); }) - .catch((e) => reject({ MESSAGE: e.message })) + .catch((e) => + reject({ MESSAGE: (e && e.message) || "Job execution failed" }) + ) ); } } @@ -735,6 +584,229 @@ export default class SASjs { return sasjsWaitingRequest.requestPromise.promise; } + private async executeJobViaJes( + sasJob: string, + data: any, + params?: any, + loginRequiredCallback?: any + ) { + const sasjsWaitingRequest: SASjsWaitingRequest = { + requestPromise: { + promise: null, + resolve: null, + reject: null, + }, + SASjob: sasJob, + data, + params, + }; + const program = this.sasjsConfig.appLoc + ? this.sasjsConfig.appLoc.replace(/\/?$/, "/") + sasJob.replace(/^\//, "") + : sasJob; + const jobUri = + this.sasjsConfig.serverType === "SASVIYA" + ? await this.getJobUri(sasJob) + : ""; + const apiUrl = `${this.sasjsConfig.serverUrl}${this.jobsPath}/?${ + jobUri.length > 0 + ? "__program=" + program + "&_job=" + jobUri + : "_program=" + program + }`; + + const inputParams = params ? params : {}; + const requestParams = { + ...inputParams, + ...this.getRequestParams(), + }; + + const formData = new FormData(); + + let isError = false; + let errorMsg = ""; + + if (data) { + const stringifiedData = JSON.stringify(data); + if ( + this.sasjsConfig.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({ MESSAGE: errorMsg }); + } + const headers: any = {}; + if (this._csrfHeader && this._csrf) { + headers[this._csrfHeader] = this._csrf; + } + 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._csrfHeader = tokenHeader; + this._csrf = token; + } + } + } + + if ( + response.redirected && + this.sasjsConfig.serverType === ServerType.SAS9 + ) { + isRedirected = true; + } + + return response.text(); + }) + .then((responseText) => { + if ( + (needsRetry(responseText) || isRedirected) && + !isLogInRequired(responseText) + ) { + if (this.retryCount < requestRetryLimit) { + this.retryCount++; + this.request(sasJob, data, params).then( + (res: any) => resolve(res), + (err: any) => reject(err) + ); + } else { + this.retryCount = 0; + reject(responseText); + } + } else { + this.retryCount = 0; + this.parseLogFromResponse(responseText, program); + + if (isLogInRequired(responseText)) { + if (loginRequiredCallback) loginRequiredCallback(true); + sasjsWaitingRequest.requestPromise.resolve = resolve; + sasjsWaitingRequest.requestPromise.reject = reject; + this.sasjsWaitingRequests.push(sasjsWaitingRequest); + } else { + if ( + this.sasjsConfig.serverType === ServerType.SAS9 && + this.sasjsConfig.debug + ) { + this.updateUsername(responseText); + const jsonResponseText = this.parseSAS9Response(responseText); + + if (jsonResponseText !== "") { + resolve(JSON.parse(jsonResponseText)); + } else { + reject({ + MESSAGE: this.parseSAS9ErrorResponse(responseText), + }); + } + } else if ( + this.sasjsConfig.serverType === ServerType.SASViya && + this.sasjsConfig.debug + ) { + try { + this.parseSASVIYADebugResponse(responseText).then( + (resText: any) => { + this.updateUsername(resText); + try { + resolve(JSON.parse(resText)); + } catch (e) { + reject({ MESSAGE: resText }); + } + }, + (err: any) => { + reject({ MESSAGE: err }); + } + ); + } catch (e) { + reject({ MESSAGE: responseText }); + } + } else { + this.updateUsername(responseText); + try { + const parsedJson = JSON.parse(responseText); + resolve(parsedJson); + } catch (e) { + reject({ MESSAGE: responseText }); + } + } + } + } + }) + .catch((e: Error) => { + reject(e); + }); + } + ); + + return sasjsWaitingRequest.requestPromise.promise; + } + private async resendWaitingRequests() { for (const sasjsWaitingRequest of this.sasjsWaitingRequests) { this.request( @@ -930,7 +1002,7 @@ export default class SASjs { try { jsonResponse = JSON.parse(this.parseSAS9Response(response)); } catch (e) { - console.log(e); + console.error(e); } } else { await this.parseSASVIYADebugResponse(response).then( @@ -938,11 +1010,11 @@ export default class SASjs { try { jsonResponse = JSON.parse(resText); } catch (e) { - console.log(e); + console.error(e); } }, (err: any) => { - console.log(err); + console.error(err); } ); } diff --git a/src/types/JobDefinition.ts b/src/types/JobDefinition.ts new file mode 100644 index 0000000..fc8fb59 --- /dev/null +++ b/src/types/JobDefinition.ts @@ -0,0 +1,3 @@ +export interface JobDefinition { + code: string; +} diff --git a/src/types/SASjsConfig.ts b/src/types/SASjsConfig.ts index ee40745..9c434b4 100644 --- a/src/types/SASjsConfig.ts +++ b/src/types/SASjsConfig.ts @@ -27,4 +27,5 @@ export class SASjsConfig { */ debug: boolean = true; contextName: string = ""; + useComputeApi = false; } diff --git a/src/types/Session.ts b/src/types/Session.ts index 8734fdf..b05e7da 100644 --- a/src/types/Session.ts +++ b/src/types/Session.ts @@ -1,3 +1,7 @@ +import { Link } from "./Link"; + export interface Session { id: string; + state: string; + links: Link[]; } diff --git a/src/utils/formatDataForRequest.ts b/src/utils/formatDataForRequest.ts new file mode 100644 index 0000000..4f62737 --- /dev/null +++ b/src/utils/formatDataForRequest.ts @@ -0,0 +1,33 @@ +import { convertToCSV } from "./convertToCsv"; +import { splitChunks } from "./splitChunks"; + +export const formatDataForRequest = (data: any) => { + const sasjsTables = []; + let tableCounter = 0; + const result: any = {}; + + 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 + result[`sasjs${tableCounter}data0`] = csvChunks.length; + csvChunks.forEach((chunk, index) => { + result[`sasjs${tableCounter}data${index + 1}`] = chunk; + }); + } else { + result[`sasjs${tableCounter}data`] = csv; + } + } + result["sasjs_tables"] = sasjsTables.join(" "); + + return result; +}; diff --git a/src/utils/makeRequest.ts b/src/utils/makeRequest.ts index 1d28c23..8b2b411 100644 --- a/src/utils/makeRequest.ts +++ b/src/utils/makeRequest.ts @@ -5,11 +5,12 @@ export async function makeRequest( request: RequestInit, callback: (value: CsrfToken) => any, contentType: "text" | "json" = "json" -): Promise { +): Promise<{ result: T; etag: string | null }> { const responseTransform = contentType === "json" ? (res: Response) => res.json() : (res: Response) => res.text(); + let etag = null; const result = await fetch(url, request).then((response) => { if (!response.ok) { if (response.status === 403) { @@ -26,12 +27,16 @@ export async function makeRequest( ...request, headers: { ...request.headers, [tokenHeader]: token }, }; - return fetch(url, retryRequest).then(responseTransform); + return fetch(url, retryRequest).then((res) => { + etag = res.headers.get("ETag"); + return responseTransform(res); + }); } } } else { + etag = response.headers.get("ETag"); return responseTransform(response); } }); - return result; + return { result, etag }; }