1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-16 08:30:07 +00:00

Merge branch 'master' into issue-186

This commit is contained in:
Mihajlo Medjedovic
2021-02-22 11:39:44 +01:00
79 changed files with 12274 additions and 9397 deletions

View File

@@ -25,3 +25,5 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Slack message
run: curl -X POST --data-urlencode "payload={\"channel\":\"#sasjs\", \"username\":\"GitHub CI\", \"text\":\"New version of @sasjs/adapter has been released! \n Please deploy and run `dctests` with new adapter to make sure everything is still in place.\", \"icon_emoji\":\":rocket:\"}" ${{ secrets.SLACK_WEBHOOK }}

View File

@@ -1,13 +0,0 @@
{
"defaultCommandTimeout": 10000,
"chromeWebSecurity": false,
"screenshotOnRunFailure": false,
"env": {
"serverUrl": "",
"appLoc": "/Public/app",
"serverType": "SAS9",
"debug": false,
"username": "",
"password": ""
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,184 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = { module.exports = {
roots: ["<rootDir>/src"], testTimeout: 90000,
testMatch: [ // All imported modules in your tests should be mocked automatically
"**/__tests__/**/*.+(ts|tsx|js)", // automock: false,
"**/?(*.)+(spec|test).+(ts|tsx|js)"
], // Stop running tests after `n` failures
// bail: 1,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/7y/nmqg1srj29q6210rs9dfsdzc0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest/presets/js-with-ts',
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['jest-extended'],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: ['**/*spec.[j|t]s?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '/build'],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
transform: { transform: {
"^.+\\.(ts|tsx)$": "ts-jest" '^.+\\.ts?$': 'ts-jest'
} }
};
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// '**/test/**/*.ts?(x)',
// '**/?(*.)+(spec|test).ts?(x)'
// ]
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

3295
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"publish:lib": "npm run build && cd build && npm publish", "publish:lib": "npm run build && cd build && npm publish",
"lint:fix": "npx prettier --write 'src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'", "lint:fix": "npx prettier --write 'src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"lint": "npx prettier --check 'src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'", "lint": "npx prettier --check 'src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"test": "jest --coverage", "test": "jest --silent --coverage",
"prepublishOnly": "cp -r ./build/* . && rm -rf ./build", "prepublishOnly": "cp -r ./build/* . && rm -rf ./build",
"postpublish": "git clean -fd", "postpublish": "git clean -fd",
"semantic-release": "semantic-release", "semantic-release": "semantic-release",
@@ -36,31 +36,31 @@
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/isomorphic-fetch": "0.0.35",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"cp": "^0.2.0", "cp": "^0.2.0",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"jest": "^25.5.4", "jest": "^26.6.3",
"jest-extended": "^0.11.5",
"path": "^0.12.7", "path": "^0.12.7",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"semantic-release": "^17.3.1", "semantic-release": "^17.3.9",
"terser-webpack-plugin": "^4.2.3", "terser-webpack-plugin": "^4.2.3",
"ts-jest": "^25.5.1", "ts-jest": "^25.5.1",
"ts-loader": "^8.0.14", "ts-loader": "^8.0.17",
"tslint": "^6.1.3", "tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0", "tslint-config-prettier": "^1.18.0",
"typedoc": "^0.19.2", "typedoc": "^0.19.2",
"typedoc-neo-theme": "^1.0.10", "typedoc-neo-theme": "^1.1.0",
"typedoc-plugin-external-module-name": "^4.0.6", "typedoc-plugin-external-module-name": "^4.0.6",
"typescript": "^3.9.7", "typescript": "^3.9.9",
"webpack": "^5.13.0", "webpack": "^5.21.2",
"webpack-cli": "^4.3.1" "webpack-cli": "^4.5.0"
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@sasjs/utils": "^2.0.2", "axios": "^0.21.1",
"es6-promise": "^4.2.8", "@sasjs/utils": "^2.5.0",
"form-data": "^3.0.0", "form-data": "^3.0.0",
"isomorphic-fetch": "^2.2.1" "https": "^1.0.0"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,21 +4,18 @@
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@sasjs/adapter": "^1.18.2", "@sasjs/adapter": "^2.1.0",
"@sasjs/test-framework": "^1.4.0", "@sasjs/test-framework": "^1.4.0",
"@testing-library/jest-dom": "^4.2.4", "@types/jest": "^26.0.20",
"@testing-library/react": "^9.5.0", "@types/node": "^14.14.25",
"@testing-library/user-event": "^7.2.1", "@types/react": "^17.0.1",
"@types/jest": "^26.0.3", "@types/react-dom": "^17.0.0",
"@types/node": "^14.0.14", "@types/react-router-dom": "^5.1.7",
"@types/react": "^16.9.41", "react": "^17.0.1",
"@types/react-dom": "^16.9.8", "react-dom": "^17.0.1",
"@types/react-router-dom": "^5.1.5",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "3.4.1", "react-scripts": "^4.0.2",
"typescript": "^3.9.6" "typescript": "^4.1.3"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@@ -45,6 +42,6 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"node-sass": "^4.14.1" "node-sass": "^5.0.0"
} }
} }

View File

@@ -1,15 +1,17 @@
import SASjs, { ServerType, SASjsConfig } from "@sasjs/adapter"; import SASjs, { SASjsConfig } from "@sasjs/adapter";
import { TestSuite } from "@sasjs/test-framework"; import { TestSuite } from "@sasjs/test-framework";
import { ServerType } from "@sasjs/utils/types";
const defaultConfig: SASjsConfig = { const defaultConfig: SASjsConfig = {
serverUrl: window.location.origin, serverUrl: window.location.origin,
pathSAS9: '/SASStoredProcess/do', pathSAS9: "/SASStoredProcess/do",
pathSASViya: '/SASJobExecution', pathSASViya: "/SASJobExecution",
appLoc: '/Public/seedapp', appLoc: "/Public/seedapp",
serverType: ServerType.SASViya, serverType: ServerType.SasViya,
debug: false, debug: false,
contextName: 'SAS Job Execution compute context', contextName: "SAS Job Execution compute context",
useComputeApi: false useComputeApi: false,
allowInsecureRequests: false
}; };
const customConfig = { const customConfig = {
@@ -17,7 +19,7 @@ const customConfig = {
pathSAS9: "sas9", pathSAS9: "sas9",
pathSASViya: "viya", pathSASViya: "viya",
appLoc: "/Public/seedapp", appLoc: "/Public/seedapp",
serverType: ServerType.SAS9, serverType: ServerType.Sas9,
debug: false debug: false
}; };
@@ -39,11 +41,12 @@ export const basicTests = (
}, },
{ {
title: "Multiple Log in attempts", title: "Multiple Log in attempts",
description: "Should fail on first attempt and should log the user in on second attempt", description:
"Should fail on first attempt and should log the user in on second attempt",
test: async () => { test: async () => {
await adapter.logOut() await adapter.logOut();
await adapter.logIn('invalid', 'invalid') await adapter.logIn("invalid", "invalid");
return adapter.logIn(userName, password) return adapter.logIn(userName, password);
}, },
assertion: (response: any) => assertion: (response: any) =>
response && response.isLoggedIn && response.userName === userName response && response.isLoggedIn && response.userName === userName

View File

@@ -12,7 +12,7 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
return adapter.startComputeJob("/Public/app/common/sendArr", data); return adapter.startComputeJob("/Public/app/common/sendArr", data);
}, },
assertion: (res: any) => { assertion: (res: any) => {
const expectedProperties = ["id", "applicationName", "attributes"] const expectedProperties = ["id", "applicationName", "attributes"];
return validate(expectedProperties, res); return validate(expectedProperties, res);
} }
}, },
@@ -21,10 +21,21 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
description: "Should start a compute job and return the job", description: "Should start a compute job and return the job",
test: () => { test: () => {
const data: any = { table1: [{ col1: "first col value" }] }; const data: any = { table1: [{ col1: "first col value" }] };
return adapter.startComputeJob("/Public/app/common/sendArr", data, {}, "", true); return adapter.startComputeJob(
"/Public/app/common/sendArr",
data,
{},
"",
true
);
}, },
assertion: (res: any) => { assertion: (res: any) => {
const expectedProperties = ["id", "state", "creationTimeStamp", "jobConditionCode"] const expectedProperties = [
"id",
"state",
"creationTimeStamp",
"jobConditionCode"
];
return validate(expectedProperties, res.job); return validate(expectedProperties, res.job);
} }
}, },
@@ -38,19 +49,19 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
`output;`, `output;`,
`end;`, `end;`,
`run;` `run;`
] ];
return adapter.executeScriptSASViya( return adapter.executeScriptSASViya(
'sasCode.sas', "sasCode.sas",
fileLines, fileLines,
'SAS Studio compute context', "SAS Studio compute context",
undefined, undefined,
true true
) );
}, },
assertion: (res: any) => { assertion: (res: any) => {
const expectedLogContent = `1 data;\\n2 do x=1 to 100;\\n3 output;\\n4 end;\\n5 run;\\n\\n` const expectedLogContent = `1 data;\\n2 do x=1 to 100;\\n3 output;\\n4 end;\\n5 run;\\n\\n`;
return validateLog(expectedLogContent, res.log); return validateLog(expectedLogContent, res.log);
} }
}, },
@@ -58,21 +69,21 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
title: "Execute Script Viya - failed job", title: "Execute Script Viya - failed job",
description: "Should execute sas file and return log", description: "Should execute sas file and return log",
test: () => { test: () => {
const fileLines = [ const fileLines = [`%abort;`];
`%abort;`
] return adapter
.executeScriptSASViya(
return adapter.executeScriptSASViya( "sasCode.sas",
'sasCode.sas', fileLines,
fileLines, "SAS Studio compute context",
'SAS Studio compute context', undefined,
undefined, true
true )
).catch((err: any) => err ) .catch((err: any) => err);
}, },
assertion: (res: any) => { assertion: (res: any) => {
const expectedLogContent = `1 %abort;\\nERROR: The %ABORT statement is not valid in open code.\\n` const expectedLogContent = `1 %abort;\\nERROR: The %ABORT statement is not valid in open code.\\n`;
return validateLog(expectedLogContent, res.log); return validateLog(expectedLogContent, res.log);
} }
} }
@@ -80,16 +91,16 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
}); });
const validateLog = (text: string, log: string): boolean => { const validateLog = (text: string, log: string): boolean => {
const isValid = JSON.stringify(log).includes(text) const isValid = JSON.stringify(log).includes(text);
return isValid return isValid;
} };
const validate = (expectedProperties: string[], data: any): boolean => { const validate = (expectedProperties: string[], data: any): boolean => {
const actualProperties = Object.keys(data); const actualProperties = Object.keys(data);
const isValid = expectedProperties.every( const isValid = expectedProperties.every((property) =>
(property) => actualProperties.includes(property) actualProperties.includes(property)
); );
return isValid return isValid;
} };

View File

@@ -185,7 +185,8 @@ export const sendObjTests = (adapter: SASjs): TestSuite => ({
}; };
return adapter.request("common/sendObj", invalidData).catch((e) => e); return adapter.request("common/sendObj", invalidData).catch((e) => e);
}, },
assertion: (error: any) => !!error && !!error.error && !!error.error.message assertion: (error: any) =>
!!error && !!error.error && !!error.error.message
}, },
{ {
title: "Single string value", title: "Single string value",

View File

@@ -23,26 +23,23 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
}, },
{ {
title: "Make error and capture log", title: "Make error and capture log",
description: "Should make an error and capture log, in the same time it is testing if debug override is working", description:
"Should make an error and capture log, in the same time it is testing if debug override is working",
test: async () => { test: async () => {
return new Promise(async (resolve, reject) => { return adapter
adapter .request("common/makeErr", data, { debug: true })
.request("common/makeErr", data, {debug: true}) .catch(() => {
.then((res) => { const sasRequests = adapter.getSasRequests();
//no action here, this request must throw error const makeErrRequest: any =
}) sasRequests.find((req) => req.serviceLink.includes("makeErr")) ||
.catch((err) => { null;
let sasRequests = adapter.getSasRequests();
let makeErrRequest: any =
sasRequests.find((req) =>
req.serviceLink.includes("makeErr")
) || null;
if (!makeErrRequest) resolve(false) if (!makeErrRequest) return false;
resolve(!!(makeErrRequest.logFile && makeErrRequest.logFile.length > 0)); return !!(
}); makeErrRequest.logFile && makeErrRequest.logFile.length > 0
}); );
});
}, },
assertion: (response) => { assertion: (response) => {
return response; return response;

View File

@@ -17,7 +17,8 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react" "jsx": "react-jsx",
"noFallthroughCasesInSwitch": true
}, },
"include": [ "include": [
"src" "src"

View File

@@ -1,11 +1,7 @@
import { import { Context, EditContextInput, ContextAllAttributes } from './types'
Context, import { isUrl } from './utils'
CsrfToken,
EditContextInput,
ContextAllAttributes
} from './types'
import { makeRequest, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from './request/RequestClient'
export class ContextManager { export class ContextManager {
private defaultComputeContexts = [ private defaultComputeContexts = [
@@ -28,8 +24,6 @@ export class ContextManager {
'SAS Visual Forecasting launcher context' 'SAS Visual Forecasting launcher context'
] ]
private csrfToken: CsrfToken | null = null
get getDefaultComputeContexts() { get getDefaultComputeContexts() {
return this.defaultComputeContexts return this.defaultComputeContexts
} }
@@ -37,28 +31,19 @@ export class ContextManager {
return this.defaultLauncherContexts return this.defaultLauncherContexts
} }
constructor( constructor(private serverUrl: string, private requestClient: RequestClient) {
private serverUrl: string,
private setCsrfToken: (csrfToken: CsrfToken) => void
) {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
} }
public async getComputeContexts(accessToken?: string) { public async getComputeContexts(accessToken?: string) {
const headers: any = { const { result: contexts } = await this.requestClient
'Content-Type': 'application/json' .get<{ items: Context[] }>(
} `${this.serverUrl}/compute/contexts?limit=10000`,
accessToken
if (accessToken) { )
headers.Authorization = `Bearer ${accessToken}` .catch((err) => {
} throw prefixMessage(err, 'Error while getting compute contexts. ')
})
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting compute contexts. ')
})
const contextsList = contexts && contexts.items ? contexts.items : [] const contextsList = contexts && contexts.items ? contexts.items : []
@@ -72,20 +57,14 @@ export class ContextManager {
} }
public async getLauncherContexts(accessToken?: string) { public async getLauncherContexts(accessToken?: string) {
const headers: any = { const { result: contexts } = await this.requestClient
'Content-Type': 'application/json' .get<{ items: Context[] }>(
} `${this.serverUrl}/launcher/contexts?limit=10000`,
accessToken
if (accessToken) { )
headers.Authorization = `Bearer ${accessToken}` .catch((err) => {
} throw prefixMessage(err, 'Error while getting launcher contexts. ')
})
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/launcher/contexts?limit=10000`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting launcher contexts. ')
})
const contextsList = contexts && contexts.items ? contexts.items : [] const contextsList = contexts && contexts.items ? contexts.items : []
@@ -183,18 +162,15 @@ export class ContextManager {
requestBody.environment = { autoExecLines } requestBody.environment = { autoExecLines }
} }
const createContextRequest: RequestInit = { const { result: context } = await this.requestClient
method: 'POST', .post<Context>(
headers, `${this.serverUrl}/compute/contexts`,
body: JSON.stringify(requestBody) requestBody,
} accessToken
)
const { result: context } = await this.request<Context>( .catch((err) => {
`${this.serverUrl}/compute/contexts`, throw prefixMessage(err, 'Error while creating compute context. ')
createContextRequest })
).catch((err) => {
throw prefixMessage(err, 'Error while creating compute context. ')
})
return context return context
} }
@@ -237,18 +213,15 @@ export class ContextManager {
launchType launchType
} }
const createContextRequest: RequestInit = { const { result: context } = await this.requestClient
method: 'POST', .post<Context>(
headers, `${this.serverUrl}/launcher/contexts`,
body: JSON.stringify(requestBody) requestBody,
} accessToken
)
const { result: context } = await this.request<Context>( .catch((err) => {
`${this.serverUrl}/launcher/contexts`, throw prefixMessage(err, 'Error while creating launcher context. ')
createContextRequest })
).catch((err) => {
throw prefixMessage(err, 'Error while creating launcher context. ')
})
return context return context
} }
@@ -267,14 +240,6 @@ export class ContextManager {
true true
) )
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
let originalContext let originalContext
originalContext = await this.getComputeContextByName( originalContext = await this.getComputeContextByName(
@@ -290,39 +255,33 @@ export class ContextManager {
) )
} }
const { result: context, etag } = await this.request<Context>( const { result: context, etag } = await this.requestClient
`${this.serverUrl}/compute/contexts/${originalContext.id}`, .get<Context>(
{ `${this.serverUrl}/compute/contexts/${originalContext.id}`,
headers accessToken
} )
).catch((err) => { .catch((err) => {
if (err && err.status === 404) { if (err && err.status === 404) {
throw new Error( throw new Error(
`The context '${contextName}' was not found on this server.` `The context '${contextName}' was not found on this server.`
) )
} }
throw err throw err
}) })
// An If-Match header with the value of the last ETag for the context // An If-Match header with the value of the last ETag for the context
// is required to be able to update it // is required to be able to update it
// https://developer.sas.com/apis/rest/Compute/#update-a-context-definition // https://developer.sas.com/apis/rest/Compute/#update-a-context-definition
headers['If-Match'] = etag return await this.requestClient.put<Context>(
`/compute/contexts/${context.id}`,
const updateContextRequest: RequestInit = { {
method: 'PUT',
headers,
body: JSON.stringify({
...context, ...context,
...editedContext, ...editedContext,
attributes: { ...context.attributes, ...editedContext.attributes } attributes: { ...context.attributes, ...editedContext.attributes }
}) },
} accessToken,
{ 'If-Match': etag }
return await this.request<Context>(
`${this.serverUrl}/compute/contexts/${context.id}`,
updateContextRequest
) )
} }
@@ -330,20 +289,17 @@ export class ContextManager {
contextName: string, contextName: string,
accessToken?: string accessToken?: string
): Promise<Context> { ): Promise<Context> {
const headers: any = { const { result: contexts } = await this.requestClient
'Content-Type': 'application/json' .get<{ items: Context[] }>(
} `${this.serverUrl}/compute/contexts?filter=eq(name, "${contextName}")`,
accessToken
if (accessToken) { )
headers.Authorization = `Bearer ${accessToken}` .catch((err) => {
} throw prefixMessage(
err,
const { result: contexts } = await this.request<{ items: Context[] }>( 'Error while getting compute context by name. '
`${this.serverUrl}/compute/contexts?filter=eq(name, "${contextName}")`, )
{ headers } })
).catch((err) => {
throw prefixMessage(err, 'Error while getting compute context by name. ')
})
if (!contexts || !(contexts.items && contexts.items.length)) { if (!contexts || !(contexts.items && contexts.items.length)) {
throw new Error( throw new Error(
@@ -358,20 +314,16 @@ export class ContextManager {
contextId: string, contextId: string,
accessToken?: string accessToken?: string
): Promise<ContextAllAttributes> { ): Promise<ContextAllAttributes> {
const headers: any = { const {
'Content-Type': 'application/json' result: context
} } = await this.requestClient
.get<ContextAllAttributes>(
if (accessToken) { `${this.serverUrl}/compute/contexts/${contextId}`,
headers.Authorization = `Bearer ${accessToken}` accessToken
} )
.catch((err) => {
const { result: context } = await this.request<ContextAllAttributes>( throw prefixMessage(err, 'Error while getting compute context by id. ')
`${this.serverUrl}/compute/contexts/${contextId}`, })
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while getting compute context by id. ')
})
return context return context
} }
@@ -380,20 +332,14 @@ export class ContextManager {
executeScript: Function, executeScript: Function,
accessToken?: string accessToken?: string
) { ) {
const headers: any = { const { result: contexts } = await this.requestClient
'Content-Type': 'application/json' .get<{ items: Context[] }>(
} `${this.serverUrl}/compute/contexts?limit=10000`,
accessToken
if (accessToken) { )
headers.Authorization = `Bearer ${accessToken}` .catch((err) => {
} throw prefixMessage(err, 'Error while fetching compute contexts.')
})
const { result: contexts } = await this.request<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
{ headers }
).catch((err) => {
throw prefixMessage(err, 'Error while fetching compute contexts.')
})
const contextsList = contexts.items || [] const contextsList = contexts.items || []
const executableContexts: any[] = [] const executableContexts: any[] = []
@@ -470,14 +416,9 @@ export class ContextManager {
const context = await this.getComputeContextByName(contextName, accessToken) const context = await this.getComputeContextByName(contextName, accessToken)
const deleteContextRequest: RequestInit = { return await this.requestClient.delete<Context>(
method: 'DELETE',
headers
}
return await this.request<Context>(
`${this.serverUrl}/compute/contexts/${context.id}`, `${this.serverUrl}/compute/contexts/${context.id}`,
deleteContextRequest accessToken
) )
} }
@@ -485,34 +426,6 @@ export class ContextManager {
// TODO: implement deleteLauncherContext method // TODO: implement deleteLauncherContext method
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,
(token) => {
this.csrfToken = token
this.setCsrfToken(token)
},
contentType
).catch((err) => {
throw prefixMessage(
err,
'Error while making request in Context Manager. '
)
})
}
private validateContextName(name: string) { private validateContextName(name: string) {
if (!name) throw new Error('Context name is required.') if (!name) throw new Error('Context name is required.')
} }

View File

@@ -1,115 +1,70 @@
import { isLogInRequired, needsRetry, isUrl } from './utils' import { isUrl } from './utils'
import { CsrfToken } from './types/CsrfToken'
import { UploadFile } from './types/UploadFile' import { UploadFile } from './types/UploadFile'
import { ErrorResponse } from './types' import { ErrorResponse, LoginRequiredError } from './types'
import { RequestClient } from './request/RequestClient'
const requestRetryLimit = 5
export class FileUploader { export class FileUploader {
constructor( constructor(
private appLoc: string, private appLoc: string,
private serverUrl: string, serverUrl: string,
private jobsPath: string, private jobsPath: string,
private setCsrfTokenWeb: any, private requestClient: RequestClient
private csrfToken: CsrfToken | null = null
) { ) {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
} }
private retryCount = 0
public uploadFile(sasJob: string, files: UploadFile[], params: any) { public uploadFile(sasJob: string, files: UploadFile[], params: any) {
return new Promise((resolve, reject) => { if (files?.length < 1)
if (files?.length < 1) return Promise.reject(
reject(new ErrorResponse('At least one file must be provided.')) new ErrorResponse('At least one file must be provided.')
if (!sasJob || sasJob === '') )
reject(new ErrorResponse('sasJob must be provided.')) if (!sasJob || sasJob === '')
return Promise.reject(new ErrorResponse('sasJob must be provided.'))
let paramsString = '' let paramsString = ''
for (let param in params) { for (let param in params) {
if (params.hasOwnProperty(param)) { if (params.hasOwnProperty(param)) {
paramsString += `&${param}=${params[param]}` paramsString += `&${param}=${params[param]}`
}
}
const program = this.appLoc
? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.jobsPath}/?${
'_program=' + program
}${paramsString}`
const formData = new FormData()
for (let file of files) {
formData.append('file', file.file, file.fileName)
}
const csrfToken = this.requestClient.getCsrfToken('file')
if (csrfToken) formData.append('_csrf', csrfToken.value)
const headers = {
'cache-control': 'no-cache',
Accept: '*/*',
'Content-Type': 'text/plain'
}
return this.requestClient
.post(uploadUrl, formData, undefined, 'application/json', headers)
.then((res) =>
typeof res.result === 'string' ? JSON.parse(res.result) : res.result
)
.catch((err: Error) => {
if (err instanceof LoginRequiredError) {
return Promise.reject(
new ErrorResponse('You must be logged in to upload a file.', err)
)
} }
} return Promise.reject(
new ErrorResponse('File upload request failed.', err)
const program = this.appLoc )
? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.serverUrl}${this.jobsPath}/?${
'_program=' + program
}${paramsString}`
const headers = {
'cache-control': 'no-cache'
}
const formData = new FormData()
for (let file of files) {
formData.append('file', file.file, file.fileName)
}
if (this.csrfToken) formData.append('_csrf', this.csrfToken.value)
fetch(uploadUrl, {
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.csrfToken = {
headerName: tokenHeader,
value: token || ''
}
this.setCsrfTokenWeb(this.csrfToken)
}
}
}
return response.text()
})
.then((responseText) => {
if (isLogInRequired(responseText))
reject(new ErrorResponse('You must be logged in to upload a file.'))
if (needsRetry(responseText)) {
if (this.retryCount < requestRetryLimit) {
this.retryCount++
this.uploadFile(sasJob, files, params).then(
(res: any) => resolve(res),
(err: any) => reject(err)
)
} else {
this.retryCount = 0
reject(responseText)
}
} else {
this.retryCount = 0
try {
resolve(JSON.parse(responseText))
} catch (e) {
reject(
new ErrorResponse(
'Error while parsing json from upload response.',
e
)
)
}
}
})
.catch((err: any) => {
reject(new ErrorResponse('Upload request failed.', err))
})
})
} }
} }

View File

@@ -1,3 +1,4 @@
import axios, { AxiosInstance } from 'axios'
import { isUrl } from './utils' import { isUrl } from './utils'
/** /**
@@ -5,8 +6,11 @@ import { isUrl } from './utils'
* *
*/ */
export class SAS9ApiClient { export class SAS9ApiClient {
private httpClient: AxiosInstance
constructor(private serverUrl: string) { constructor(private serverUrl: string) {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
this.httpClient = axios.create({ baseURL: this.serverUrl })
} }
/** /**
@@ -38,18 +42,18 @@ export class SAS9ApiClient {
repositoryName: string repositoryName: string
) { ) {
const requestPayload = linesOfCode.join('\n') 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 const executeScriptResponse = await this.httpClient.put(
`/sas/servers/${serverName}/cmd?repositoryName=${repositoryName}`,
`command=${requestPayload}`,
{
headers: {
Accept: 'application/json'
},
responseType: 'text'
}
)
return executeScriptResponse.data
} }
} }

View File

@@ -1,12 +1,4 @@
import { import { convertToCSV, isRelativePath, isUri, isUrl } from './utils'
isAuthorizeFormRequired,
parseAndSubmitAuthorizeForm,
convertToCSV,
makeRequest,
isRelativePath,
isUri,
isUrl
} from './utils'
import * as NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { import {
Job, Job,
@@ -14,17 +6,21 @@ import {
Context, Context,
ContextAllAttributes, ContextAllAttributes,
Folder, Folder,
CsrfToken,
EditContextInput, EditContextInput,
JobDefinition, JobDefinition,
PollOptions PollOptions,
ComputeJobExecutionError,
JobExecutionError
} from './types' } from './types'
import { formatDataForRequest } from './utils/formatDataForRequest' import { formatDataForRequest } from './utils/formatDataForRequest'
import { SessionManager } from './SessionManager' import { SessionManager } from './SessionManager'
import { ContextManager } from './ContextManager' import { ContextManager } from './ContextManager'
import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time' import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { prefixMessage } from '@sasjs/utils/error' import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
import { RequestClient } from './request/RequestClient'
import { NotFoundError } from './types/NotFoundError'
import { SasAuthResponse } from '@sasjs/utils/types'
/** /**
* A client for interfacing with the SAS Viya REST API. * A client for interfacing with the SAS Viya REST API.
@@ -35,20 +31,21 @@ export class SASViyaApiClient {
private serverUrl: string, private serverUrl: string,
private rootFolderName: string, private rootFolderName: string,
private contextName: string, private contextName: string,
private setCsrfToken: (csrfToken: CsrfToken) => void private requestClient: RequestClient
) { ) {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
} }
private csrfToken: CsrfToken | null = null
private fileUploadCsrfToken: CsrfToken | null = null
private _debug = false private _debug = false
private sessionManager = new SessionManager( private sessionManager = new SessionManager(
this.serverUrl, this.serverUrl,
this.contextName, this.contextName,
this.setCsrfToken this.requestClient
)
private contextManager = new ContextManager(
this.serverUrl,
this.requestClient
) )
private contextManager = new ContextManager(this.serverUrl, this.setCsrfToken)
private folderMap = new Map<string, Job[]>() private folderMap = new Map<string, Job[]>()
public get debug() { public get debug() {
@@ -147,10 +144,10 @@ export class SASViyaApiClient {
headers.Authorization = `Bearer ${accessToken}` headers.Authorization = `Bearer ${accessToken}`
} }
const { result: contexts } = await this.request<{ items: Context[] }>( const { result: contexts } = await this.requestClient.get<{
`${this.serverUrl}/compute/contexts?limit=10000`, items: Context[]
{ headers } }>(`/compute/contexts?limit=10000`, accessToken)
)
const executionContext = const executionContext =
contexts.items && contexts.items.length contexts.items && contexts.items.length
? contexts.items.find((c: any) => c.name === contextName) ? contexts.items.find((c: any) => c.name === contextName)
@@ -166,9 +163,10 @@ export class SASViyaApiClient {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} }
const { result: createdSession } = await this.request<Session>( const { result: createdSession } = await this.requestClient.post<Session>(
`${this.serverUrl}/compute/contexts/${executionContext.id}/sessions`, `/compute/contexts/${executionContext.id}/sessions`,
createSessionRequest {},
accessToken
) )
return createdSession return createdSession
@@ -371,24 +369,22 @@ export class SASViyaApiClient {
} }
// Execute job in session // Execute job in session
const postJobRequest = { const jobRequestBody = {
method: 'POST', name: fileName,
headers, description: 'Powered by SASjs',
body: JSON.stringify({ code: linesOfCode,
name: fileName, variables: jobVariables,
description: 'Powered by SASjs', arguments: jobArguments
code: linesOfCode,
variables: jobVariables,
arguments: jobArguments
})
} }
const { result: postedJob, etag } = await this.requestClient
const { result: postedJob, etag } = await this.request<Job>( .post<Job>(
`${this.serverUrl}/compute/sessions/${executionSessionId}/jobs`, `/compute/sessions/${executionSessionId}/jobs`,
postJobRequest jobRequestBody,
).catch((err) => { accessToken
throw err )
}) .catch((err: any) => {
throw err
})
if (!waitForResult) { if (!waitForResult) {
return session return session
@@ -410,12 +406,14 @@ export class SASViyaApiClient {
pollOptions pollOptions
) )
const { result: currentJob } = await this.request<Job>( const { result: currentJob } = await this.requestClient
`${this.serverUrl}/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`, .get<Job>(
{ headers } `/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
).catch((err) => { accessToken
throw err )
}) .catch((err) => {
throw err
})
let jobResult let jobResult
let log let log
@@ -423,12 +421,8 @@ export class SASViyaApiClient {
const logLink = currentJob.links.find((l) => l.rel === 'log') const logLink = currentJob.links.find((l) => l.rel === 'log')
if (debug && logLink) { if (debug && logLink) {
log = await this.request<any>( log = await this.requestClient
`${this.serverUrl}${logLink.href}/content?limit=10000`, .get<any>(`${logLink.href}/content?limit=10000`, accessToken)
{
headers
}
)
.then((res: any) => .then((res: any) =>
res.result.items.map((i: any) => i.line).join('\n') res.result.items.map((i: any) => i.line).join('\n')
) )
@@ -438,7 +432,7 @@ export class SASViyaApiClient {
} }
if (jobStatus === 'failed' || jobStatus === 'error') { if (jobStatus === 'failed' || jobStatus === 'error') {
return Promise.reject({ job: currentJob, log }) return Promise.reject(new ComputeJobExecutionError(currentJob, log))
} }
let resultLink let resultLink
@@ -450,36 +444,30 @@ export class SASViyaApiClient {
} }
if (resultLink) { if (resultLink) {
jobResult = await this.request<any>( jobResult = await this.requestClient
`${this.serverUrl}${resultLink}`, .get<any>(resultLink, accessToken, 'text/plain')
{ headers }, .catch(async (e) => {
'text' if (e instanceof NotFoundError) {
).catch(async (e) => { if (logLink) {
if (e && e.status === 404) { log = await this.requestClient
if (logLink) { .get<any>(`${logLink.href}/content?limit=10000`, accessToken)
log = await this.request<any>( .then((res: any) =>
`${this.serverUrl}${logLink.href}/content?limit=10000`, res.result.items.map((i: any) => i.line).join('\n')
{ )
headers .catch((err) => {
} throw err
) })
.then((res: any) =>
res.result.items.map((i: any) => i.line).join('\n')
)
.catch((err) => {
throw err
})
return Promise.reject({ return Promise.reject({
status: 500, status: 500,
log: log log
}) })
}
} }
} return {
return { result: JSON.stringify(e)
result: JSON.stringify(e) }
} })
})
} }
await this.sessionManager await this.sessionManager
@@ -507,6 +495,17 @@ export class SASViyaApiClient {
} }
} }
/**
* Fetches a folder. Path to the folder is required.
* @param folderPath - the absolute path to the folder.
* @param accessToken - an access token for authorizing the request.
*/
public async getFolder(folderPath: string, accessToken?: string) {
return await this.requestClient
.get(`/folders/folders/@item?path=${folderPath}`, accessToken)
.then((res) => res.result)
}
/** /**
* Creates a folder. Path to or URI of the parent folder is required. * Creates a folder. Path to or URI of the parent folder is required.
* @param folderName - the name of the new folder. * @param folderName - the name of the new folder.
@@ -569,22 +568,15 @@ export class SASViyaApiClient {
} }
} }
const createFolderRequest: RequestInit = { const {
method: 'POST', result: createFolderResponse
body: JSON.stringify({ } = await this.requestClient.post<Folder>(
`/folders/folders?parentFolderUri=${parentFolderUri}`,
{
name: folderName, name: folderName,
type: 'folder' type: 'folder'
}) },
} accessToken
createFolderRequest.headers = { 'Content-Type': 'application/json' }
if (accessToken) {
createFolderRequest.headers.Authorization = `Bearer ${accessToken}`
}
const { result: createFolderResponse } = await this.request<Folder>(
`${this.serverUrl}/folders/folders?parentFolderUri=${parentFolderUri}`,
createFolderRequest
) )
// update folder map with newly created folder. // update folder map with newly created folder.
@@ -618,13 +610,9 @@ export class SASViyaApiClient {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken) parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
} }
const createJobDefinitionRequest: RequestInit = { return await this.requestClient.post<Job>(
method: 'POST', `${this.serverUrl}/jobDefinitions/definitions?parentFolderUri=${parentFolderUri}`,
headers: { {
'Content-Type': 'application/vnd.sas.job.definition+json',
Accept: 'application/vnd.sas.job.definition+json'
},
body: JSON.stringify({
name: jobName, name: jobName,
parameters: [ parameters: [
{ {
@@ -635,19 +623,8 @@ export class SASViyaApiClient {
], ],
type: 'Compute', type: 'Compute',
code code
}) },
} accessToken
if (accessToken) {
createJobDefinitionRequest!.headers = {
...createJobDefinitionRequest.headers,
Authorization: `Bearer ${accessToken}`
}
}
return await this.request<Job>(
`${this.serverUrl}/jobDefinitions/definitions?parentFolderUri=${parentFolderUri}`,
createJobDefinitionRequest
) )
} }
@@ -658,18 +635,13 @@ export class SASViyaApiClient {
public async getAuthCode(clientId: string) { public async getAuthCode(clientId: string) {
const authUrl = `${this.serverUrl}/SASLogon/oauth/authorize?client_id=${clientId}&response_type=code` const authUrl = `${this.serverUrl}/SASLogon/oauth/authorize?client_id=${clientId}&response_type=code`
const authCode = await fetch(authUrl, { const authCode = await this.requestClient
referrerPolicy: 'same-origin', .get<string>(authUrl, undefined, 'text/plain')
credentials: 'include' .then((response) => response.result)
})
.then((response) => response.text())
.then(async (response) => { .then(async (response) => {
let code = '' let code = ''
if (isAuthorizeFormRequired(response)) { if (isAuthorizeFormRequired(response)) {
const formResponse: any = await parseAndSubmitAuthorizeForm( const formResponse: any = await this.requestClient.authorize(response)
response,
this.serverUrl
)
const responseBody = formResponse const responseBody = formResponse
.split('<body>')[1] .split('<body>')[1]
@@ -707,7 +679,7 @@ export class SASViyaApiClient {
clientId: string, clientId: string,
clientSecret: string, clientSecret: string,
authCode: string authCode: string
) { ): Promise<SasAuthResponse> {
const url = this.serverUrl + '/SASLogon/oauth/token' const url = this.serverUrl + '/SASLogon/oauth/token'
let token let token
if (typeof Buffer === 'undefined') { if (typeof Buffer === 'undefined') {
@@ -730,13 +702,15 @@ export class SASViyaApiClient {
formData.append('code', authCode) formData.append('code', authCode)
} }
const authResponse = await fetch(url, { const authResponse = await this.requestClient
method: 'POST', .post(
credentials: 'include', url,
headers, formData,
body: formData as any, undefined,
referrerPolicy: 'same-origin' 'multipart/form-data; boundary=' + (formData as any)._boundary,
}).then((res) => res.json()) headers
)
.then((res) => res.result as SasAuthResponse)
return authResponse return authResponse
} }
@@ -774,13 +748,15 @@ export class SASViyaApiClient {
formData.append('refresh_token', refreshToken) formData.append('refresh_token', refreshToken)
} }
const authResponse = await fetch(url, { const authResponse = await this.requestClient
method: 'POST', .post<SasAuthResponse>(
credentials: 'include', url,
headers, formData,
body: formData as any, undefined,
referrerPolicy: 'same-origin' 'multipart/form-data; boundary=' + (formData as any)._boundary,
}).then((res) => res.json()) headers
)
.then((res) => res.result)
return authResponse return authResponse
} }
@@ -796,13 +772,10 @@ export class SASViyaApiClient {
if (accessToken) { if (accessToken) {
headers.Authorization = `Bearer ${accessToken}` headers.Authorization = `Bearer ${accessToken}`
} }
const deleteResponse = await this.request(url, {
method: 'DELETE',
credentials: 'include',
headers
})
return deleteResponse const deleteResponse = await this.requestClient.delete(url, accessToken)
return deleteResponse.result
} }
/** /**
@@ -872,9 +845,11 @@ export class SASViyaApiClient {
throw new Error(`URI of job definition was not found.`) throw new Error(`URI of job definition was not found.`)
} }
const { result: jobDefinition } = await this.request<JobDefinition>( const {
result: jobDefinition
} = await this.requestClient.get<JobDefinition>(
`${this.serverUrl}${jobDefinitionLink.href}`, `${this.serverUrl}${jobDefinitionLink.href}`,
{ headers } accessToken
) )
code = jobDefinition.code code = jobDefinition.code
@@ -949,20 +924,10 @@ export class SASViyaApiClient {
const jobDefinitionLink = jobToExecute?.links.find( const jobDefinitionLink = jobToExecute?.links.find(
(l) => l.rel === 'getResource' (l) => l.rel === 'getResource'
)?.href )?.href
const requestInfo: any = {
method: 'GET'
}
const headers: any = { 'Content-Type': 'application/json' }
if (!!accessToken) { const { result: jobDefinition } = await this.requestClient.get<Job>(
headers.Authorization = `Bearer ${accessToken}`
}
requestInfo.headers = headers
const { result: jobDefinition } = await this.request<Job>(
`${this.serverUrl}${jobDefinitionLink}`, `${this.serverUrl}${jobDefinitionLink}`,
requestInfo accessToken
) )
const jobArguments: { [key: string]: any } = { const jobArguments: { [key: string]: any } = {
@@ -989,47 +954,46 @@ export class SASViyaApiClient {
jobArguments[`_webin_name${index + 1}`] = fileInfo.tableName jobArguments[`_webin_name${index + 1}`] = fileInfo.tableName
}) })
const postJobRequest = { const postJobRequestBody = {
method: 'POST', name: `exec-${jobName}`,
headers, description: 'Powered by SASjs',
body: JSON.stringify({ jobDefinition,
name: `exec-${jobName}`, arguments: jobArguments
description: 'Powered by SASjs',
jobDefinition,
arguments: jobArguments
})
} }
const { result: postedJob, etag } = await this.request<Job>( const { result: postedJob, etag } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`, `${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequest postJobRequestBody,
accessToken
) )
const jobStatus = await this.pollJobState(postedJob, etag, accessToken) const jobStatus = await this.pollJobState(postedJob, etag, accessToken)
const { result: currentJob } = await this.request<Job>( const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
{ headers } accessToken
) )
let jobResult let jobResult
let log let log
if (jobStatus === 'failed') {
return Promise.reject(currentJob.error)
}
const resultLink = currentJob.results['_webout.json'] const resultLink = currentJob.results['_webout.json']
const logLink = currentJob.links.find((l) => l.rel === 'log') const logLink = currentJob.links.find((l) => l.rel === 'log')
if (resultLink) { if (resultLink) {
jobResult = await this.request<any>( jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`, `${this.serverUrl}${resultLink}/content`,
{ headers }, accessToken,
'text' 'text/plain'
) )
} }
if (debug && logLink) { if (debug && logLink) {
log = await this.request<any>( log = await this.requestClient
`${this.serverUrl}${logLink.href}/content`, .get<any>(`${this.serverUrl}${logLink.href}/content`, accessToken)
{ .then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
headers }
} if (jobStatus === 'failed') {
).then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) throw new JobExecutionError(
currentJob.error?.errorCode,
currentJob.error?.message,
log
)
} }
return { result: jobResult?.result, log } return { result: jobResult?.result, log }
} }
@@ -1043,22 +1007,16 @@ export class SASViyaApiClient {
} }
const url = '/folders/folders/@item?path=' + path const url = '/folders/folders/@item?path=' + path
const requestInfo: any = { const { result: folder } = await this.requestClient.get<Folder>(
method: 'GET' `${url}`,
} accessToken
if (accessToken) {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` }
}
const { result: folder } = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
) )
if (!folder) { if (!folder) {
throw new Error(`The path ${path} does not exist on ${this.serverUrl}`) throw new Error(`The path ${path} does not exist on ${this.serverUrl}`)
} }
const { result: members } = await this.request<{ items: any[] }>( const { result: members } = await this.requestClient.get<{ items: any[] }>(
`${this.serverUrl}/folders/folders/${folder.id}/members?limit=${folder.memberCount}`, `/folders/folders/${folder.id}/members?limit=${folder.memberCount}`,
requestInfo accessToken
) )
const itemsAtRoot = members.items const itemsAtRoot = members.items
@@ -1072,7 +1030,7 @@ export class SASViyaApiClient {
accessToken?: string, accessToken?: string,
pollOptions?: PollOptions pollOptions?: PollOptions
) { ) {
let POLL_INTERVAL = 100 let POLL_INTERVAL = 300
let MAX_POLL_COUNT = 1000 let MAX_POLL_COUNT = 1000
if (pollOptions) { if (pollOptions) {
@@ -1094,12 +1052,10 @@ export class SASViyaApiClient {
Promise.reject(`Job state link was not found.`) Promise.reject(`Job state link was not found.`)
} }
const { result: state } = await this.request<string>( const { result: state } = await this.requestClient.get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=30`, `${this.serverUrl}${stateLink.href}?_action=wait&wait=30`,
{ accessToken,
headers 'text/plain'
},
'text'
) )
const currentState = state.trim() const currentState = state.trim()
@@ -1117,12 +1073,10 @@ export class SASViyaApiClient {
postedJobState === 'pending' postedJobState === 'pending'
) { ) {
if (stateLink) { if (stateLink) {
const { result: jobState } = await this.request<string>( const { result: jobState } = await this.requestClient.get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=30`, `${this.serverUrl}${stateLink.href}?_action=wait&wait=30`,
{ accessToken,
headers 'text/plain'
},
'text'
) )
postedJobState = jobState.trim() postedJobState = jobState.trim()
@@ -1165,17 +1119,10 @@ export class SASViyaApiClient {
) )
} }
const createFileRequest = { const uploadResponse = await this.requestClient.uploadFile(
method: 'POST',
body: csv,
headers
}
const uploadResponse = await this.request<any>(
`${this.serverUrl}/files/files#rawUpload`, `${this.serverUrl}/files/files#rawUpload`,
createFileRequest, csv,
'json', accessToken
'fileUpload'
) )
uploadedFiles.push({ tableName, file: uploadResponse.result }) uploadedFiles.push({ tableName, file: uploadResponse.result })
@@ -1190,16 +1137,10 @@ export class SASViyaApiClient {
const url = isUri(folderPath) const url = isUri(folderPath)
? folderPath ? folderPath
: `/folders/folders/@item?path=${folderPath}` : `/folders/folders/@item?path=${folderPath}`
const requestInfo: any = {
method: 'GET' const { result: folder } = await this.requestClient
} .get<Folder>(`${this.serverUrl}${url}`, accessToken)
if (accessToken) { .catch(() => {
requestInfo.headers = { Authorization: `Bearer ${accessToken}` }
}
const { result: folder } = await this.request<Folder>(
`${this.serverUrl}${url}`,
requestInfo
).catch((err) => {
return { result: null } return { result: null }
}) })
@@ -1217,20 +1158,12 @@ export class SASViyaApiClient {
private async getRecycleBinUri(accessToken: string) { private async getRecycleBinUri(accessToken: string) {
const url = '/folders/folders/@myRecycleBin' const url = '/folders/folders/@myRecycleBin'
const requestInfo = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken
}
}
const { result: folder } = await this.request<Folder>( const { result: folder } = await this.requestClient
`${this.serverUrl}${url}`, .get<Folder>(`${this.serverUrl}${url}`, accessToken)
requestInfo .catch(() => {
).catch((err) => { return { result: null }
return { result: null } })
})
if (!folder) return undefined if (!folder) return undefined
@@ -1291,27 +1224,16 @@ export class SASViyaApiClient {
} }
} }
const { result: members } = await this.request<{ items: any[] }>( const { result: members } = await this.requestClient.get<{ items: any[] }>(
`${this.serverUrl}${sourceFolderUri}/members?limit=${limit}`, `${this.serverUrl}${sourceFolderUri}/members?limit=${limit}`,
requestInfo accessToken
).catch((err) => { )
if (err.code && err.code === 'ENOTFOUND') {
const notFoundError = {
body: JSON.stringify({
message: `Folder '${sourceFolder.split('/').pop()}' was not found.`
})
}
throw notFoundError if (members && members.items) {
} return members.items.map((item: any) => item.name)
} else {
throw prefixMessage( return []
err, }
'There was an error while fetching folder children.'
)
})
return members.items.map((item: any) => item.name)
} }
/** /**
@@ -1353,35 +1275,31 @@ export class SASViyaApiClient {
const sourceFolderId = sourceFolderUri?.split('/').pop() const sourceFolderId = sourceFolderUri?.split('/').pop()
const requestInfo = { const { result: folder } = await this.requestClient
method: 'PATCH', .patch<Folder>(
headers: { `${this.serverUrl}${sourceFolderUri}`,
'Content-Type': 'application/json', {
Authorization: 'Bearer ' + accessToken id: sourceFolderId,
}, name: targetFolderName,
body: JSON.stringify({ parentFolderUri: targetParentFolderUri
id: sourceFolderId, },
name: targetFolderName, accessToken
parentFolderUri: targetParentFolderUri )
}) .catch((err) => {
} if (err.code && err.code === 'ENOTFOUND') {
const notFoundError = {
body: {
message: `Folder '${sourceFolder
.split('/')
.pop()}' was not found.`
}
}
const { result: folder } = await this.request<Folder>( throw notFoundError
`${this.serverUrl}${sourceFolderUri}`,
requestInfo
).catch((err) => {
if (err.code && err.code === 'ENOTFOUND') {
const notFoundError = {
body: JSON.stringify({
message: `Folder '${sourceFolder.split('/').pop()}' was not found.`
})
} }
throw notFoundError throw err
} })
throw err
})
if (!folder) return undefined if (!folder) return undefined
@@ -1409,42 +1327,4 @@ export class SASViyaApiClient {
return movedFolder return movedFolder
} }
setCsrfTokenLocal = (csrfToken: CsrfToken) => {
this.csrfToken = csrfToken
this.setCsrfToken(csrfToken)
}
setFileUploadCsrfToken = (csrfToken: CsrfToken) => {
this.fileUploadCsrfToken = csrfToken
}
private async request<T>(
url: string,
options: RequestInit,
contentType: 'text' | 'json' = 'json',
type: 'fileUpload' | 'other' = 'other'
) {
const callback =
type === 'fileUpload'
? this.setFileUploadCsrfToken
: this.setCsrfTokenLocal
if (type === 'other') {
if (this.csrfToken) {
options.headers = {
...options.headers,
[this.csrfToken.headerName]: this.csrfToken.value
}
}
} else {
if (this.fileUploadCsrfToken) {
options.headers = {
...options.headers,
[this.fileUploadCsrfToken.headerName]: this.fileUploadCsrfToken.value
}
}
}
return await makeRequest<T>(url, options, callback, contentType)
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { Session, Context, CsrfToken, SessionVariable } from './types' import { Session, Context, CsrfToken, SessionVariable } from './types'
import { asyncForEach, makeRequest, isUrl } from './utils' import { asyncForEach, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from './request/RequestClient'
const MAX_SESSION_COUNT = 1 const MAX_SESSION_COUNT = 1
const RETRY_LIMIT: number = 3 const RETRY_LIMIT: number = 3
@@ -14,14 +15,13 @@ export class SessionManager {
constructor( constructor(
private serverUrl: string, private serverUrl: string,
private contextName: string, private contextName: string,
private setCsrfToken: (csrfToken: CsrfToken) => void private requestClient: RequestClient
) { ) {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
} }
private sessions: Session[] = [] private sessions: Session[] = []
private currentContext: Context | null = null private currentContext: Context | null = null
private csrfToken: CsrfToken | null = null
private _debug: boolean = false private _debug: boolean = false
private printedSessionState = { private printedSessionState = {
printed: false, printed: false,
@@ -58,15 +58,8 @@ export class SessionManager {
} }
async clearSession(id: string, accessToken?: string) { async clearSession(id: string, accessToken?: string) {
const deleteSessionRequest = { return await this.requestClient
method: 'DELETE', .delete<Session>(`/compute/sessions/${id}`, accessToken)
headers: this.getHeaders(accessToken)
}
return await this.request<Session>(
`${this.serverUrl}/compute/sessions/${id}`,
deleteSessionRequest
)
.then(() => { .then(() => {
this.sessions = this.sessions.filter((s) => s.id !== id) this.sessions = this.sessions.filter((s) => s.id !== id)
}) })
@@ -98,17 +91,20 @@ export class SessionManager {
} }
private async createAndWaitForSession(accessToken?: string) { private async createAndWaitForSession(accessToken?: string) {
const createSessionRequest = { const {
method: 'POST', result: createdSession,
headers: this.getHeaders(accessToken) etag
} } = await this.requestClient
.post<Session>(
const { result: createdSession, etag } = await this.request<Session>( `${this.serverUrl}/compute/contexts/${
`${this.serverUrl}/compute/contexts/${this.currentContext!.id}/sessions`, this.currentContext!.id
createSessionRequest }/sessions`,
).catch((err) => { {},
throw err accessToken
}) )
.catch((err) => {
throw err
})
await this.waitForSession(createdSession, etag, accessToken) await this.waitForSession(createdSession, etag, accessToken)
@@ -119,13 +115,13 @@ export class SessionManager {
private async setCurrentContext(accessToken?: string) { private async setCurrentContext(accessToken?: string) {
if (!this.currentContext) { if (!this.currentContext) {
const { result: contexts } = await this.request<{ const { result: contexts } = await this.requestClient
items: Context[] .get<{
}>(`${this.serverUrl}/compute/contexts?limit=10000`, { items: Context[]
headers: this.getHeaders(accessToken) }>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken)
}).catch((err) => { .catch((err) => {
throw err throw err
}) })
const contextsList = const contextsList =
contexts && contexts.items && contexts.items.length contexts && contexts.items && contexts.items.length
@@ -166,10 +162,7 @@ export class SessionManager {
accessToken?: string accessToken?: string
) { ) {
let sessionState = session.state let sessionState = session.state
const headers: any = {
...this.getHeaders(accessToken),
'If-None-Match': etag
}
const stateLink = session.links.find((l: any) => l.rel === 'state') const stateLink = session.links.find((l: any) => l.rel === 'state')
return new Promise(async (resolve, _) => { return new Promise(async (resolve, _) => {
@@ -185,12 +178,10 @@ export class SessionManager {
this.printedSessionState.printed = true this.printedSessionState.printed = true
} }
const { result: state } = await this.requestSessionStatus<string>( const state = await this.getSessionState(
`${this.serverUrl}${stateLink.href}?wait=30`, `${this.serverUrl}${stateLink.href}?wait=30`,
{ etag!,
headers accessToken
},
'text'
).catch((err) => { ).catch((err) => {
throw err throw err
}) })
@@ -223,73 +214,33 @@ export class SessionManager {
}) })
} }
private async request<T>( private async getSessionState(
url: string, url: string,
options: RequestInit, etag: string,
contentType: 'text' | 'json' = 'json' accessToken?: string
) { ) {
if (this.csrfToken) { return await this.requestClient
options.headers = { .get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
...options.headers, .then((res) => res.result as string)
[this.csrfToken.headerName]: this.csrfToken.value .catch((err) => {
} if (err.status === INTERNAL_SAS_ERROR.status)
} return INTERNAL_SAS_ERROR.message
return await makeRequest<T>( throw err
url, })
options,
(token) => {
this.csrfToken = token
this.setCsrfToken(token)
},
contentType
).catch((err) => {
throw err
})
}
private async requestSessionStatus<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,
(token) => {
this.csrfToken = token
this.setCsrfToken(token)
},
contentType
).catch((err) => {
if (err.status === INTERNAL_SAS_ERROR.status)
return { result: INTERNAL_SAS_ERROR.message }
throw err
})
} }
async getVariable(sessionId: string, variable: string, accessToken?: string) { async getVariable(sessionId: string, variable: string, accessToken?: string) {
const getSessionVariable = { return await this.requestClient
method: 'GET', .get<SessionVariable>(
headers: this.getHeaders(accessToken) `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
} accessToken
return await this.request<SessionVariable>(
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
getSessionVariable
).catch((err) => {
throw prefixMessage(
err,
`Error while fetching session variable '${variable}'.`
) )
}) .catch((err) => {
throw prefixMessage(
err,
`Error while fetching session variable '${variable}'.`
)
})
} }
} }

7
src/__mocks__/axios.ts Normal file
View File

@@ -0,0 +1,7 @@
import { AxiosStatic } from 'axios'
const mockAxios = jest.genMockFromModule('axios') as AxiosStatic
mockAxios.create = jest.fn(() => mockAxios)
export default mockAxios

156
src/auth/AuthManager.ts Normal file
View File

@@ -0,0 +1,156 @@
import { ServerType } from '@sasjs/utils/types'
import { isAuthorizeFormRequired } from '.'
import { RequestClient } from '../request/RequestClient'
import { serialize } from '../utils'
export class AuthManager {
public userName = ''
private loginUrl: string
private logoutUrl: string
constructor(
private serverUrl: string,
private serverType: ServerType,
private requestClient: RequestClient,
private loginCallback: () => Promise<void>
) {
this.loginUrl = `/SASLogon/login`
this.logoutUrl =
this.serverType === ServerType.Sas9
? '/SASLogon/logout?'
: '/SASLogon/logout.do?'
}
/**
* Logs into the SAS server with the supplied credentials.
* @param username - a string representing the username.
* @param password - a string representing the password.
*/
public async logIn(username: string, password: string) {
const loginParams: any = {
_service: 'default',
username,
password
}
this.userName = loginParams.username
const { isLoggedIn, loginForm } = await this.checkSession()
if (isLoggedIn) {
await this.loginCallback()
return {
isLoggedIn,
userName: this.userName
}
}
for (const key in loginForm) {
loginParams[key] = loginForm[key]
}
const loginParamsStr = serialize(loginParams)
const { result: loginResponse } = await this.requestClient.post<string>(
this.loginUrl,
loginParamsStr,
undefined,
'text/plain',
{
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
)
let loggedIn = isLogInSuccess(loginResponse)
if (!loggedIn) {
const currentSession = await this.checkSession()
loggedIn = currentSession.isLoggedIn
}
if (loggedIn) {
this.loginCallback()
}
return {
isLoggedIn: !!loggedIn,
userName: this.userName
}
}
/**
* Checks whether a session is active, or login is required.
* @returns - a promise which resolves with an object containing two values - a boolean `isLoggedIn`, and a string `userName`.
*/
public async checkSession() {
const { result: loginResponse } = await this.requestClient.get<string>(
this.loginUrl.replace('.do', ''),
undefined,
'text/plain'
)
const responseText = loginResponse
const isLoggedIn = /<button.+onClick.+logout/gm.test(responseText)
let loginForm: any = null
if (!isLoggedIn) {
loginForm = await this.getLoginForm(responseText)
}
return Promise.resolve({
isLoggedIn,
userName: this.userName,
loginForm
})
}
private getLoginForm(response: any) {
const pattern: RegExp = /<form.+action="(.*Logon[^"]*).*>/
const matches = pattern.exec(response)
const formInputs: any = {}
if (matches && matches.length) {
this.setLoginUrl(matches)
const inputs = response.match(/<input.*"hidden"[^>]*>/g)
if (inputs) {
inputs.forEach((inputStr: string) => {
const valueMatch = inputStr.match(/name="([^"]*)"\svalue="([^"]*)/)
if (valueMatch && valueMatch.length) {
formInputs[valueMatch[1]] = valueMatch[2]
}
})
}
}
return Object.keys(formInputs).length ? formInputs : null
}
private setLoginUrl = (matches: RegExpExecArray) => {
let parsedURL = matches[1].replace(/\?.*/, '')
if (parsedURL[0] === '/') {
parsedURL = parsedURL.substr(1)
const tempLoginLink = this.serverUrl
? `${this.serverUrl}/${parsedURL}`
: `${parsedURL}`
const loginUrl = tempLoginLink
this.loginUrl =
this.serverType === ServerType.SasViya
? tempLoginLink
: loginUrl.replace('.do', '')
}
}
/**
* Logs out of the configured SAS server.
*/
public logOut() {
this.requestClient.clearCsrfTokens()
return this.requestClient.get(this.logoutUrl, undefined).then(() => true)
}
}
const isLogInSuccess = (response: string): boolean =>
/You have signed in/gm.test(response)

3
src/auth/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './AuthManager'
export * from './isAuthorizeFormRequired'
export * from './isLoginRequired'

View File

@@ -0,0 +1,217 @@
import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types'
import axios from 'axios'
import {
mockLoginAuthoriseRequiredResponse,
mockLoginSuccessResponse
} from './mockResponses'
import { serialize } from '../../utils'
import { RequestClient } from '../../request/RequestClient'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('AuthManager', () => {
const authCallback = jest.fn().mockImplementation(() => Promise.resolve())
const serverUrl = 'http://test-server.com'
const serverType = ServerType.SasViya
const userName = 'test-username'
const password = 'test-password'
const requestClient = new RequestClient(serverUrl)
beforeAll(() => {
dotenv.config()
jest.restoreAllMocks()
})
it('should instantiate and set the correct URLs for a Viya server', () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
expect(authManager).toBeTruthy()
expect((authManager as any).serverUrl).toEqual(serverUrl)
expect((authManager as any).serverType).toEqual(serverType)
expect((authManager as any).loginUrl).toEqual(`/SASLogon/login`)
expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout.do?')
})
it('should instantiate and set the correct URLs for a SAS9 server', () => {
const authCallback = () => Promise.resolve()
const serverType = ServerType.Sas9
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
expect(authManager).toBeTruthy()
expect((authManager as any).serverUrl).toEqual(serverUrl)
expect((authManager as any).serverType).toEqual(serverType)
expect((authManager as any).loginUrl).toEqual(`/SASLogon/login`)
expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout?')
})
it('should call the auth callback and return when already logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: true,
userName: 'test',
loginForm: 'test'
})
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1)
done()
})
it('should post a login request to the server if not logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
})
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
}
)
expect(authCallback).toHaveBeenCalledTimes(1)
done()
})
it('should parse and submit the authorisation form when necessary', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn(requestClient, 'authorize')
.mockImplementation(() => Promise.resolve())
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse,
config: { url: 'https://test.com/SASLogon/login' },
request: { responseURL: 'https://test.com/OAuth/authorize' }
})
)
mockedAxios.get.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse
})
)
await authManager.logIn(userName, password)
expect(requestClient.authorize).toHaveBeenCalledWith(
mockLoginAuthoriseRequiredResponse
)
done()
})
it('should check and return session information if logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: '<button onClick="logout">' })
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(mockedAxios.get).toHaveBeenNthCalledWith(1, `/SASLogon/login`, {
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
})
done()
})
it('should check and return session information if logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: '<button onClick="logout">' })
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(mockedAxios.get).toHaveBeenNthCalledWith(1, `/SASLogon/login`, {
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
})
done()
})
})

View File

@@ -0,0 +1,2 @@
export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
export const mockLoginSuccessResponse = `You have signed in`

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -0,0 +1,54 @@
import { ServerType } from '@sasjs/utils/types'
import { ErrorResponse } from '..'
import { SASViyaApiClient } from '../SASViyaApiClient'
import { ComputeJobExecutionError, LoginRequiredError } from '../types'
import { BaseJobExecutor } from './JobExecutor'
export class ComputeJobExecutor extends BaseJobExecutor {
constructor(serverUrl: string, private sasViyaApiClient: SASViyaApiClient) {
super(serverUrl, ServerType.SasViya)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const waitForResult = true
const expectWebout = true
return this.sasViyaApiClient
?.executeComputeJob(
sasJob,
config.contextName,
config.debug,
data,
accessToken,
waitForResult,
expectWebout
)
.then((response) => {
this.appendRequest(response, sasJob, config.debug)
let responseJson
return response.result
return responseJson
})
.catch(async (e: Error) => {
if (e instanceof ComputeJobExecutionError) {
this.appendRequest(e, sasJob, config.debug)
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() =>
this.execute(sasJob, data, config, loginRequiredCallback)
)
}
return Promise.reject(new ErrorResponse(e?.message, e))
})
}
}

View File

@@ -0,0 +1,40 @@
import { ServerType } from '@sasjs/utils/types'
import { ErrorResponse } from '..'
import { SASViyaApiClient } from '../SASViyaApiClient'
import { JobExecutionError, LoginRequiredError } from '../types'
import { BaseJobExecutor } from './JobExecutor'
export class JesJobExecutor extends BaseJobExecutor {
constructor(serverUrl: string, private sasViyaApiClient: SASViyaApiClient) {
super(serverUrl, ServerType.SasViya)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
return await this.sasViyaApiClient
?.executeJob(sasJob, config.contextName, config.debug, data, accessToken)
.then((response) => {
this.appendRequest(response, sasJob, config.debug)
return response.result
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
this.appendRequest(e, sasJob, config.debug)
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() =>
this.execute(sasJob, data, config, loginRequiredCallback)
)
}
return Promise.reject(new ErrorResponse(e?.message, e))
})
}
}

View File

@@ -0,0 +1,96 @@
import { ServerType } from '@sasjs/utils/types'
import { SASjsRequest } from '../types'
import { asyncForEach, parseGeneratedCode, parseSourceCode } from '../utils'
export type ExecuteFunction = () => Promise<any>
export interface JobExecutor {
execute: (
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string
) => Promise<any>
resendWaitingRequests: () => Promise<void>
getRequests: () => SASjsRequest[]
clearRequests: () => void
}
export abstract class BaseJobExecutor implements JobExecutor {
constructor(protected serverUrl: string, protected serverType: ServerType) {}
private waitingRequests: ExecuteFunction[] = []
private requests: SASjsRequest[] = []
abstract execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
accessToken?: string | undefined
): Promise<any>
resendWaitingRequests = async () => {
await asyncForEach(
this.waitingRequests,
async (waitingRequest: ExecuteFunction) => {
await waitingRequest()
}
)
this.waitingRequests = []
return
}
getRequests = () => this.requests
clearRequests = () => {
this.requests = []
}
protected appendWaitingRequest(request: ExecuteFunction) {
this.waitingRequests.push(request)
}
protected 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 = response.result.WORK
}
} else if (response?.result) {
sourceCode = parseSourceCode(response.result)
generatedCode = parseGeneratedCode(response.result)
sasWork = response.result.WORK
}
}
const stringifiedResult =
typeof response?.result === 'string'
? response?.result
: JSON.stringify(response?.result, null, 2)
this.requests.push({
logFile: response?.log || stringifiedResult || response,
serviceLink: program,
timestamp: new Date(),
sourceCode,
generatedCode,
SASWORK: sasWork
})
if (this.requests.length > 20) {
this.requests.splice(0, 1)
}
}
}

View File

@@ -0,0 +1,189 @@
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 { isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
export class WebJobExecutor extends BaseJobExecutor {
constructor(
serverUrl: string,
serverType: ServerType,
private jobsPath: string,
private requestClient: RequestClient,
private sasViyaApiClient: SASViyaApiClient
) {
super(serverUrl, serverType)
}
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)
.then(async (res) => {
if (this.serverType === ServerType.SasViya && config.debug) {
const jsonResponse = await this.parseSasViyaDebugResponse(
res.result as string
)
this.appendRequest(res, sasJob, config.debug)
return jsonResponse
}
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.appendWaitingRequest(() =>
this.execute(sasJob, data, config, loginRequiredCallback)
)
}
return Promise.reject(new ErrorResponse(e?.message, e))
})
}
private parseSasViyaDebugResponse = async (response: string) => {
const iframeStart = response.split(
'<iframe style="width: 99%; height: 500px" src="'
)[1]
const jsonUrl = iframeStart ? iframeStart.split('"></iframe>')[0] : null
if (!jsonUrl) {
throw new Error('Unable to find webout file URL.')
}
return this.requestClient
.get(this.serverUrl + jsonUrl, undefined)
.then((res) => res.result)
}
private async getJobUri(sasJob: string) {
if (!this.sasViyaApiClient) return ''
let uri = ''
let folderPath
let jobName: string
if (isRelativePath(sasJob)) {
const folderPathParts = sasJob.split('/')
folderPath = folderPathParts.length > 1 ? folderPathParts[0] : ''
jobName = folderPathParts.length > 1 ? folderPathParts[1] : ''
} else {
const folderPathParts = sasJob.split('/')
jobName = folderPathParts.pop() || ''
folderPath = folderPathParts.join('/')
}
if (!jobName) {
throw new Error('Job name is empty, null or undefined.')
}
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 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(', ')
}
}

View File

@@ -0,0 +1,4 @@
export * from './ComputeJobExecutor'
export * from './JesJobExecutor'
export * from './JobExecutor'
export * from './WebJobExecutor'

View File

@@ -0,0 +1,464 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { CsrfToken, JobExecutionError } from '..'
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
import { LoginRequiredError } from '../types'
import { AuthorizeError } from '../types/AuthorizeError'
import { NotFoundError } from '../types/NotFoundError'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
export interface HttpClient {
get<T>(
url: string,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
delete<T>(
url: string,
accessToken: string | undefined
): Promise<{ result: T; etag: string }>
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
clearCsrfTokens(): void
}
export class RequestClient implements HttpClient {
private csrfToken: CsrfToken = { headerName: '', value: '' }
private fileUploadCsrfToken: CsrfToken | undefined
private httpClient: AxiosInstance
constructor(private baseUrl: string, allowInsecure = false) {
const https = require('https')
if (allowInsecure && https.Agent) {
this.httpClient = axios.create({
baseURL: baseUrl,
httpsAgent: new https.Agent({
rejectUnauthorized: !allowInsecure
})
})
} else {
this.httpClient = axios.create({
baseURL: baseUrl
})
}
}
public getCsrfToken(type: 'general' | 'file' = 'general') {
return type === 'file' ? this.fileUploadCsrfToken : this.csrfToken
}
public clearCsrfTokens() {
this.csrfToken = { headerName: '', value: '' }
this.fileUploadCsrfToken = { headerName: '', value: '' }
}
public async get<T>(
url: string,
accessToken: string | undefined,
contentType: string = 'application/json',
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
}
const requestConfig: AxiosRequestConfig = {
headers,
responseType: contentType === 'text/plain' ? 'text' : 'json',
withCredentials: true
}
if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined
}
return this.httpClient
.get<T>(url, requestConfig)
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.get<T>(url, accessToken, contentType, overrideHeaders)
)
})
}
public post<T>(
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, contentType),
...overrideHeaders
}
return this.httpClient
.post<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.post<T>(url, data, accessToken, contentType, overrideHeaders)
)
})
}
public async put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number } = {}
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, 'application/json'),
...overrideHeaders
}
return this.httpClient
.put<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.put<T>(url, data, accessToken, overrideHeaders)
)
})
}
public async delete<T>(
url: string,
accessToken?: string
): Promise<{ result: T; etag: string }> {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.delete<T>(url, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () => this.delete<T>(url, accessToken))
})
}
public async patch<T>(
url: string,
data: any = {},
accessToken?: string
): Promise<{ result: T; etag: string }> {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.patch<T>(url, data, { headers, withCredentials: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.patch<T>(url, data, accessToken)
)
})
}
public async uploadFile(
url: string,
content: string,
accessToken?: string
): Promise<any> {
const headers = this.getHeaders(accessToken, 'application/json')
if (this.fileUploadCsrfToken?.value) {
headers[
this.fileUploadCsrfToken.headerName
] = this.fileUploadCsrfToken.value
}
try {
const response = await this.httpClient.post(url, content, { headers })
return {
result: response.data,
etag: response.headers['etag'] as string
}
} catch (e) {
const response = e.response as AxiosResponse
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetFileUploadCsrfToken(response)
if (this.fileUploadCsrfToken) {
return this.uploadFile(url, content, accessToken)
}
throw e
}
throw e
}
}
public authorize = async (response: string) => {
let authUrl: string | null = null
const params: any = {}
const responseBody = response.split('<body>')[1].split('</body>')[0]
const bodyElement = document.createElement('div')
bodyElement.innerHTML = responseBody
const form = bodyElement.querySelector('#application_authorization')
authUrl = form ? this.baseUrl + form.getAttribute('action') : null
const inputs: any = form?.querySelectorAll('input')
for (const input of inputs) {
if (input.name === 'user_oauth_approval') {
input.value = 'true'
}
params[input.name] = input.value
}
const csrfTokenKey = Object.keys(params).find((k) =>
k?.toLowerCase().includes('csrf')
)
if (csrfTokenKey) {
this.csrfToken.value = params[csrfTokenKey]
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
}
const formData = new FormData()
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key])
}
}
if (!authUrl) {
throw new Error('Auth Form URL is null or undefined.')
}
return await this.httpClient
.post(authUrl, formData, {
responseType: 'text',
headers: { Accept: '*/*', 'Content-Type': 'text/plain' }
})
.then((res) => res.data)
.catch((error) => {
console.log(error)
})
}
private getHeaders = (
accessToken: string | undefined,
contentType: string
) => {
const headers: any = {}
if (contentType !== 'application/x-www-form-urlencoded') {
headers['Content-Type'] = contentType
}
if (contentType === 'application/json') {
headers.Accept = 'application/json'
} else {
headers.Accept = '*/*'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
if (this.csrfToken.headerName && this.csrfToken.value) {
headers[this.csrfToken.headerName] = this.csrfToken.value
}
return headers
}
private parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
this.fileUploadCsrfToken = token
}
}
private parseAndSetCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
this.csrfToken = token
}
}
private parseCsrfToken = (response: AxiosResponse): CsrfToken | undefined => {
const tokenHeader = (response.headers[
'x-csrf-header'
] as string)?.toLowerCase()
if (tokenHeader) {
const token = response.headers[tokenHeader]
const csrfToken = {
headerName: tokenHeader,
value: token || ''
}
return csrfToken
}
}
private handleError = async (e: any, callback: any) => {
const response = e.response as AxiosResponse
if (e instanceof AuthorizeError) {
const res = await this.httpClient.get(e.confirmUrl, {
responseType: 'text',
headers: { 'Content-Type': 'text/plain', Accept: '*/*' }
})
if (isAuthorizeFormRequired(res?.data as string)) {
await this.authorize(res.data as string)
}
return await callback()
}
if (e instanceof LoginRequiredError) {
this.clearCsrfTokens()
}
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetCsrfToken(response)
if (this.csrfToken.headerName && this.csrfToken.value) {
return await callback()
}
throw e
} else if (response?.status === 404) {
throw new NotFoundError(response.config.url!)
}
throw e
}
private async parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse
try {
if (typeof response.data === 'string') {
parsedResponse = JSON.parse(response.data)
} else {
parsedResponse = response.data
}
} catch {
try {
parsedResponse = JSON.parse(parseWeboutResponse(response.data))
} catch {
parsedResponse = response.data
}
}
return {
result: parsedResponse as T,
etag
}
}
}
const throwIfError = (response: AxiosResponse) => {
if (response.status === 401) {
throw new LoginRequiredError()
}
if (response.data?.entityID?.includes('login')) {
throw new LoginRequiredError()
}
if (
typeof response.data === 'string' &&
isAuthorizeFormRequired(response.data)
) {
throw new AuthorizeError(
'Authorization required',
response.request.responseURL
)
}
if (
typeof response.data === 'string' &&
isLogInRequired(response.data) &&
!response.config?.url?.includes('/SASLogon/login')
) {
throw new LoginRequiredError()
}
if (response.data?.auth_request) {
const authorizeRequestUrl = response.request.responseURL
throw new AuthorizeError(response.data.message, authorizeRequestUrl)
}
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
}
try {
const hasError = !!data?.match(/stored process not found: /i)
if (hasError) {
const parts = data.split(/stored process not found: /i)
if (parts.length > 1) {
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
const message = `Stored process not found: ${storedProcessPath}`
return new JobExecutionError(404, message, '')
}
}
} catch (_) {
return null
}
} catch (_) {
return null
}
}
}

View File

@@ -1,34 +1,16 @@
import { ContextManager } from '../ContextManager' import { ContextManager } from '../ContextManager'
import { RequestClient } from '../request/RequestClient'
import * as dotenv from 'dotenv'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('ContextManager', () => { describe('ContextManager', () => {
let originalFetch: any dotenv.config()
let fetchCallNumber = 0
const fakeGlobalFetch = (fakeResponses: object[]) => {
;(global as any).fetch = jest.fn().mockImplementation(() => {
const fakeResponse = fakeResponses[fetchCallNumber]
if (
fetchCallNumber !== fakeResponses.length &&
fakeResponses.length > 1
) {
if (fetchCallNumber + 1 === fakeResponses.length) fetchCallNumber = 0
else fetchCallNumber += 1
} else {
fetchCallNumber = 0
}
return Promise.resolve({
ok: true,
headers: { get: () => '' },
json: () => Promise.resolve(fakeResponse)
})
})
}
const contextManager = new ContextManager( const contextManager = new ContextManager(
process.env.SERVER_URL as string, process.env.SERVER_URL as string,
() => {} new RequestClient(process.env.SERVER_URL as string)
) )
const defaultComputeContexts = contextManager.getDefaultComputeContexts const defaultComputeContexts = contextManager.getDefaultComputeContexts
@@ -43,14 +25,6 @@ describe('ContextManager', () => {
Math.floor(Math.random() * defaultLauncherContexts.length) Math.floor(Math.random() * defaultLauncherContexts.length)
] ]
beforeAll(() => {
originalFetch = (global as any).fetch
})
afterEach(() => {
;(global as any).fetch = originalFetch
})
describe('getComputeContexts', () => { describe('getComputeContexts', () => {
it('should fetch compute contexts', async () => { it('should fetch compute contexts', async () => {
const sampleComputeContext = { const sampleComputeContext = {
@@ -65,7 +39,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([sampleResponse]) mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect(contextManager.getComputeContexts()).resolves.toEqual([ await expect(contextManager.getComputeContexts()).resolves.toEqual([
sampleComputeContext sampleComputeContext
@@ -87,7 +63,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([sampleResponse]) mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect(contextManager.getLauncherContexts()).resolves.toEqual([ await expect(contextManager.getLauncherContexts()).resolves.toEqual([
sampleComputeContext sampleComputeContext
@@ -137,7 +115,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([sampleResponse]) mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect( await expect(
contextManager.createComputeContext( contextManager.createComputeContext(
@@ -176,10 +156,13 @@ describe('ContextManager', () => {
items: [sampleNewComputeContext] items: [sampleNewComputeContext]
} }
fakeGlobalFetch([ mockedAxios.get.mockImplementation(() =>
sampleResponseExistingComputeContexts, Promise.resolve({ data: sampleResponseExistingComputeContexts })
sampleResponseCreatedComputeContext )
])
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedComputeContext })
)
await expect( await expect(
contextManager.createComputeContext( contextManager.createComputeContext(
@@ -226,10 +209,13 @@ describe('ContextManager', () => {
items: [sampleNewComputeContext] items: [sampleNewComputeContext]
} }
fakeGlobalFetch([ mockedAxios.get.mockImplementation(() =>
sampleResponseExistingComputeContexts, Promise.resolve({ data: sampleResponseExistingComputeContexts })
sampleResponseCreatedComputeContext )
])
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedComputeContext })
)
await expect( await expect(
contextManager.createComputeContext( contextManager.createComputeContext(
@@ -287,11 +273,16 @@ describe('ContextManager', () => {
items: [sampleNewComputeContext] items: [sampleNewComputeContext]
} }
fakeGlobalFetch([ mockedAxios.get
sampleResponseExistingComputeContexts, .mockImplementationOnce(() =>
sampleResponseCreatedLauncherContext, Promise.resolve({ data: sampleResponseExistingComputeContexts })
sampleResponseCreatedComputeContext )
]) .mockImplementationOnce(() =>
Promise.resolve({ data: sampleResponseCreatedLauncherContext })
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedComputeContext })
)
await expect( await expect(
contextManager.createComputeContext( contextManager.createComputeContext(
@@ -346,7 +337,9 @@ describe('ContextManager', () => {
items: [sampleLauncherContext] items: [sampleLauncherContext]
} }
fakeGlobalFetch([sampleResponse]) mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
await expect( await expect(
contextManager.createLauncherContext(contextName, 'Test Description') contextManager.createLauncherContext(contextName, 'Test Description')
@@ -380,10 +373,13 @@ describe('ContextManager', () => {
items: [sampleNewLauncherContext] items: [sampleNewLauncherContext]
} }
fakeGlobalFetch([ mockedAxios.get.mockImplementation(() =>
sampleResponseExistingLauncherContext, Promise.resolve({ data: sampleResponseExistingLauncherContext })
sampleResponseCreatedLauncherContext )
])
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponseCreatedLauncherContext })
)
await expect( await expect(
contextManager.createLauncherContext(contextName, 'Test Description') contextManager.createLauncherContext(contextName, 'Test Description')
@@ -448,7 +444,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([sampleResponseGetComputeContextByName]) mockedAxios.put.mockImplementation(() =>
Promise.resolve({ data: sampleResponseGetComputeContextByName })
)
const expectedResponse = { const expectedResponse = {
etag: '', etag: '',
@@ -475,7 +473,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([sampleResponse]) mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const user = 'testUser' const user = 'testUser'
@@ -508,7 +508,9 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([sampleResponse]) mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const fakedExecuteScript = async () => { const fakedExecuteScript = async () => {
return Promise.resolve({ log: '' }) return Promise.resolve({ log: '' })
@@ -567,10 +569,13 @@ describe('ContextManager', () => {
items: [sampleComputeContext] items: [sampleComputeContext]
} }
fakeGlobalFetch([ mockedAxios.get.mockImplementation(() =>
sampleResponseGetComputeContextByName, Promise.resolve({ data: sampleResponseGetComputeContextByName })
sampleResponseDeletedContext )
])
mockedAxios.delete.mockImplementation(() =>
Promise.resolve({ data: sampleResponseDeletedContext })
)
const expectedResponse = { const expectedResponse = {
etag: '', etag: '',

View File

@@ -1,5 +1,9 @@
import { FileUploader } from '../FileUploader' import { FileUploader } from '../FileUploader'
import { UploadFile } from '../types' import { UploadFile } from '../types'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
const sampleResponse = `{ const sampleResponse = `{
"SYSUSERID": "cas", "SYSUSERID": "cas",
@@ -24,39 +28,22 @@ const prepareFilesAndParams = () => {
} }
describe('FileUploader', () => { describe('FileUploader', () => {
let originalFetch: any
const fileUploader = new FileUploader( const fileUploader = new FileUploader(
'/sample/apploc', '/sample/apploc',
'https://sample.server.com', 'https://sample.server.com',
'/jobs/path', '/jobs/path',
null, new RequestClient('https://sample.server.com')
null
) )
beforeAll(() => {
originalFetch = (global as any).fetch
})
beforeEach(() => {
;(global as any).fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
text: () => Promise.resolve(sampleResponse)
})
)
})
afterAll(() => {
;(global as any).fetch = originalFetch
})
it('should upload successfully', async (done) => { it('should upload successfully', async (done) => {
const sasJob = 'test/upload' const sasJob = 'test/upload'
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
fileUploader.uploadFile(sasJob, files, params).then((res: any) => { fileUploader.uploadFile(sasJob, files, params).then((res: any) => {
expect(JSON.stringify(res)).toEqual( expect(res).toEqual(JSON.parse(sampleResponse))
JSON.stringify(JSON.parse(sampleResponse))
)
done() done()
}) })
}) })
@@ -83,10 +70,8 @@ describe('FileUploader', () => {
}) })
it('should throw an error when login is required', async (done) => { it('should throw an error when login is required', async (done) => {
;(global as any).fetch = jest.fn().mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ Promise.resolve({ data: '<form action="Logon">' })
text: () => Promise.resolve('<form action="Logon">')
})
) )
const sasJob = 'test' const sasJob = 'test'
@@ -101,35 +86,29 @@ describe('FileUploader', () => {
}) })
it('should throw an error when invalid JSON is returned by the server', async (done) => { it('should throw an error when invalid JSON is returned by the server', async (done) => {
;(global as any).fetch = jest.fn().mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ Promise.resolve({ data: '{invalid: "json"' })
text: () => Promise.resolve('{invalid: "json"')
})
) )
const sasJob = 'test' const sasJob = 'test'
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => { fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
expect(err.error.message).toEqual( expect(err.error.message).toEqual('File upload request failed.')
'Error while parsing json from upload response.'
)
done() done()
}) })
}) })
it('should throw an error when the server request fails', async (done) => { it('should throw an error when the server request fails', async (done) => {
;(global as any).fetch = jest.fn().mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ Promise.reject({ data: '{message: "Server error"}' })
text: () => Promise.reject('{message: "Server error"}')
})
) )
const sasJob = 'test' const sasJob = 'test'
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => { fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
expect(err.error.message).toEqual('Upload request failed.') expect(err.error.message).toEqual('File upload request failed.')
done() done()
}) })

View File

@@ -1,25 +1,19 @@
import { SessionManager } from '../SessionManager' import { SessionManager } from '../SessionManager'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('SessionManager', () => { describe('SessionManager', () => {
dotenv.config() dotenv.config()
let originalFetch: any
const sessionManager = new SessionManager( const sessionManager = new SessionManager(
process.env.SERVER_URL as string, process.env.SERVER_URL as string,
process.env.DEFAULT_COMPUTE_CONTEXT as string, process.env.DEFAULT_COMPUTE_CONTEXT as string,
() => {} new RequestClient('https://sample.server.com')
) )
beforeAll(() => {
originalFetch = (global as any).fetch
})
afterEach(() => {
;(global as any).fetch = originalFetch
})
describe('getVariable', () => { describe('getVariable', () => {
it('should fetch session variable', async () => { it('should fetch session variable', async () => {
const sampleResponse = { const sampleResponse = {
@@ -31,12 +25,8 @@ describe('SessionManager', () => {
version: 1 version: 1
} }
;(global as any).fetch = jest.fn().mockImplementation(() => mockedAxios.get.mockImplementation(() =>
Promise.resolve({ Promise.resolve({ data: sampleResponse })
ok: true,
headers: { get: () => '' },
json: () => Promise.resolve(sampleResponse)
})
) )
const expectedResponse = { etag: '', result: sampleResponse } const expectedResponse = { etag: '', result: sampleResponse }

View File

@@ -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)
}
}

View File

@@ -0,0 +1,9 @@
import { Job } from './Job'
export class ComputeJobExecutionError extends Error {
constructor(public job: Job, public log: string) {
super('Error: Job execution failed')
this.name = 'ComputeJobExecutionError'
Object.setPrototypeOf(this, ComputeJobExecutionError.prototype)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,7 @@
export class NotFoundError extends Error {
constructor(public url: string) {
super(`Error: Resource at ${url} was not found`)
this.name = 'NotFoundError'
Object.setPrototypeOf(this, NotFoundError.prototype)
}
}

View File

@@ -1,4 +1,4 @@
import { ServerType } from './ServerType' import { ServerType } from '@sasjs/utils/types'
/** /**
* Specifies the configuration for the SASjs instance - eg where and how to * Specifies the configuration for the SASjs instance - eg where and how to
@@ -57,4 +57,10 @@ export class SASjsConfig {
* triggered using the APIs instead of the Job Execution Web Service broker. * triggered using the APIs instead of the Job Execution Web Service broker.
*/ */
useComputeApi = false useComputeApi = false
/**
* Defaults to `false`.
* When set to `true`, the adapter will allow requests to SAS servers that use a self-signed SSL certificate.
* Changing this setting is not recommended.
*/
allowInsecureRequests = false
} }

View File

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

View File

@@ -1,8 +0,0 @@
/**
* Server type that can be `Viya` or `SAS9`.
*
*/
export enum ServerType {
SASViya = 'SASVIYA',
SAS9 = 'SAS9'
}

View File

@@ -1,15 +1,16 @@
export * from './ComputeJobExecutionError'
export * from './Context' export * from './Context'
export * from './CsrfToken' export * from './CsrfToken'
export * from './ErrorResponse' export * from './ErrorResponse'
export * from './Folder' export * from './Folder'
export * from './Job' export * from './Job'
export * from './JobExecutionError'
export * from './JobDefinition' export * from './JobDefinition'
export * from './JobResult' export * from './JobResult'
export * from './Link' export * from './Link'
export * from './LoginRequiredError'
export * from './SASjsConfig' export * from './SASjsConfig'
export * from './SASjsRequest' export * from './SASjsRequest'
export * from './SASjsWaitingRequest'
export * from './ServerType'
export * from './Session' export * from './Session'
export * from './UploadFile' export * from './UploadFile'
export * from './PollOptions' export * from './PollOptions'

View File

@@ -1,15 +1,10 @@
export * from './asyncForEach' export * from './asyncForEach'
export * from './compareTimestamps' export * from './compareTimestamps'
export * from './convertToCsv' export * from './convertToCsv'
export * from './isAuthorizeFormRequired'
export * from './isLoginRequired'
export * from './isLoginSuccess'
export * from './isRelativePath' export * from './isRelativePath'
export * from './isUri' export * from './isUri'
export * from './isUrl' export * from './isUrl'
export * from './makeRequest'
export * from './needsRetry' export * from './needsRetry'
export * from './parseAndSubmitAuthorizeForm'
export * from './parseGeneratedCode' export * from './parseGeneratedCode'
export * from './parseSourceCode' export * from './parseSourceCode'
export * from './parseSasViyaLog' export * from './parseSasViyaLog'

View File

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

View File

@@ -1,154 +0,0 @@
import { CsrfToken } from '../types'
import { needsRetry } from './needsRetry'
let retryCount: number = 0
const retryLimit: number = 5
export async function makeRequest<T>(
url: string,
request: RequestInit,
callback: (value: CsrfToken) => any,
contentType: 'text' | 'json' = 'json'
): Promise<{ result: T; etag: string | null }> {
let retryRequest: any = null
const responseTransform =
contentType === 'json'
? (res: Response) => res.json()
: (res: Response) => res.text()
let etag = null
const result = await fetch(url, request)
.then(async (response) => {
if (response.redirected && response.url.includes('SASLogon/login')) {
return Promise.reject({ status: 401 })
}
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 || ''
})
retryRequest = {
...request,
headers: { ...request.headers, [tokenHeader]: token }
}
return await fetch(url, retryRequest).then((res) => {
etag = res.headers.get('ETag')
return responseTransform(res)
})
} else {
let body: any = await response.text().catch((err) => {
throw err
})
try {
body = JSON.parse(body)
body.message = `Forbidden. Check your permissions and user groups, and also the scopes granted when registering your CLIENT_ID. ${
body.message || ''
}`
body = JSON.stringify(body)
} catch (_) {}
return Promise.reject({ status: response.status, body })
}
} else {
let body: any = await response.text().catch((err) => {
throw err
})
if (needsRetry(body)) {
if (retryCount < retryLimit) {
retryCount++
let retryResponse = await makeRequest(
url,
retryRequest || request,
callback,
contentType
).catch((err) => {
throw err
})
retryCount = 0
etag = retryResponse.etag
return retryResponse.result
} else {
retryCount = 0
throw new Error('Request retry limit exceeded')
}
}
if (response.status === 401) {
try {
body = JSON.parse(body)
body.message = `Unauthorized request. Check your credentials(client, secret, access token). ${
body.message || ''
}`
body = JSON.stringify(body)
} catch (_) {}
}
return Promise.reject({ status: response.status, body })
}
} else {
if (response.status === 204) {
return Promise.resolve()
}
const responseTransformed = await responseTransform(response).catch(
(err) => {
throw err
}
)
let responseText = ''
if (typeof responseTransformed === 'string') {
responseText = responseTransformed
} else {
responseText = JSON.stringify(responseTransformed)
}
if (needsRetry(responseText)) {
if (retryCount < retryLimit) {
retryCount++
const retryResponse = await makeRequest(
url,
retryRequest || request,
callback,
contentType
).catch((err) => {
throw err
})
retryCount = 0
etag = retryResponse.etag
return retryResponse.result
} else {
retryCount = 0
throw new Error('Request retry limit exceeded')
}
}
etag = response.headers.get('ETag')
return responseTransformed
}
})
.catch((err) => {
throw err
})
return { result, etag }
}

View File

@@ -1,49 +0,0 @@
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

@@ -26,7 +26,8 @@ const browserConfig = {
] ]
}, },
resolve: { resolve: {
extensions: ['.ts', '.js'] extensions: ['.ts', '.js'],
fallback: { https: false }
}, },
output: { output: {
filename: 'index.js', filename: 'index.js',