1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-07 20:40:05 +00:00

feat(*): recreate package with new name

This commit is contained in:
Krishna Acondy
2020-07-07 19:53:35 +01:00
commit 066f953863
150 changed files with 48625 additions and 0 deletions

51
src/SAS9ApiClient.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* A client for interfacing with the SAS9 REST API
*
*/
export class SAS9ApiClient {
constructor(private serverUrl: string) {}
/**
* returns on object containing the server URL
*/
public getConfig() {
return {
serverUrl: this.serverUrl,
};
}
/**
* Updates serverurl which is not null
* @param serverUrl - the URL of the server.
*/
public setConfig(serverUrl: string) {
if (serverUrl) this.serverUrl = serverUrl;
}
/**
* Executes code on a SAS9 server.
* @param linesOfCode - an array of lines of code to execute
* @param serverName - the server to execute the code on
* @param repositoryName - the repository to execute the code on
*/
public async executeScript(
linesOfCode: string[],
serverName: string,
repositoryName: string
) {
const requestPayload = linesOfCode.join("\n");
const executeScriptRequest = {
method: "PUT",
headers: {
Accept: "application/json",
},
body: `command=${requestPayload}`,
};
const executeScriptResponse = await fetch(
`${this.serverUrl}/sas/servers/${serverName}/cmd?repositoryName=${repositoryName}`,
executeScriptRequest
).then((res) => res.text());
return executeScriptResponse;
}
}

850
src/SASViyaApiClient.ts Normal file
View File

@@ -0,0 +1,850 @@
import {
isAuthorizeFormRequired,
parseAndSubmitAuthorizeForm,
convertToCSV,
makeRequest,
} from "./utils";
import * as NodeFormData from "form-data";
import * as path from "path";
import { Job, Session, Context, Folder } from "./types";
/**
* A client for interfacing with the SAS Viya REST API
*
*/
export class SASViyaApiClient {
constructor(
private serverUrl: string,
private rootFolderName: string,
private rootFolderMap = new Map<string, Job[]>()
) {
if (!rootFolderName) {
throw new Error("Root folder must be provided.");
}
}
private csrfToken: { headerName: string; value: string } | null = null;
private rootFolder: Folder | null = null;
/**
* Returns a map containing the directory structure in the currently set root folder.
*/
public async getAppLocMap() {
if (this.rootFolderMap.size) {
return this.rootFolderMap;
}
this.populateRootFolderMap();
return this.rootFolderMap;
}
/**
* returns an object containing the Server URL and root folder name
*/
public getConfig() {
return {
serverUrl: this.serverUrl,
rootFolderName: this.rootFolderName,
};
}
/**
* Updates server URL or root folder name when not null
* @param serverUrl - the URL of the server.
* @param rootFolderName - the name for rootFolderName.
*/
public setConfig(serverUrl: string, rootFolderName: string) {
if (serverUrl) this.serverUrl = serverUrl;
if (rootFolderName) this.rootFolderName = rootFolderName;
}
/**
* Returns all available compute contexts on this server.
* @param accessToken - an access token for an authorized user.
*/
public async getAllContexts(accessToken?: string) {
const headers: any = {
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const contexts = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts`,
{ headers }
);
const contextsList = contexts && contexts.items ? contexts.items : [];
return contextsList.map((context: any) => ({
createdBy: context.createdBy,
id: context.id,
name: context.name,
version: context.version,
attributes: {},
}));
}
/**
* Returns all compute contexts on this server that the user has access to.
* @param accessToken - an access token for an authorized user.
*/
public async getExecutableContexts(accessToken?: string) {
const headers: any = {
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const contexts = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts`,
{ headers }
);
const contextsList = contexts && contexts.items ? contexts.items : [];
const executableContexts: any[] = [];
const promises = contextsList.map((context: any) => {
const linesOfCode = ["%put &=sysuserid;"];
return this.executeScript(
`test-${context.name}`,
linesOfCode,
context.name,
accessToken,
undefined,
true
).catch(() => null);
});
const results = await Promise.all(promises);
results.forEach((result: any, index: number) => {
if (result && result.jobStatus === "completed") {
let sysUserId = "";
if (result && result.log && result.log.items) {
const sysUserIdLog = result.log.items.find((i: any) =>
i.line.startsWith("SYSUSERID=")
);
if (sysUserIdLog) {
sysUserId = sysUserIdLog.line.replace("SYSUSERID=", "");
}
}
executableContexts.push({
createdBy: contextsList[index].createdBy,
id: contextsList[index].id,
name: contextsList[index].name,
version: contextsList[index].version,
attributes: {
sysUserId,
},
});
}
});
return executableContexts;
}
/**
* Creates a session on the given context.
* @param contextName - the name of the context to create a session on.
* @param accessToken - an access token for an authorized user.
*/
public async createSession(contextName: string, accessToken?: string) {
const headers: any = {
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const contexts = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts`,
{ headers }
);
const executionContext =
contexts.items && contexts.items.length
? contexts.items.find((c: any) => c.name === contextName)
: null;
if (!executionContext) {
throw new Error(`Execution context ${contextName} not found.`);
}
const createSessionRequest = {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
};
const createdSession = this.request<Session>(
`${this.serverUrl}/compute/contexts/${executionContext.id}/sessions`,
createSessionRequest
);
return createdSession;
}
/**
* Executes code on the current SAS Viya server.
* @param fileName - a name for the file being submitted for execution.
* @param linesOfCode - an array of lines of code to execute.
* @param contextName - the context to execute the code in.
* @param accessToken - an access token for an authorized user.
* @param sessionId - optional session ID to reuse.
* @param silent - optional flag to turn of logging.
*/
public async executeScript(
fileName: string,
linesOfCode: string[],
contextName: string,
accessToken?: string,
sessionId = "",
silent = false
) {
const headers: any = {
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
if (this.csrfToken) {
headers[this.csrfToken.headerName] = this.csrfToken.value;
}
const contexts = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts`,
{ headers }
);
const executionContext =
contexts.items && contexts.items.length
? contexts.items.find((c: any) => c.name === contextName)
: null;
if (executionContext) {
// Request new session in context or use the ID passed in
let executionSessionId: string;
if (sessionId) {
executionSessionId = sessionId;
} else {
const createSessionRequest = {
method: "POST",
headers,
};
const createdSession = await this.request<Session>(
`${this.serverUrl}/compute/contexts/${executionContext.id}/sessions`,
createSessionRequest
);
executionSessionId = createdSession.id;
}
// Execute job in session
const postJobRequest = {
method: "POST",
headers,
body: JSON.stringify({
name: fileName,
description: "Powered by SASjs",
code: linesOfCode,
}),
};
const postedJob = await this.request<Job>(
`${this.serverUrl}/compute/sessions/${executionSessionId}/jobs`,
postJobRequest
);
if (!silent) {
console.log(`Job has been submitted for ${fileName}`);
console.log(
`You can monitor the job progress at ${this.serverUrl}${
postedJob.links.find((l: any) => l.rel === "state")!.href
}`
);
}
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`,
{
headers,
}
);
return { jobStatus, log };
}
} else {
console.error(
`Unable to find execution context ${contextName}.\nPlease check the contextName in the tgtDeployVars and try again.`
);
console.error("Response from server: ", JSON.stringify(contexts));
}
}
/**
* Creates a folder in the specified location. Either parentFolderPath or
* parentFolderUri must be provided.
* @param folderName - the name of the new folder.
* @param parentFolderPath - the full path to the parent folder. If not
* provided, the parentFolderUri must be provided.
* @param parentFolderUri - the URI (eg /folders/folders/UUID) of the parent
* folder. If not provided, the parentFolderPath must be provided.
*/
public async createFolder(
folderName: string,
parentFolderPath?: string,
parentFolderUri?: string,
accessToken?: string
): Promise<Folder> {
if (!parentFolderPath && !parentFolderUri) {
throw new Error("Parent folder path or uri is required");
}
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken);
if (!parentFolderUri){
console.log(`Parent folder is not present: ${parentFolderPath}`);
const newParentFolderPath = parentFolderPath.substring(0, parentFolderPath.lastIndexOf("/"));
const newFolderName = `${parentFolderPath.split("/").pop()}`;
if (newParentFolderPath === ""){
throw new Error("Root Folder should have been present on server");
}
console.log(`Creating Parent Folder:\n${newFolderName} in ${newParentFolderPath}`)
const parentFolder = await this.createFolder(newFolderName, newParentFolderPath, undefined, accessToken)
console.log(`Parent Folder "${newFolderName}" successfully created.`)
parentFolderUri = `/folders/folders/${parentFolder.id}`;
}
}
const createFolderRequest: RequestInit = {
method: "POST",
body: JSON.stringify({
name: folderName,
type: "folder",
}),
};
createFolderRequest.headers = { "Content-Type": "application/json" };
if (accessToken) {
createFolderRequest.headers.Authorization = `Bearer ${accessToken}`;
}
const createFolderResponse = await this.request<Folder>(
`${this.serverUrl}/folders/folders?parentFolderUri=${parentFolderUri}`,
createFolderRequest
);
// update rootFolderMap with newly created folder.
await this.populateRootFolderMap(accessToken);
return createFolderResponse;
}
/**
* Creates a Job in the specified folder (or folder uri).
* @param parentFolderPath - the location of the new job.
* @param parentFolderUri - the URI location of the new job. The function is a
* little faster if the folder URI is supplied instead of the path.
* @param jobName - the name of the new job to be created.
* @param code - the SAS code for the new job.
*/
public async createJobDefinition(
jobName: string,
code: string,
parentFolderPath?: string,
parentFolderUri?: string,
accessToken?: string
) {
if (!parentFolderPath && !parentFolderUri) {
throw new Error('Either parentFolderPath or parentFolderUri must be provided');
}
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken);
}
const createJobDefinitionRequest: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/vnd.sas.job.definition+json",
Accept: "application/vnd.sas.job.definition+json",
},
body: JSON.stringify({
name: jobName,
parameters:[
{
"name":"_addjesbeginendmacros",
"type":"CHARACTER",
"defaultValue":"false"
}
],
type: "Compute",
code,
}),
};
if (accessToken) {
createJobDefinitionRequest!.headers = {
...createJobDefinitionRequest.headers,
Authorization: `Bearer ${accessToken}`,
};
}
return await this.request<Job>(
`${this.serverUrl}/jobDefinitions/definitions?parentFolderUri=${parentFolderUri}`,
createJobDefinitionRequest
);
}
/**
* Performs a login redirect and returns an auth code for the given client
* @param clientId - the client ID to authenticate with.
*/
public async getAuthCode(clientId: string) {
const authUrl = `${this.serverUrl}/SASLogon/oauth/authorize?client_id=${clientId}&response_type=code`;
const authCode = await fetch(authUrl, {
referrerPolicy: "same-origin",
credentials: "include",
})
.then((response) => response.text())
.then(async (response) => {
let code = "";
if (isAuthorizeFormRequired(response)) {
const formResponse: any = await parseAndSubmitAuthorizeForm(
response,
this.serverUrl
);
const responseBody = formResponse
.split("<body>")[1]
.split("</body>")[0];
const bodyElement: any = document.createElement("div");
bodyElement.innerHTML = responseBody;
code = bodyElement.querySelector(".infobox h4").innerText;
return code;
} else {
const responseBody = response.split("<body>")[1].split("</body>")[0];
const bodyElement: any = document.createElement("div");
bodyElement.innerHTML = responseBody;
if (bodyElement) {
code = bodyElement.querySelector(".infobox h4").innerText;
}
return code;
}
})
.catch(() => null);
return authCode;
}
/**
* Exchanges the auth code for an access token for the given client.
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param authCode - the auth code received from the server.
*/
public async getAccessToken(
clientId: string,
clientSecret: string,
authCode: string
) {
const url = this.serverUrl + "/SASLogon/oauth/token";
let token;
if (typeof Buffer === "undefined") {
token = btoa(clientId + ":" + clientSecret);
} else {
token = Buffer.from(clientId + ":" + clientSecret).toString("base64");
}
const headers = {
Authorization: "Basic " + token,
};
let formData;
if (typeof FormData === "undefined") {
formData = new NodeFormData();
formData.append("grant_type", "authorization_code");
formData.append("code", authCode);
} else {
formData = new FormData();
formData.append("grant_type", "authorization_code");
formData.append("code", authCode);
}
const authResponse = await fetch(url, {
method: "POST",
credentials: "include",
headers,
body: formData as any,
referrerPolicy: "same-origin",
}).then((res) => res.json());
return authResponse;
}
/**
* Exchanges the refresh token for an access token for the given client.
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param authCode - the refresh token received from the server.
*/
public async refreshTokens(
clientId: string,
clientSecret: string,
refreshToken: string
) {
const url = this.serverUrl + "/SASLogon/oauth/token";
let token;
if (typeof Buffer === "undefined") {
token = btoa(clientId + ":" + clientSecret);
} else {
token = Buffer.from(clientId + ":" + clientSecret).toString("base64");
}
const headers = {
Authorization: "Basic " + token,
};
let formData;
if (typeof FormData === "undefined") {
formData = new NodeFormData();
formData.append("grant_type", "refresh_token");
formData.append("refresh_token", refreshToken);
} else {
formData = new FormData();
formData.append("grant_type", "refresh_token");
formData.append("refresh_token", refreshToken);
}
const authResponse = await fetch(url, {
method: "POST",
credentials: "include",
headers,
body: formData as any,
referrerPolicy: "same-origin",
}).then((res) => res.json());
return authResponse;
}
/**
* Deletes the client representing the supplied ID.
* @param clientId - the client ID to authenticate with.
* @param accessToken - an access token for an authorized user.
*/
public async deleteClient(clientId: string, accessToken?: string) {
const url = this.serverUrl + `/oauth/clients/${clientId}`;
const headers: any = {};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const deleteResponse = await this.request(url, {
method: "DELETE",
credentials: "include",
headers,
});
return deleteResponse;
}
/**
* Executes a job via the SAS Viya Job Execution 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 executeJob(
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}`
);
}
let files: any[] = [];
if (data && Object.keys(data).length) {
files = await this.uploadTables(data, accessToken);
}
const jobName = path.basename(sasJob);
const jobFolder = sasJob.replace(`/${jobName}`, "");
const allJobsInFolder = this.rootFolderMap.get(jobFolder.replace("/", ""));
if (allJobsInFolder) {
const jobSpec = allJobsInFolder.find((j: Job) => j.name === jobName);
const jobDefinitionLink = jobSpec?.links.find(
(l) => l.rel === "getResource"
)?.href;
const requestInfo: any = {
method: "GET",
};
const headers: any = { "Content-Type": "application/json" };
if (!!accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
requestInfo.headers = headers;
const jobDefinition = await this.request<Job>(
`${this.serverUrl}${jobDefinitionLink}`,
requestInfo
);
const jobArguments: { [key: string]: any } = {
_contextName: contextName,
_program: `${this.rootFolderName}/${sasJob}`,
_webin_file_count: files.length,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true,
};
if (debug) {
jobArguments["_omittextlog"] = "false";
jobArguments["_omitsessionresults"] = "false";
jobArguments["_debug"] = 131;
}
files.forEach((fileInfo, index) => {
jobArguments[
`_webin_fileuri${index + 1}`
] = `/files/files/${fileInfo.id}`;
jobArguments[`_webin_name${index + 1}`] = fileInfo.tableName;
});
const postJobRequest = {
method: "POST",
headers,
body: JSON.stringify({
name: `exec-${jobName}`,
description: "Powered by SASjs",
jobDefinition,
arguments: jobArguments,
}),
};
const postedJob = await this.request<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequest
);
const jobStatus = await this.pollJobState(postedJob, accessToken, true);
const currentJob = await this.request<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
{ headers }
);
const resultLink = currentJob.results["_webout.json"];
if (resultLink) {
const result = await this.request<any>(
`${this.serverUrl}${resultLink}/content`,
{ headers }
);
return result;
}
return postedJob;
} else {
throw new Error(
`The job ${sasJob} was not found at the location ${this.rootFolderName}`
);
}
}
private async populateRootFolderMap(accessToken?: string) {
const allItems = new Map<string, Job[]>();
const url = "/folders/folders/@item?path=" + this.rootFolderName;
const requestInfo: any = {
method: "GET",
};
if (accessToken) {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` };
}
const folder = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
);
if (!folder){
throw new Error("Cannot populate RootFolderMap unless rootFolder exists");
}
const members = await this.request<{ items: any[] }>(
`${this.serverUrl}/folders/folders/${folder.id}/members`,
requestInfo
);
const itemsAtRoot = members.items;
allItems.set("", itemsAtRoot);
const subfolderRequests = members.items
.filter((i: any) => i.contentType === "folder")
.map(async (member: any) => {
const subFolderUrl =
"/folders/folders/@item?path=" +
this.rootFolderName +
"/" +
member.name;
const memberDetail = await this.request<Folder>(
`${this.serverUrl}${subFolderUrl}`,
requestInfo
);
const membersLink = memberDetail.links.find(
(l: any) => l.rel === "members"
);
const memberContents = await this.request<{ items: any[] }>(
`${this.serverUrl}${membersLink!.href}`,
requestInfo
);
const itemsInFolder = memberContents.items as any[];
allItems.set(member.name, itemsInFolder);
return itemsInFolder;
});
await Promise.all(subfolderRequests);
this.rootFolderMap = allItems;
}
private async populateRootFolder(accessToken?: string) {
const url = "/folders/folders/@item?path=" + this.rootFolderName;
const requestInfo: RequestInit = {
method: "GET",
};
if (accessToken) {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` };
}
const rootFolder = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
).catch(() => null);
this.rootFolder = rootFolder;
}
private async pollJobState(
postedJob: any,
accessToken?: string,
silent = false
) {
let postedJobState = "";
let pollCount = 0;
const headers: any = {
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const stateLink = postedJob.links.find((l: any) => l.rel === "state");
return new Promise((resolve, _) => {
const interval = setInterval(async () => {
if (
postedJobState === "running" ||
postedJobState === "" ||
postedJobState === "pending"
) {
if (stateLink) {
if (!silent) {
console.log("Polling job status... \n");
}
const jobState = await this.request<string>(
`${this.serverUrl}${stateLink.href}?wait=30`,
{
headers,
},
"text"
);
postedJobState = jobState.trim();
if (!silent) {
console.log(`Current state: ${postedJobState}\n`);
}
pollCount++;
if (pollCount >= 100) {
resolve(postedJobState);
}
}
} else {
clearInterval(interval);
resolve(postedJobState);
}
}, 100);
});
}
private async uploadTables(data: any, accessToken?: string) {
const uploadedFiles = [];
const headers: any = {
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
for (const tableName in data) {
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 createFileRequest = {
method: "POST",
body: csv,
headers,
};
const file = await this.request<any>(
`${this.serverUrl}/files/files#rawUpload`,
createFileRequest
);
uploadedFiles.push({ tableName, file });
}
return uploadedFiles;
}
private async getFolderUri(folderPath: string, accessToken?: string) {
const url = "/folders/folders/@item?path=" + folderPath;
const requestInfo: any = {
method: "GET",
};
if (accessToken) {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` };
}
const folder = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
);
if (!folder)
return undefined;
return `/folders/folders/${folder.id}`;
}
private async request<T>(
url: string,
options: RequestInit,
contentType: "text" | "json" = "json"
) {
if (this.csrfToken) {
options.headers = {
...options.headers,
[this.csrfToken.headerName]: this.csrfToken.value,
};
}
return await makeRequest<T>(
url,
options,
(csrfToken) => (this.csrfToken = csrfToken),
contentType
);
}
}

48
src/SASjs.spec.ts Normal file
View File

@@ -0,0 +1,48 @@
import SASjs from "./index";
const adapter = new SASjs();
it("should parse SAS9 source code", async done => {
expect(sampleResponse).toBeTruthy();
const parsedSourceCode = (adapter as any).parseSAS9SourceCode(sampleResponse);
expect(parsedSourceCode).toBeTruthy();
const sourceCodeLines = parsedSourceCode.split("\r\n");
expect(sourceCodeLines.length).toEqual(5);
expect(sourceCodeLines[0].startsWith("6")).toBeTruthy();
expect(sourceCodeLines[1].startsWith("7")).toBeTruthy();
expect(sourceCodeLines[2].startsWith("8")).toBeTruthy();
expect(sourceCodeLines[3].startsWith("9")).toBeTruthy();
expect(sourceCodeLines[4].startsWith("10")).toBeTruthy();
done();
});
it("should parse generated code", async done => {
expect(sampleResponse).toBeTruthy();
const parsedGeneratedCode = (adapter as any).parseGeneratedCode(
sampleResponse
);
expect(parsedGeneratedCode).toBeTruthy();
const generatedCodeLines = parsedGeneratedCode.split("\r\n");
expect(generatedCodeLines.length).toEqual(5);
expect(generatedCodeLines[0].startsWith("MPRINT(MM_WEBIN)")).toBeTruthy();
expect(generatedCodeLines[1].startsWith("MPRINT(MM_WEBLEFT)")).toBeTruthy();
expect(generatedCodeLines[2].startsWith("MPRINT(MM_WEBOUT)")).toBeTruthy();
expect(generatedCodeLines[3].startsWith("MPRINT(MM_WEBRIGHT)")).toBeTruthy();
expect(generatedCodeLines[4].startsWith("MPRINT(MM_WEBOUT)")).toBeTruthy();
done();
});
/* tslint:disable */
const sampleResponse = `<meta http-equiv="Content-Type" content="text/html; charset=windows-1252"/>
6 @file mm_webout.sas
7 @brief Send data to/from SAS Stored Processes
8 @details This macro should be added to the start of each Stored Process,
9 **immediately** followed by a call to:
10 %webout(OPEN)
MPRINT(MM_WEBIN): ;
MPRINT(MM_WEBLEFT): filename _temp temp lrecl=999999;
MPRINT(MM_WEBOUT): data _null_;
MPRINT(MM_WEBRIGHT): file _temp;
MPRINT(MM_WEBOUT): if upcase(symget('_debug'))='LOG' then put '&gt;&gt;weboutBEGIN&lt;&lt;';
`;
/* tslint:enable */

1041
src/SASjs.ts Normal file

File diff suppressed because it is too large Load Diff

5
src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import SASjs from "./SASjs";
export * from "./types";
export * from "./SASViyaApiClient";
export * from "./SAS9ApiClient";
export default SASjs;

6
src/types/Context.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface Context {
name: string;
id: string;
createdBy: string;
version: number;
}

4
src/types/CsrfToken.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface CsrfToken {
headerName: string;
value: string;
}

7
src/types/Folder.ts Normal file
View File

@@ -0,0 +1,7 @@
import { Link } from "./Link";
export interface Folder {
id: string;
uri: string;
links: Link[];
}

11
src/types/Job.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Link } from "./Link";
import { JobResult } from "./JobResult";
export interface Job {
id: string;
name: string;
uri: string;
createdBy: string;
links: Link[];
results: JobResult;
}

3
src/types/JobResult.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface JobResult {
"_webout.json": string;
}

7
src/types/Link.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface Link {
method: string;
rel: string;
href: string;
uri: string;
type: string;
}

30
src/types/SASjsConfig.ts Normal file
View File

@@ -0,0 +1,30 @@
import { ServerType } from "./ServerType";
/**
* Specifies the configuration for the SASjs instance.
*
*/
export class SASjsConfig {
/**
* The location (including http protocol and port) of the SAS Server.
* Can be omitted, eg if serving directly from the SAS Web Server or being
* streamed.
*/
serverUrl: string = "";
pathSAS9: string = "";
pathSASViya: string = "";
/**
* The appLoc is the parent folder under which the SAS services (STPs or Job
* Execution Services) are stored.
*/
appLoc: string = "";
/**
* Can be SAS9 or SASVIYA
*/
serverType: ServerType | null = null;
/**
* Set to `true` to enable additional debugging.
*/
debug: boolean = true;
contextName: string = "";
}

12
src/types/SASjsRequest.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Represents a SASjs request, its response and logs.
*
*/
export interface SASjsRequest {
serviceLink: string;
timestamp: Date;
sourceCode: string;
generatedCode: string;
logFile: string;
SASWORK: any;
}

View File

@@ -0,0 +1,14 @@
/**
* Represents requests that are queued, pending a signon event
*
*/
export interface SASjsWaitingRequest {
requestPromise: {
promise: any;
resolve: any;
reject: any;
};
SASjob: string;
data: any;
params?: any;
}

8
src/types/ServerType.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Server type - Viya or SAS9.
*
*/
export enum ServerType {
SASViya = "SASVIYA",
SAS9 = "SAS9",
}

3
src/types/Session.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface Session {
id: string;
}

10
src/types/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from "./Context";
export * from "./CsrfToken";
export * from "./Folder";
export * from "./Job";
export * from "./Link";
export * from "./SASjsConfig";
export * from "./SASjsRequest";
export * from "./SASjsWaitingRequest";
export * from "./ServerType";
export * from "./Session";

View File

@@ -0,0 +1,5 @@
export async function asyncForEach(array: any[], callback: any) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}

View File

@@ -0,0 +1,9 @@
import { SASjsRequest } from "../types/SASjsRequest";
/**
* Comparator for SASjs request timestamps
*
*/
export const compareTimestamps = (a: SASjsRequest, b: SASjsRequest) => {
return b.timestamp.getTime() - a.timestamp.getTime();
};

133
src/utils/convertToCsv.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Converts the given JSON object to a CSV string.
* @param data - the JSON object to convert.
*/
export const convertToCSV = (data: any) => {
const replacer = (key: any, value: any) => (value === null ? "" : value);
const headerFields = Object.keys(data[0]);
let csvTest;
let invalidString = false;
const headers = headerFields.map((field) => {
let firstFoundType: string | null = null;
let hasMixedTypes: boolean = false;
let rowNumError: number = -1;
const longestValueForField = data
.map((row: any, index: number) => {
if (row[field] || row[field] === "") {
if (firstFoundType) {
let currentFieldType =
row[field] === "" || typeof row[field] === "string"
? "chars"
: "number";
if (!hasMixedTypes) {
hasMixedTypes = currentFieldType !== firstFoundType;
rowNumError = hasMixedTypes ? index + 1 : -1;
}
} else {
if (row[field] === "") {
firstFoundType = "chars";
} else {
firstFoundType =
typeof row[field] === "string" ? "chars" : "number";
}
}
let byteSize;
if (typeof row[field] === "string") {
let doubleQuotesFound = row[field]
.split("")
.filter((char: any) => char === '"');
byteSize = getByteSize(row[field]);
if (doubleQuotesFound.length > 0) {
byteSize += doubleQuotesFound.length;
}
}
return byteSize;
}
})
.sort((a: number, b: number) => b - a)[0];
if (longestValueForField && longestValueForField > 32765) {
invalidString = true;
}
if (hasMixedTypes) {
console.error(
`Row (${rowNumError}), Column (${field}) has mixed types: ERROR`
);
}
return `${field}:${firstFoundType === "chars" ? "$" : ""}${
longestValueForField
? longestValueForField
: firstFoundType === "chars"
? "1"
: "best"
}.`;
});
if (invalidString) {
return "ERROR: LARGE STRING LENGTH";
}
csvTest = data.map((row: any) => {
const fields = Object.keys(row).map((fieldName, index) => {
let value;
let containsSpecialChar = false;
const currentCell = row[fieldName];
if (JSON.stringify(currentCell).search(/(\\t|\\n|\\r)/gm) > -1) {
value = currentCell.toString();
containsSpecialChar = true;
} else {
value = JSON.stringify(currentCell, replacer);
}
value = value.replace(/\\\\/gm, "\\");
if (containsSpecialChar) {
if (value.includes(",") || value.includes('"')) {
value = '"' + value + '"';
}
} else {
if (
!value.includes(",") &&
value.includes('"') &&
!value.includes('\\"')
) {
value = value.substring(1, value.length - 1);
}
value = value.replace(/\\"/gm, '""');
}
value = value.replace(/\r\n/gm, "\n");
if (value === "" && headers[index].includes("best")) {
value = ".";
}
return value;
});
return fields.join(",");
});
let finalCSV =
headers.join(",").replace(/,/g, " ") + "\r\n" + csvTest.join("\r\n");
return finalCSV;
};
const getByteSize = (str: string) => {
let byteSize = str.length;
for (let i = str.length - 1; i >= 0; i--) {
const code = str.charCodeAt(i);
if (code > 0x7f && code <= 0x7ff) byteSize++;
else if (code > 0x7ff && code <= 0xffff) byteSize += 2;
if (code >= 0xdc00 && code <= 0xdfff) i--; //trail surrogate
}
return byteSize;
};

14
src/utils/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export * from "./asyncForEach";
export * from "./compareTimestamps";
export * from "./convertToCsv";
export * from "./isAuthorizeFormRequired";
export * from "./isLoginRequired";
export * from "./isLoginSuccess";
export * from "./makeRequest";
export * from "./needsRetry";
export * from "./parseAndSubmitAuthorizeForm";
export * from "./parseGeneratedCode";
export * from "./parseSourceCode";
export * from "./parseSasViyaLog";
export * from "./serialize";
export * from "./splitChunks";

View File

@@ -0,0 +1,3 @@
export const isAuthorizeFormRequired = (response: string): boolean => {
return /<form.+action="(.*Logon\/oauth\/authorize[^"]*).*>/gm.test(response);
};

View File

@@ -0,0 +1,5 @@
export const isLogInRequired = (response: string): boolean => {
const pattern: RegExp = /<form.+action="(.*Logon[^"]*).*>/gm;
const matches = pattern.test(response);
return matches;
};

View File

@@ -0,0 +1,2 @@
export const isLogInSuccess = (response: string): boolean =>
/You have signed in/gm.test(response);

37
src/utils/makeRequest.ts Normal file
View File

@@ -0,0 +1,37 @@
import { CsrfToken } from "../types";
export async function makeRequest<T>(
url: string,
request: RequestInit,
callback: (value: CsrfToken) => any,
contentType: "text" | "json" = "json"
): Promise<T> {
const responseTransform =
contentType === "json"
? (res: Response) => res.json()
: (res: Response) => res.text();
const result = await fetch(url, request).then((response) => {
if (!response.ok) {
if (response.status === 403) {
const tokenHeader = response.headers.get("X-CSRF-HEADER");
if (tokenHeader) {
const token = response.headers.get(tokenHeader);
callback({
headerName: tokenHeader,
value: token || "",
});
const retryRequest = {
...request,
headers: { ...request.headers, [tokenHeader]: token },
};
return fetch(url, retryRequest).then(responseTransform);
}
}
} else {
return responseTransform(response);
}
});
return result;
}

11
src/utils/needsRetry.ts Normal file
View File

@@ -0,0 +1,11 @@
export const needsRetry = (responseText: string): boolean => {
return (
(responseText.includes('"errorCode":403') &&
responseText.includes("_csrf") &&
responseText.includes("X-CSRF-TOKEN")) ||
(responseText.includes('"status":403') &&
responseText.includes('"error":"Forbidden"')) ||
(responseText.includes('"status":449') &&
responseText.includes("Authentication success, retry original request"))
);
};

View File

@@ -0,0 +1,49 @@
export const parseAndSubmitAuthorizeForm = async (
response: string,
serverUrl: string
) => {
let authUrl: string | null = null;
const params: any = {};
const responseBody = response.split("<body>")[1].split("</body>")[0];
const bodyElement = document.createElement("div");
bodyElement.innerHTML = responseBody;
const form = bodyElement.querySelector("#application_authorization");
authUrl = form ? serverUrl + 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 formData = new FormData();
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key]);
}
}
return new Promise((resolve, reject) => {
if (authUrl) {
fetch(authUrl, {
method: "POST",
credentials: "include",
body: formData,
referrerPolicy: "same-origin",
})
.then((res) => res.text())
.then((res) => {
resolve(res);
});
} else {
reject("Auth form url is null");
}
});
};

View File

@@ -0,0 +1,7 @@
export const parseGeneratedCode = (log: string) => {
const startsWith = "MPRINT";
const isGeneratedCodeLine = (line: string) =>
line.trim().startsWith(startsWith);
const logLines = log.split("\n").filter(isGeneratedCodeLine);
return logLines.join("\r\n");
};

View File

@@ -0,0 +1,12 @@
export const parseSasViyaLog = (logResponse: { items: any[] }) => {
let log;
try {
log = logResponse.items
? logResponse.items.map((i) => i.line).join("\n")
: JSON.stringify(logResponse);
} catch (e) {
console.error("An error has occurred while parsing the log response", e);
log = logResponse;
}
return log;
};

View File

@@ -0,0 +1,6 @@
export const parseSourceCode = (log: string): string => {
const isSourceCodeLine = (line: string) =>
line.trim().substring(0, 10).trimStart().match(/^\d/);
const logLines = log.split("\n").filter(isSourceCodeLine);
return logLines.join("\r\n");
};

15
src/utils/serialize.ts Normal file
View File

@@ -0,0 +1,15 @@
export const serialize = (obj: any) => {
const str: any[] = [];
for (const p in obj) {
if (obj.hasOwnProperty(p)) {
if (obj[p] instanceof Array) {
for (let i = 0, n = obj[p].length; i < n; i++) {
str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p][i]));
}
} else {
str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
}
}
}
return str.join("&");
};

12
src/utils/splitChunks.ts Normal file
View File

@@ -0,0 +1,12 @@
export const splitChunks = (content: string) => {
const size = 16000;
const numChunks = Math.ceil(content.length / size);
const chunks = new Array(numChunks);
for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
chunks[i] = content.substr(o, size);
}
return chunks;
};