1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-19 20:34:36 +00:00

Compare commits

..

1 Commits

Author SHA1 Message Date
sabir_hassan
591e1ebc09 fix: callback type checking fixes #304 2021-05-27 21:59:40 +05:00
47 changed files with 2748 additions and 26670 deletions

View File

@@ -6,7 +6,7 @@ GREEN="\033[1;32m"
# temporary file which holds the message). # temporary file which holds the message).
commit_message=$(cat "$1") commit_message=$(cat "$1")
if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 \-\*]+\))?!?: .+$") then if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z \-]+\))?!?: .+$") then
echo "${GREEN} ✔ Commit message meets Conventional Commit standards" echo "${GREEN} ✔ Commit message meets Conventional Commit standards"
exit 0 exit 0
fi fi

View File

@@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

View File

@@ -7,8 +7,3 @@ groups:
- saadjutt01 - saadjutt01
- medjedovicm - medjedovicm
- allanbowe - allanbowe
- sabhas
- name: SASjs QA
reviewers: 1
usernames:
- VladislavParhomchik

View File

@@ -12,8 +12,6 @@ What code changes have been made to achieve the intent.
## Checks ## Checks
No PR (that involves a non-trivial code change) should be merged, unless all four of the items below are confirmed! If an urgent fix is needed - use a tar file.
- [ ] Code is formatted correctly (`npm run lint:fix`). - [ ] Code is formatted correctly (`npm run lint:fix`).
- [ ] All unit tests are passing (`npm test`). - [ ] All unit tests are passing (`npm test`).
- [ ] All `sasjs-cli` unit tests are passing (`npm test`). - [ ] All `sasjs-cli` unit tests are passing (`npm test`).

View File

@@ -172,43 +172,35 @@ Configuration on the client side involves passing an object on startup, which ca
* `serverType` - either `SAS9` or `SASVIYA`. * `serverType` - either `SAS9` or `SASVIYA`.
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode. * `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned. * `debug` - if `true` then SAS Logs and extra debug information is returned.
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used. * `useComputeApi` - if `true` and the serverType is `SASVIYA` then the REST APIs will be called directly (rather than using the JES web service).
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`. * `contextName` - if missing or blank, and `useComputeApi` is `true` and `serverType` is `SASVIYA` then the JES API will be used.
The adapter supports a number of approaches for interfacing with Viya (`serverType` is `SASVIYA`). For maximum performance, be sure to [configure your compute context](https://sasjs.io/guide-viya/#shared-account-and-server-re-use) with `reuseServerProcesses` as `true` and a system account in `runServerAs`. This functionality is available since Viya 3.5. This configuration is supported when [creating contexts using the CLI](https://sasjs.io/sasjs-cli-context/#sasjs-context-create). The adapter supports a number of approaches for interfacing with Viya (`serverType` is `SASVIYA`). For maximum performance, be sure to [configure your compute context](https://sasjs.io/guide-viya/#shared-account-and-server-re-use) with `reuseServerProcesses` as `true` and a system account in `runServerAs`. This functionality is available since Viya 3.5. This configuration is supported when [creating contexts using the CLI](https://sasjs.io/sasjs-cli-context/#sasjs-context-create).
### Using JES Web App ### Using JES Web App
In this setup, all requests are routed through the JES web app, at `YOURSERVER/SASJobExecution?_program=/your/program`. This is the most reliable method, and also the slowest. One request is made to the JES app, and remaining requests (getting job uri, session spawning, passing parameters, running the program, fetching the log) are handled by the SAS server inside the JES app. In this setup, all requests are routed through the JES web app, at `YOURSERVER/SASJobExecution`. This is the most reliable method, and also the slowest. One request is made to the JES app, and remaining requests (getting job uri, session spawning, passing parameters, running the program, fetching the log) are made on the SAS server by the JES app.
``` ```
{ {
appLoc:"/Your/Path", appLoc:"/Your/Path",
serverType:"SASVIYA", serverType:"SASVIYA"
contextName: 'yourComputeContext'
} }
``` ```
Note - to use the web approach, the `useComputeApi` property must be `undefined` or `null`.
### Using the JES API ### Using the JES API
Here we are running Jobs using the Job Execution Service except this time we are making the requests directly using the REST API instead of through the JES Web App. This is helpful when we need to call web services outside of a browser (eg with the SASjs CLI or other commandline tools). To save one network request, the adapter prefetches the JOB URIs and passes them in the `__job` parameter. Depending on your network bandwidth, it may or may not be faster than the JES Web approach. Here we are running Jobs using the Job Execution Service except this time we are making the requests directly using the REST API instead of through the JES Web App. This is helpful when we need to call web services outside of a browser (eg with the SASjs CLI or other commandline tools). To save one network request, the adapter prefetches the JOB URIs and passes them in the `__job` parameter.
This approach (`useComputeApi: false`) also ensures that jobs are displayed in Environment Manager.
``` ```
{ {
appLoc:"/Your/Path", appLoc:"/Your/Path",
serverType:"SASVIYA", serverType:"SASVIYA",
useComputeApi: false, useComputeApi: true
contextName: 'yourComputeContext'
} }
``` ```
### Using the Compute API ### Using the Compute API
This approach is by far the fastest, as a result of the optimisations we have built into the adapter. With this configuration, in the first sasjs request, we take a URI map of the services in the target folder, and create a session manager. This manager will spawn a additional session every time a request is made. Subsequent requests will use the existing 'hot' session, if it exists. Sessions are always deleted after every use, which actually makes this _less_ resource intensive than a typical JES web app, in which all sessions are kept alive by default for 15 minutes. This approach is by far the fastest, as a result of the optimisations we have built into the adapter. With this configuration, in the first sasjs request, we take a URI map of the services in the target folder, and create a session manager - which spawns an extra session. The next time a request is made, the adapter will use the 'hot' session. Sessions are deleted after every use, which actually makes this _less_ resource intensive than a typical JES web app, in which all sessions are kept alive by default for 15 minutes.
With this approach (`useComputeApi: true`), the requests/logs will _not_ appear in the list in Environment manager.
``` ```
{ {

5660
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,17 @@
"description": "JavaScript adapter for SAS", "description": "JavaScript adapter for SAS",
"homepage": "https://adapter.sasjs.io", "homepage": "https://adapter.sasjs.io",
"scripts": { "scripts": {
"build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && rimraf build/src && rimraf node", "build": "rimraf build && rimraf node && mkdir node && cp -r src/* node && webpack && rimraf build/src && rimraf node",
"package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack", "package:lib": "npm run build && cp ./package.json build && cd build && npm version \"5.0.0\" && npm pack",
"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}\" && npx prettier --write \"sasjs-tests/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}' && npx prettier --write 'sasjs-tests/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}\" && npx prettier --check \"sasjs-tests/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}' && npx prettier --check 'sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"test": "jest --silent --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",
"typedoc": "typedoc", "typedoc": "typedoc",
"prepare": "git rev-parse --git-dir && git config core.hooksPath ./.git-hooks && git config core.autocrlf false || true" "postinstall": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -38,40 +38,31 @@
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/jest": "^26.0.23", "@types/jest": "^26.0.22",
"@types/mime": "^2.0.3",
"@types/tough-cookie": "^4.0.0",
"copyfiles": "^2.4.1",
"cp": "^0.2.0", "cp": "^0.2.0",
"dotenv": "^10.0.0", "dotenv": "^8.2.0",
"jest": "^27.0.6", "jest": "^26.6.3",
"jest-extended": "^0.11.5", "jest-extended": "^0.11.5",
"mime": "^2.5.2",
"node-polyfill-webpack-plugin": "^1.1.4",
"path": "^0.12.7", "path": "^0.12.7",
"process": "^0.11.10",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"semantic-release": "^17.4.4", "semantic-release": "^17.4.2",
"terser-webpack-plugin": "^5.1.4", "terser-webpack-plugin": "^4.2.3",
"ts-jest": "^27.0.3", "ts-jest": "^25.5.1",
"ts-loader": "^9.2.2", "ts-loader": "^9.1.2",
"tslint": "^6.1.3", "tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0", "tslint-config-prettier": "^1.18.0",
"typedoc": "^0.21.2", "typedoc": "^0.20.35",
"typedoc-neo-theme": "^1.1.1", "typedoc-neo-theme": "^1.1.0",
"typedoc-plugin-external-module-name": "^4.0.6", "typedoc-plugin-external-module-name": "^4.0.6",
"typescript": "^4.3.4", "typescript": "^3.9.9",
"webpack": "^5.41.1", "webpack": "^5.33.2",
"webpack-cli": "^4.7.2" "webpack-cli": "^4.7.0"
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@sasjs/utils": "^2.23.2", "@sasjs/utils": "^2.10.2",
"axios": "^0.21.1", "axios": "^0.21.1",
"axios-cookiejar-support": "^1.0.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"https": "^1.0.0", "https": "^1.0.0"
"tough-cookie": "^4.0.0",
"url": "^0.11.0"
} }
} }

View File

@@ -6,7 +6,7 @@ When developing on `@sasjs/adapter`, it's good practice to run the test suite ag
You can use the provided `update:adapter` NPM script for this. You can use the provided `update:adapter` NPM script for this.
```bash ```
npm run update:adapter npm run update:adapter
``` ```
@@ -37,7 +37,7 @@ To be able to run the `deploy` script, two environment variables need to be set:
So you can run the script like so: So you can run the script like so:
```bash ```
SSH_ACCOUNT=me@my-sas-server.com DEPLOY_PATH=/var/www/html/my-folder/sasjs-tests npm run deploy SSH_ACCOUNT=me@my-sas-server.com DEPLOY_PATH=/var/www/html/my-folder/sasjs-tests npm run deploy
``` ```
@@ -49,7 +49,8 @@ The below services need to be created on your SAS server, at the location specif
### SAS 9 ### SAS 9
```sas ```
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas"; filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc; %inc mc;
filename ft15f001 temp; filename ft15f001 temp;
@@ -71,24 +72,11 @@ parmcards4;
%webout(CLOSE) %webout(CLOSE)
;;;; ;;;;
%mm_createwebservice(path=/Public/app/common,name=sendArr) %mm_createwebservice(path=/Public/app/common,name=sendArr)
parmcards4;
let he who hath understanding, reckon the number of the beast
;;;;
%mm_createwebservice(path=/Public/app/common,name=makeErr)
parmcards4;
%webout(OPEN)
data _null_;
file _webout;
put ' the discovery channel ';
run;
%webout(CLOSE)
;;;;
%mm_createwebservice(path=/Public/app/common,name=invalidJSON)
``` ```
### SAS Viya ### SAS Viya
```sas ```
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas"; filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc; %inc mc;
filename ft15f001 temp; filename ft15f001 temp;
@@ -127,15 +115,6 @@ If you can trust yourself when all men doubt you,
But make allowance for their doubting too; But make allowance for their doubting too;
;;;; ;;;;
%mp_createwebservice(path=/Public/app/common,name=makeErr) %mp_createwebservice(path=/Public/app/common,name=makeErr)
parmcards4;
%webout(OPEN)
data _null_;
file _webout;
put ' the discovery channel ';
run;
%webout(CLOSE)
;;;;
%mp_createwebservice(path=/Public/app/common,name=invalidJSON)
``` ```
You should now be able to access the tests in your browser at the deployed path on your server. You should now be able to access the tests in your browser at the deployed path on your server.

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz", "@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
"@sasjs/test-framework": "^1.4.0", "@sasjs/test-framework": "^1.4.0",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/node": "^14.14.41", "@types/node": "^14.14.25",
"@types/react": "^17.0.1", "@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",

View File

@@ -13,19 +13,14 @@ const App = (): ReactElement<{}> => {
useEffect(() => { useEffect(() => {
if (adapter) { if (adapter) {
const testSuites = [ setTestSuites([
basicTests(adapter, config.userName, config.password), basicTests(adapter, config.userName, config.password),
sendArrTests(adapter), sendArrTests(adapter),
sendObjTests(adapter), sendObjTests(adapter),
specialCaseTests(adapter), specialCaseTests(adapter),
sasjsRequestTests(adapter) sasjsRequestTests(adapter),
] computeTests(adapter)
])
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
testSuites.push(computeTests(adapter))
}
setTestSuites(testSuites)
} }
}, [adapter, config]) }, [adapter, config])

View File

@@ -145,29 +145,6 @@ export const basicTests = (
sasjsConfig.debug === false sasjsConfig.debug === false
) )
} }
},
{
title: 'Request with extra attributes on JES approach',
description:
'Should complete successful request with extra attributes present in response',
test: async () => {
const config = {
useComputeApi: false
}
return await adapter.request(
'common/sendArr',
stringData,
config,
undefined,
undefined,
['file', 'data']
)
},
assertion: (response: any) => {
const responseKeys: any = Object.keys(response)
return responseKeys.includes('file') && responseKeys.includes('data')
}
} }
] ]
}) })

View File

@@ -25,7 +25,7 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
'/Public/app/common/sendArr', '/Public/app/common/sendArr',
data, data,
{}, {},
undefined, '',
true true
) )
}, },

View File

@@ -176,59 +176,11 @@ export const sendObjTests = (adapter: SASjs): TestSuite => ({
name: 'sendObj', name: 'sendObj',
tests: [ tests: [
{ {
title: 'Table name starts with numeric', title: 'Invalid column name',
description: 'Should throw an error', description: 'Should throw an error',
test: async () => { test: async () => {
const invalidData: any = { const invalidData: any = {
'1InvalidTable': [{ col1: 42 }] '1 invalid table': [{ col1: 42 }]
}
return adapter.request('common/sendObj', invalidData).catch((e) => e)
},
assertion: (error: any) =>
!!error && !!error.error && !!error.error.message
},
{
title: 'Table name contains a space',
description: 'Should throw an error',
test: async () => {
const invalidData: any = {
'an invalidTable': [{ col1: 42 }]
}
return adapter.request('common/sendObj', invalidData).catch((e) => e)
},
assertion: (error: any) =>
!!error && !!error.error && !!error.error.message
},
{
title: 'Table name contains a special character',
description: 'Should throw an error',
test: async () => {
const invalidData: any = {
'anInvalidTable#': [{ col1: 42 }]
}
return adapter.request('common/sendObj', invalidData).catch((e) => e)
},
assertion: (error: any) =>
!!error && !!error.error && !!error.error.message
},
{
title: 'Table name exceeds max length of 32 characters',
description: 'Should throw an error',
test: async () => {
const invalidData: any = {
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: [{ col1: 42 }]
}
return adapter.request('common/sendObj', invalidData).catch((e) => e)
},
assertion: (error: any) =>
!!error && !!error.error && !!error.error.message
},
{
title: "Invalid data object's structure",
description: 'Should throw an error',
test: async () => {
const invalidData: any = {
inData: [[{ data: 'value' }]]
} }
return adapter.request('common/sendObj', invalidData).catch((e) => e) return adapter.request('common/sendObj', invalidData).catch((e) => e)
}, },

View File

@@ -2,7 +2,6 @@ import { Context, EditContextInput, ContextAllAttributes } from './types'
import { isUrl } from './utils' import { isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { AuthConfig } from '@sasjs/utils/types'
export class ContextManager { export class ContextManager {
private defaultComputeContexts = [ private defaultComputeContexts = [
@@ -329,12 +328,12 @@ export class ContextManager {
public async getExecutableContexts( public async getExecutableContexts(
executeScript: Function, executeScript: Function,
authConfig?: AuthConfig accessToken?: string
) { ) {
const { result: contexts } = await this.requestClient const { result: contexts } = await this.requestClient
.get<{ items: Context[] }>( .get<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`, `${this.serverUrl}/compute/contexts?limit=10000`,
authConfig?.access_token accessToken
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while fetching compute contexts.') throw prefixMessage(err, 'Error while fetching compute contexts.')
@@ -351,7 +350,7 @@ export class ContextManager {
`test-${context.name}`, `test-${context.name}`,
linesOfCode, linesOfCode,
context.name, context.name,
authConfig, accessToken,
null, null,
false, false,
true, true,

View File

@@ -2,19 +2,15 @@ import { isUrl } from './utils'
import { UploadFile } from './types/UploadFile' import { UploadFile } from './types/UploadFile'
import { ErrorResponse, LoginRequiredError } from './types/errors' import { ErrorResponse, LoginRequiredError } from './types/errors'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { ServerType } from '@sasjs/utils/types'
import SASjs from './SASjs'
import { Server } from 'https'
import { SASjsConfig } from './types'
import { config } from 'process'
export class FileUploader { export class FileUploader {
constructor( constructor(
private sasjsConfig: SASjsConfig, private appLoc: string,
serverUrl: string,
private jobsPath: string, private jobsPath: string,
private requestClient: RequestClient private requestClient: RequestClient
) { ) {
if (this.sasjsConfig.serverUrl) isUrl(this.sasjsConfig.serverUrl) if (serverUrl) isUrl(serverUrl)
} }
public uploadFile(sasJob: string, files: UploadFile[], params: any) { public uploadFile(sasJob: string, files: UploadFile[], params: any) {
@@ -33,8 +29,8 @@ export class FileUploader {
} }
} }
const program = this.sasjsConfig.appLoc const program = this.appLoc
? this.sasjsConfig.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '') ? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob : sasJob
const uploadUrl = `${this.jobsPath}/?${ const uploadUrl = `${this.jobsPath}/?${
'_program=' + program '_program=' + program
@@ -48,12 +44,6 @@ export class FileUploader {
const csrfToken = this.requestClient.getCsrfToken('file') const csrfToken = this.requestClient.getCsrfToken('file')
if (csrfToken) formData.append('_csrf', csrfToken.value) if (csrfToken) formData.append('_csrf', csrfToken.value)
if (this.sasjsConfig.debug) formData.append('_debug', '131')
if (
this.sasjsConfig.serverType === ServerType.SasViya &&
this.sasjsConfig.contextName
)
formData.append('_contextname', this.sasjsConfig.contextName)
const headers = { const headers = {
'cache-control': 'no-cache', 'cache-control': 'no-cache',
@@ -63,15 +53,9 @@ export class FileUploader {
return this.requestClient return this.requestClient
.post(uploadUrl, formData, undefined, 'application/json', headers) .post(uploadUrl, formData, undefined, 'application/json', headers)
.then((res) => { .then((res) =>
let result
result =
typeof res.result === 'string' ? JSON.parse(res.result) : res.result typeof res.result === 'string' ? JSON.parse(res.result) : res.result
)
return result
//TODO: append to SASjs requests
})
.catch((err: Error) => { .catch((err: Error) => {
if (err instanceof LoginRequiredError) { if (err instanceof LoginRequiredError) {
return Promise.reject( return Promise.reject(

View File

@@ -1,6 +1,4 @@
import { generateTimestamp } from '@sasjs/utils/time' import axios, { AxiosInstance } from 'axios'
import * as NodeFormData from 'form-data'
import { Sas9RequestClient } from './request/Sas9RequestClient'
import { isUrl } from './utils' import { isUrl } from './utils'
/** /**
@@ -8,11 +6,11 @@ import { isUrl } from './utils'
* *
*/ */
export class SAS9ApiClient { export class SAS9ApiClient {
private requestClient: Sas9RequestClient private httpClient: AxiosInstance
constructor(private serverUrl: string, private jobsPath: string) { constructor(private serverUrl: string) {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
this.requestClient = new Sas9RequestClient(serverUrl, false) this.httpClient = axios.create({ baseURL: this.serverUrl })
} }
/** /**
@@ -35,61 +33,27 @@ export class SAS9ApiClient {
/** /**
* Executes code on a SAS9 server. * Executes code on a SAS9 server.
* @param linesOfCode - an array of code lines to execute. * @param linesOfCode - an array of code lines to execute.
* @param userName - the user name to log into the current SAS server. * @param serverName - the server to execute the code on.
* @param password - the password to log into the current SAS server. * @param repositoryName - the repository to execute the code in.
*/ */
public async executeScript( public async executeScript(
linesOfCode: string[], linesOfCode: string[],
userName: string, serverName: string,
password: string repositoryName: string
) { ) {
await this.requestClient.login(userName, password, this.jobsPath) const requestPayload = linesOfCode.join('\n')
// This piece of code forces a webout to prevent Stored Process Errors. const executeScriptResponse = await this.httpClient.put(
const forceOutputCode = [ `/sas/servers/${serverName}/cmd?repositoryName=${repositoryName}`,
'data _null_;', `command=${requestPayload}`,
'file _webout;', {
`put 'Executed sasjs run';`, headers: {
'run;' Accept: 'application/json'
] },
const formData = generateFileUploadForm( responseType: 'text'
[...linesOfCode, ...forceOutputCode].join('\n') }
) )
const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner` return executeScriptResponse.data
const contentType =
'multipart/form-data; boundary=' + formData.getBoundary()
const contentLength = formData.getLengthSync()
const headers = {
'cache-control': 'no-cache',
Accept: '*/*',
'Content-Type': contentType,
'Content-Length': contentLength,
Connection: 'keep-alive'
}
const storedProcessUrl = `${this.jobsPath}/?${
'_program=' + codeInjectorPath + '&_debug=log'
}`
const response = await this.requestClient.post(
storedProcessUrl,
formData,
undefined,
contentType,
headers
)
return response.result as string
} }
} }
const generateFileUploadForm = (data: any): NodeFormData => {
const formData = new NodeFormData()
const filename = `sasjs-execute-sas9-${generateTimestamp('')}.sas`
formData.append(filename, data, {
filename,
contentType: 'text/plain'
})
return formData
}

View File

@@ -12,7 +12,6 @@ import {
Context, Context,
ContextAllAttributes, ContextAllAttributes,
Folder, Folder,
File,
EditContextInput, EditContextInput,
JobDefinition, JobDefinition,
PollOptions PollOptions
@@ -26,16 +25,11 @@ 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 {
isAccessTokenExpiring,
isRefreshTokenExpiring
} from '@sasjs/utils/auth'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { SasAuthResponse, MacroVar, AuthConfig } from '@sasjs/utils/types'
import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired' import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import * as mime from 'mime'
/** /**
* A client for interfacing with the SAS Viya REST API. * A client for interfacing with the SAS Viya REST API.
@@ -134,14 +128,14 @@ export class SASViyaApiClient {
/** /**
* Returns all compute contexts on this server that the user has access to. * Returns all compute contexts on this server that the user has access to.
* @param authConfig - an access token, refresh token, client and secret for an authorized user. * @param accessToken - an access token for an authorized user.
*/ */
public async getExecutableContexts(authConfig?: AuthConfig) { public async getExecutableContexts(accessToken?: string) {
const bindedExecuteScript = this.executeScript.bind(this) const bindedExecuteScript = this.executeScript.bind(this)
return await this.contextManager.getExecutableContexts( return await this.contextManager.getExecutableContexts(
bindedExecuteScript, bindedExecuteScript,
authConfig accessToken
) )
} }
@@ -270,40 +264,37 @@ export class SASViyaApiClient {
* @param jobPath - the path to the file being submitted for execution. * @param jobPath - the path to the file being submitted for execution.
* @param linesOfCode - an array of code lines to execute. * @param linesOfCode - an array of code lines to execute.
* @param contextName - the context to execute the code in. * @param contextName - the context to execute the code in.
* @param authConfig - an object containing an access token, refresh token, client ID and secret. * @param accessToken - an access token for an authorized user.
* @param data - execution data. * @param data - execution data.
* @param debug - when set to true, the log will be returned. * @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code). * @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session * @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/ */
public async executeScript( public async executeScript(
jobPath: string, jobPath: string,
linesOfCode: string[], linesOfCode: string[],
contextName: string, contextName: string,
authConfig?: AuthConfig, accessToken?: string,
data = null, data = null,
debug: boolean = false, debug: boolean = false,
expectWebout = false, expectWebout = false,
waitForResult = true, waitForResult = true,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false
variables?: MacroVar
): Promise<any> { ): Promise<any> {
let access_token = (authConfig || {}).access_token try {
if (authConfig) { const headers: any = {
;({ access_token } = await this.getTokens(authConfig)) 'Content-Type': 'application/json'
} }
const logger = process.logger || console if (accessToken) headers.Authorization = `Bearer ${accessToken}`
try {
let executionSessionId: string let executionSessionId: string
const session = await this.sessionManager const session = await this.sessionManager
.getSession(access_token) .getSession(accessToken)
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting session. ') throw prefixMessage(err, 'Error while getting session. ')
}) })
@@ -312,7 +303,7 @@ export class SASViyaApiClient {
if (printPid) { if (printPid) {
const { result: jobIdVariable } = await this.sessionManager const { result: jobIdVariable } = await this.sessionManager
.getVariable(executionSessionId, 'SYSJOBID', access_token) .getVariable(executionSessionId, 'SYSJOBID', accessToken)
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting session variable. ') throw prefixMessage(err, 'Error while getting session variable. ')
}) })
@@ -344,6 +335,7 @@ export class SASViyaApiClient {
if (debug) { if (debug) {
jobArguments['_OMITTEXTLOG'] = false jobArguments['_OMITTEXTLOG'] = false
jobArguments['_OMITSESSIONRESULTS'] = false jobArguments['_OMITSESSIONRESULTS'] = false
jobArguments['_DEBUG'] = 131
} }
let fileName let fileName
@@ -364,15 +356,11 @@ export class SASViyaApiClient {
: jobPath : jobPath
} }
if (variables) jobVariables = { ...jobVariables, ...variables }
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
let files: any[] = [] let files: any[] = []
if (data) { if (data) {
if (JSON.stringify(data).includes(';')) { if (JSON.stringify(data).includes(';')) {
files = await this.uploadTables(data, access_token).catch((err) => { files = await this.uploadTables(data, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while uploading tables. ') throw prefixMessage(err, 'Error while uploading tables. ')
}) })
@@ -402,7 +390,7 @@ export class SASViyaApiClient {
.post<Job>( .post<Job>(
`/compute/sessions/${executionSessionId}/jobs`, `/compute/sessions/${executionSessionId}/jobs`,
jobRequestBody, jobRequestBody,
access_token accessToken
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while posting job. ') throw prefixMessage(err, 'Error while posting job. ')
@@ -411,8 +399,8 @@ export class SASViyaApiClient {
if (!waitForResult) return session if (!waitForResult) return session
if (debug) { if (debug) {
logger.info(`Job has been submitted for '${fileName}'.`) console.log(`Job has been submitted for '${fileName}'.`)
logger.info( console.log(
`You can monitor the job progress at '${this.serverUrl}${ `You can monitor the job progress at '${this.serverUrl}${
postedJob.links.find((l: any) => l.rel === 'state')!.href postedJob.links.find((l: any) => l.rel === 'state')!.href
}'.` }'.`
@@ -422,7 +410,7 @@ export class SASViyaApiClient {
const jobStatus = await this.pollJobState( const jobStatus = await this.pollJobState(
postedJob, postedJob,
etag, etag,
authConfig, accessToken,
pollOptions pollOptions
).catch(async (err) => { ).catch(async (err) => {
const error = err?.response?.data const error = err?.response?.data
@@ -435,7 +423,7 @@ export class SASViyaApiClient {
const logCount = 1000000 const logCount = 1000000
err.log = await fetchLogByChunks( err.log = await fetchLogByChunks(
this.requestClient, this.requestClient,
access_token!, accessToken!,
sessionLogUrl, sessionLogUrl,
logCount logCount
) )
@@ -443,14 +431,10 @@ export class SASViyaApiClient {
throw prefixMessage(err, 'Error while polling job status. ') throw prefixMessage(err, 'Error while polling job status. ')
}) })
if (authConfig) {
;({ access_token } = await this.getTokens(authConfig))
}
const { result: currentJob } = await this.requestClient const { result: currentJob } = await this.requestClient
.get<Job>( .get<Job>(
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`, `/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
access_token accessToken
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting job. ') throw prefixMessage(err, 'Error while getting job. ')
@@ -466,7 +450,7 @@ export class SASViyaApiClient {
const logCount = currentJob.logStatistics?.lineCount ?? 1000000 const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks( log = await fetchLogByChunks(
this.requestClient, this.requestClient,
access_token!, accessToken!,
logUrl, logUrl,
logCount logCount
) )
@@ -486,7 +470,7 @@ export class SASViyaApiClient {
if (resultLink) { if (resultLink) {
jobResult = await this.requestClient jobResult = await this.requestClient
.get<any>(resultLink, access_token, 'text/plain') .get<any>(resultLink, accessToken, 'text/plain')
.catch(async (e) => { .catch(async (e) => {
if (e instanceof NotFoundError) { if (e instanceof NotFoundError) {
if (logLink) { if (logLink) {
@@ -494,7 +478,7 @@ export class SASViyaApiClient {
const logCount = currentJob.logStatistics?.lineCount ?? 1000000 const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks( log = await fetchLogByChunks(
this.requestClient, this.requestClient,
access_token!, accessToken!,
logUrl, logUrl,
logCount logCount
) )
@@ -513,7 +497,7 @@ export class SASViyaApiClient {
} }
await this.sessionManager await this.sessionManager
.clearSession(executionSessionId, access_token) .clearSession(executionSessionId, accessToken)
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while clearing session. ') throw prefixMessage(err, 'Error while clearing session. ')
}) })
@@ -525,7 +509,7 @@ export class SASViyaApiClient {
jobPath, jobPath,
linesOfCode, linesOfCode,
contextName, contextName,
authConfig, accessToken,
data, data,
debug, debug,
false, false,
@@ -548,53 +532,6 @@ export class SASViyaApiClient {
.then((res) => res.result) .then((res) => res.result)
} }
/**
* Creates a file. Path to or URI of the parent folder is required.
* @param fileName - the name of the new file.
* @param contentBuffer - the content of the new file in Buffer.
* @param parentFolderPath - the full path to the parent folder. If not
* provided, the parentFolderUri must be provided.
* @param parentFolderUri - the URI (eg /folders/folders/UUID) of the parent
* folder. If not provided, the parentFolderPath must be provided.
* @param accessToken - an access token for authorizing the request.
*/
public async createFile(
fileName: string,
contentBuffer: Buffer,
parentFolderPath?: string,
parentFolderUri?: string,
accessToken?: string
): Promise<File> {
if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.')
}
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
}
const headers = {
Accept: 'application/vnd.sas.file+json',
'Content-Disposition': `filename="${fileName}";`
}
const formData = new NodeFormData()
formData.append('file', contentBuffer, fileName)
const mimeType =
mime.getType(fileName.match(/\.[0-9a-z]+$/i)?.[0] || '') ?? 'text/plain'
return (
await this.requestClient.post<File>(
`/files/files?parentFolderUri=${parentFolderUri}&typeDefName=file#rawUpload`,
formData,
accessToken,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
).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.
@@ -612,7 +549,6 @@ export class SASViyaApiClient {
accessToken?: string, accessToken?: string,
isForced?: boolean isForced?: boolean
): Promise<Folder> { ): Promise<Folder> {
const logger = process.logger || console
if (!parentFolderPath && !parentFolderUri) { if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.') throw new Error('Path or URI of the parent folder is required.')
} }
@@ -620,7 +556,7 @@ export class SASViyaApiClient {
if (!parentFolderUri && parentFolderPath) { if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken) parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
if (!parentFolderUri) { if (!parentFolderUri) {
logger.info( console.log(
`Parent folder at path '${parentFolderPath}' is not present.` `Parent folder at path '${parentFolderPath}' is not present.`
) )
@@ -632,7 +568,7 @@ export class SASViyaApiClient {
if (newParentFolderPath === '') { if (newParentFolderPath === '') {
throw new Error('Root folder has to be present on the server.') throw new Error('Root folder has to be present on the server.')
} }
logger.info( console.log(
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'` `Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
) )
const parentFolder = await this.createFolder( const parentFolder = await this.createFolder(
@@ -641,7 +577,7 @@ export class SASViyaApiClient {
undefined, undefined,
accessToken accessToken
) )
logger.info( console.log(
`Parent folder '${newFolderName}' has been successfully created.` `Parent folder '${newFolderName}' has been successfully created.`
) )
parentFolderUri = `/folders/folders/${parentFolder.id}` parentFolderUri = `/folders/folders/${parentFolder.id}`
@@ -783,11 +719,13 @@ export class SASViyaApiClient {
let formData let formData
if (typeof FormData === 'undefined') { if (typeof FormData === 'undefined') {
formData = new NodeFormData() formData = new NodeFormData()
} else {
formData = new FormData()
}
formData.append('grant_type', 'authorization_code') formData.append('grant_type', 'authorization_code')
formData.append('code', authCode) formData.append('code', authCode)
} else {
formData = new FormData()
formData.append('grant_type', 'authorization_code')
formData.append('code', authCode)
}
const authResponse = await this.requestClient const authResponse = await this.requestClient
.post( .post(
@@ -876,25 +814,18 @@ export class SASViyaApiClient {
* @param expectWebout - a boolean indicating whether to expect a _webout response. * @param expectWebout - a boolean indicating whether to expect a _webout response.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/ */
public async executeComputeJob( public async executeComputeJob(
sasJob: string, sasJob: string,
contextName: string, contextName: string,
debug?: boolean, debug?: boolean,
data?: any, data?: any,
authConfig?: AuthConfig, accessToken?: string,
waitForResult = true, waitForResult = true,
expectWebout = false, expectWebout = false,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false
variables?: MacroVar
) { ) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await this.getTokens(authConfig))
}
if (isRelativePath(sasJob) && !this.rootFolderName) { if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error( throw new Error(
'Relative paths cannot be used without specifying a root folder name' 'Relative paths cannot be used without specifying a root folder name'
@@ -908,7 +839,7 @@ export class SASViyaApiClient {
? `${this.rootFolderName}/${folderPath}` ? `${this.rootFolderName}/${folderPath}`
: folderPath : folderPath
await this.populateFolderMap(fullFolderPath, access_token).catch((err) => { await this.populateFolderMap(fullFolderPath, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while populating folder map. ') throw prefixMessage(err, 'Error while populating folder map. ')
}) })
@@ -920,6 +851,12 @@ export class SASViyaApiClient {
) )
} }
const headers: any = { 'Content-Type': 'application/json' }
if (!!accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const jobToExecute = jobFolder?.find((item) => item.name === jobName) const jobToExecute = jobFolder?.find((item) => item.name === jobName)
if (!jobToExecute) { if (!jobToExecute) {
@@ -940,7 +877,7 @@ export class SASViyaApiClient {
const { result: jobDefinition } = await this.requestClient const { result: jobDefinition } = await this.requestClient
.get<JobDefinition>( .get<JobDefinition>(
`${this.serverUrl}${jobDefinitionLink.href}`, `${this.serverUrl}${jobDefinitionLink.href}`,
access_token accessToken
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting job definition. ') throw prefixMessage(err, 'Error while getting job definition. ')
@@ -960,14 +897,13 @@ export class SASViyaApiClient {
sasJob, sasJob,
linesToExecute, linesToExecute,
contextName, contextName,
authConfig, accessToken,
data, data,
debug, debug,
expectWebout, expectWebout,
waitForResult, waitForResult,
pollOptions, pollOptions,
printPid, printPid
variables
) )
} }
@@ -984,12 +920,8 @@ export class SASViyaApiClient {
contextName: string, contextName: string,
debug: boolean, debug: boolean,
data?: any, data?: any,
authConfig?: AuthConfig accessToken?: string
) { ) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await this.getTokens(authConfig))
}
if (isRelativePath(sasJob) && !this.rootFolderName) { if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error( throw new Error(
'Relative paths cannot be used without specifying a root folder name.' 'Relative paths cannot be used without specifying a root folder name.'
@@ -1002,7 +934,7 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob) const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}` ? `${this.rootFolderName}/${folderPath}`
: folderPath : folderPath
await this.populateFolderMap(fullFolderPath, access_token) await this.populateFolderMap(fullFolderPath, accessToken)
const jobFolder = this.folderMap.get(fullFolderPath) const jobFolder = this.folderMap.get(fullFolderPath)
if (!jobFolder) { if (!jobFolder) {
@@ -1015,7 +947,7 @@ export class SASViyaApiClient {
let files: any[] = [] let files: any[] = []
if (data && Object.keys(data).length) { if (data && Object.keys(data).length) {
files = await this.uploadTables(data, access_token) files = await this.uploadTables(data, accessToken)
} }
if (!jobToExecute) { if (!jobToExecute) {
@@ -1027,7 +959,7 @@ export class SASViyaApiClient {
const { result: jobDefinition } = await this.requestClient.get<Job>( const { result: jobDefinition } = await this.requestClient.get<Job>(
`${this.serverUrl}${jobDefinitionLink}`, `${this.serverUrl}${jobDefinitionLink}`,
access_token accessToken
) )
const jobArguments: { [key: string]: any } = { const jobArguments: { [key: string]: any } = {
@@ -1063,18 +995,18 @@ export class SASViyaApiClient {
const { result: postedJob, etag } = await this.requestClient.post<Job>( const { result: postedJob, etag } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`, `${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody, postJobRequestBody,
access_token accessToken
) )
const jobStatus = await this.pollJobState( const jobStatus = await this.pollJobState(
postedJob, postedJob,
etag, etag,
authConfig accessToken
).catch((err) => { ).catch((err) => {
throw prefixMessage(err, 'Error while polling job status. ') throw prefixMessage(err, 'Error while polling job status. ')
}) })
const { result: currentJob } = await this.requestClient.get<Job>( const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token accessToken
) )
let jobResult let jobResult
@@ -1085,13 +1017,13 @@ export class SASViyaApiClient {
if (resultLink) { if (resultLink) {
jobResult = await this.requestClient.get<any>( jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`, `${this.serverUrl}${resultLink}/content`,
access_token, accessToken,
'text/plain' 'text/plain'
) )
} }
if (debug && logLink) { if (debug && logLink) {
log = await this.requestClient log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token) .get<any>(`${this.serverUrl}${logLink.href}/content`, accessToken)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) .then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
} }
if (jobStatus === 'failed') { if (jobStatus === 'failed') {
@@ -1141,18 +1073,12 @@ export class SASViyaApiClient {
private async pollJobState( private async pollJobState(
postedJob: any, postedJob: any,
etag: string | null, etag: string | null,
authConfig?: AuthConfig, accessToken?: string,
pollOptions?: PollOptions pollOptions?: PollOptions
) { ) {
const logger = process.logger || console
let POLL_INTERVAL = 300 let POLL_INTERVAL = 300
let MAX_POLL_COUNT = 1000 let MAX_POLL_COUNT = 1000
let MAX_ERROR_COUNT = 5 let MAX_ERROR_COUNT = 5
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await this.getTokens(authConfig))
}
if (pollOptions) { if (pollOptions) {
POLL_INTERVAL = pollOptions.POLL_INTERVAL || POLL_INTERVAL POLL_INTERVAL = pollOptions.POLL_INTERVAL || POLL_INTERVAL
@@ -1166,8 +1092,8 @@ export class SASViyaApiClient {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'If-None-Match': etag 'If-None-Match': etag
} }
if (access_token) { if (accessToken) {
headers.Authorization = `Bearer ${access_token}` headers.Authorization = `Bearer ${accessToken}`
} }
const stateLink = postedJob.links.find((l: any) => l.rel === 'state') const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
if (!stateLink) { if (!stateLink) {
@@ -1177,7 +1103,7 @@ export class SASViyaApiClient {
const { result: state } = await this.requestClient const { result: state } = await this.requestClient
.get<string>( .get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`, `${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
access_token, accessToken,
'text/plain', 'text/plain',
{}, {},
this.debug this.debug
@@ -1205,15 +1131,11 @@ export class SASViyaApiClient {
postedJobState === 'pending' || postedJobState === 'pending' ||
postedJobState === 'unavailable' postedJobState === 'unavailable'
) { ) {
if (authConfig) {
;({ access_token } = await this.getTokens(authConfig))
}
if (stateLink) { if (stateLink) {
const { result: jobState } = await this.requestClient const { result: jobState } = await this.requestClient
.get<string>( .get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`, `${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
access_token, accessToken,
'text/plain', 'text/plain',
{}, {},
this.debug this.debug
@@ -1242,8 +1164,8 @@ export class SASViyaApiClient {
} }
if (this.debug && printedState !== postedJobState) { if (this.debug && printedState !== postedJobState) {
logger.info('Polling job status...') console.log('Polling job status...')
logger.info(`Current job state: ${postedJobState}`) console.log(`Current job state: ${postedJobState}`)
printedState = postedJobState printedState = postedJobState
} }
@@ -1433,9 +1355,6 @@ export class SASViyaApiClient {
accessToken accessToken
) )
if (!sourceFolderUri) {
return undefined
}
const sourceFolderId = sourceFolderUri?.split('/').pop() const sourceFolderId = sourceFolderUri?.split('/').pop()
const { result: folder } = await this.requestClient const { result: folder } = await this.requestClient
@@ -1490,21 +1409,4 @@ export class SASViyaApiClient {
return movedFolder return movedFolder
} }
private async getTokens(authConfig: AuthConfig): Promise<AuthConfig> {
const logger = process.logger || console
let { access_token, refresh_token, client, secret } = authConfig
if (
isAccessTokenExpiring(access_token) ||
isRefreshTokenExpiring(refresh_token)
) {
logger.info('Refreshing access and refresh tokens.')
;({ access_token, refresh_token } = await this.refreshTokens(
client,
secret,
refresh_token
))
}
return { access_token, refresh_token, client, secret }
}
} }

View File

@@ -4,17 +4,15 @@ import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient' import { SAS9ApiClient } from './SAS9ApiClient'
import { FileUploader } from './FileUploader' import { FileUploader } from './FileUploader'
import { AuthManager } from './auth' import { AuthManager } from './auth'
import { ServerType, MacroVar, AuthConfig } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { import {
JobExecutor, JobExecutor,
WebJobExecutor, WebJobExecutor,
ComputeJobExecutor, ComputeJobExecutor,
JesJobExecutor, JesJobExecutor
Sas9JobExecutor
} from './job-execution' } from './job-execution'
import { ErrorResponse } from './types/errors' import { ErrorResponse } from './types/errors'
import { ExtraResponseAttributes } from '@sasjs/utils/types'
const defaultConfig: SASjsConfig = { const defaultConfig: SASjsConfig = {
serverUrl: '', serverUrl: '',
@@ -24,7 +22,7 @@ const defaultConfig: SASjsConfig = {
serverType: ServerType.SasViya, serverType: ServerType.SasViya,
debug: false, debug: false,
contextName: 'SAS Job Execution compute context', contextName: 'SAS Job Execution compute context',
useComputeApi: null, useComputeApi: false,
allowInsecureRequests: false allowInsecureRequests: false
} }
@@ -43,7 +41,6 @@ export default class SASjs {
private webJobExecutor: JobExecutor | null = null private webJobExecutor: JobExecutor | null = null
private computeJobExecutor: JobExecutor | null = null private computeJobExecutor: JobExecutor | null = null
private jesJobExecutor: JobExecutor | null = null private jesJobExecutor: JobExecutor | null = null
private sas9JobExecutor: JobExecutor | null = null
constructor(config?: any) { constructor(config?: any) {
this.sasjsConfig = { this.sasjsConfig = {
@@ -60,15 +57,15 @@ export default class SASjs {
public async executeScriptSAS9( public async executeScriptSAS9(
linesOfCode: string[], linesOfCode: string[],
userName: string, serverName: string,
password: string repositoryName: string
) { ) {
this.isMethodSupported('executeScriptSAS9', ServerType.Sas9) this.isMethodSupported('executeScriptSAS9', ServerType.Sas9)
return await this.sas9ApiClient?.executeScript( return await this.sas9ApiClient?.executeScript(
linesOfCode, linesOfCode,
userName, serverName,
password repositoryName
) )
} }
@@ -103,12 +100,12 @@ export default class SASjs {
/** /**
* Gets executable compute contexts. * Gets executable compute contexts.
* @param authConfig - an access token, refresh token, client and secret for an authorized user. * @param accessToken - an access token for an authorized user.
*/ */
public async getExecutableContexts(authConfig: AuthConfig) { public async getExecutableContexts(accessToken: string) {
this.isMethodSupported('getExecutableContexts', ServerType.SasViya) this.isMethodSupported('getExecutableContexts', ServerType.SasViya)
return await this.sasViyaApiClient!.getExecutableContexts(authConfig) return await this.sasViyaApiClient!.getExecutableContexts(accessToken)
} }
/** /**
@@ -240,14 +237,14 @@ export default class SASjs {
* @param fileName - name of the file to run. It will be converted to path to the file being submitted for execution. * @param fileName - name of the file to run. It will be converted to path to the file being submitted for execution.
* @param linesOfCode - lines of sas code from the file to run. * @param linesOfCode - lines of sas code from the file to run.
* @param contextName - context name on which code will be run on the server. * @param contextName - context name on which code will be run on the server.
* @param authConfig - (optional) the access token, refresh token, client and secret for authorizing the request. * @param accessToken - (optional) the access token for authorizing the request.
* @param debug - (optional) if true, global debug config will be overriden * @param debug - (optional) if true, global debug config will be overriden
*/ */
public async executeScriptSASViya( public async executeScriptSASViya(
fileName: string, fileName: string,
linesOfCode: string[], linesOfCode: string[],
contextName: string, contextName: string,
authConfig?: AuthConfig, accessToken?: string,
debug?: boolean debug?: boolean
) { ) {
this.isMethodSupported('executeScriptSASViya', ServerType.SasViya) this.isMethodSupported('executeScriptSASViya', ServerType.SasViya)
@@ -261,14 +258,14 @@ export default class SASjs {
fileName, fileName,
linesOfCode, linesOfCode,
contextName, contextName,
authConfig, accessToken,
null, null,
debug ? debug : this.sasjsConfig.debug debug ? debug : this.sasjsConfig.debug
) )
} }
/** /**
* Creates a folder in the logical SAS folder tree * Creates a folder at SAS file system.
* @param folderName - name of the folder to be created. * @param folderName - name of the folder to be created.
* @param parentFolderPath - the full path (eg `/Public/example/myFolder`) of the parent folder. * @param parentFolderPath - the full path (eg `/Public/example/myFolder`) of the parent folder.
* @param parentFolderUri - the URI of the parent folder. * @param parentFolderUri - the URI of the parent folder.
@@ -300,40 +297,6 @@ export default class SASjs {
) )
} }
/**
* Creates a file in the logical SAS folder tree
* @param fileName - name of the file to be created.
* @param content - content of the file to be created.
* @param parentFolderPath - the full path (eg `/Public/example/myFolder`) of the parent folder.
* @param parentFolderUri - the URI of the parent folder.
* @param accessToken - the access token to authorizing the request.
* @param sasApiClient - a client for interfacing with SAS API.
*/
public async createFile(
fileName: string,
content: Buffer,
parentFolderPath: string,
parentFolderUri?: string,
accessToken?: string,
sasApiClient?: SASViyaApiClient
) {
if (sasApiClient)
return await sasApiClient.createFile(
fileName,
content,
parentFolderPath,
parentFolderUri,
accessToken
)
return await this.sasViyaApiClient!.createFile(
fileName,
content,
parentFolderPath,
parentFolderUri,
accessToken
)
}
/** /**
* Fetches a folder from the SAS file system. * Fetches a folder from the SAS file system.
* @param folderPath - path of the folder to be fetched. * @param folderPath - path of the folder to be fetched.
@@ -544,7 +507,12 @@ export default class SASjs {
public uploadFile(sasJob: string, files: UploadFile[], params: any) { public uploadFile(sasJob: string, files: UploadFile[], params: any) {
const fileUploader = const fileUploader =
this.fileUploader || this.fileUploader ||
new FileUploader(this.sasjsConfig, this.jobsPath, this.requestClient!) new FileUploader(
this.sasjsConfig.appLoc,
this.sasjsConfig.serverUrl,
this.jobsPath,
this.requestClient!
)
return fileUploader.uploadFile(sasJob, files, params) return fileUploader.uploadFile(sasJob, files, params)
} }
@@ -570,38 +538,30 @@ export default class SASjs {
* `await request(sasJobPath, data, config, () => setIsLoggedIn(false))` * `await request(sasJobPath, data, config, () => setIsLoggedIn(false))`
* If you are not passing in any data and configuration, it will look like so: * If you are not passing in any data and configuration, it will look like so:
* `await request(sasJobPath, {}, {}, () => setIsLoggedIn(false))` * `await request(sasJobPath, {}, {}, () => setIsLoggedIn(false))`
* @param extraResponseAttributes - a array of predefined values that are used
* to provide extra attributes (same names as those values) to be added in response
* Supported values are declared in ExtraResponseAttributes type.
*/ */
public async request( public async request(
sasJob: string, sasJob: string,
data: { [key: string]: any } | null, data: { [key: string]: any },
config: { [key: string]: any } = {}, config: { [key: string]: any } = {},
loginRequiredCallback?: () => any, loginRequiredCallback?: () => any,
authConfig?: AuthConfig, accessToken?: string
extraResponseAttributes: ExtraResponseAttributes[] = []
) { ) {
config = { config = {
...this.sasjsConfig, ...this.sasjsConfig,
...config ...config
} }
const validationResult = this.validateInput(data)
if (validationResult.status) {
if ( if (
config.serverType !== ServerType.Sas9 && typeof loginRequiredCallback === 'function' ||
config.useComputeApi !== undefined && typeof loginRequiredCallback === 'undefined'
config.useComputeApi !== null
) { ) {
if (config.serverType === ServerType.SasViya && config.contextName) {
if (config.useComputeApi) { if (config.useComputeApi) {
return await this.computeJobExecutor!.execute( return await this.computeJobExecutor!.execute(
sasJob, sasJob,
data, data,
config, config,
loginRequiredCallback, loginRequiredCallback,
authConfig accessToken
) )
} else { } else {
return await this.jesJobExecutor!.execute( return await this.jesJobExecutor!.execute(
@@ -609,91 +569,23 @@ export default class SASjs {
data, data,
config, config,
loginRequiredCallback, loginRequiredCallback,
authConfig, accessToken
extraResponseAttributes
) )
} }
} else if (
config.serverType === ServerType.Sas9 &&
config.username &&
config.password
) {
return await this.sas9JobExecutor!.execute(sasJob, data, config)
} else { } else {
return await this.webJobExecutor!.execute( return await this.webJobExecutor!.execute(
sasJob, sasJob,
data, data,
config, config,
loginRequiredCallback, loginRequiredCallback
authConfig,
extraResponseAttributes
) )
} }
} else { } else {
return Promise.reject(new ErrorResponse(validationResult.msg)) return Promise.reject(
} new ErrorResponse(
} `Invalid loginRequiredCallback parameter was provided. Expected Callback function but found ${typeof loginRequiredCallback}`
)
/** )
* This function validates the input data structure and table naming convention
*
* @param data A json object that contains one or more tables, it can also be null
* @returns An object which contains two attributes: 1) status: boolean, 2) msg: string
*/
private validateInput(data: { [key: string]: any } | null): {
status: boolean
msg: string
} {
if (data === null) return { status: true, msg: '' }
for (const key in data) {
if (!key.match(/^[a-zA-Z_]/)) {
return {
status: false,
msg: 'First letter of table should be alphabet or underscore.'
}
}
if (!key.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/)) {
return { status: false, msg: 'Table name should be alphanumeric.' }
}
if (key.length > 32) {
return {
status: false,
msg: 'Maximum length for table name could be 32 characters.'
}
}
if (this.getType(data[key]) !== 'Array') {
return {
status: false,
msg: 'Parameter data contains invalid table structure.'
}
}
for (let i = 0; i < data[key].length; i++) {
if (this.getType(data[key][i]) !== 'object') {
return {
status: false,
msg: `Table ${key} contains invalid structure.`
}
}
}
}
return { status: true, msg: '' }
}
/**
* this function returns the type of variable
*
* @param data it could be anything, like string, array, object etc.
* @returns a string which tells the type of input parameter
*/
private getType(data: any): string {
if (Array.isArray(data)) {
return 'Array'
} else {
return typeof data
} }
} }
@@ -734,7 +626,7 @@ export default class SASjs {
) )
sasApiClient.debug = this.sasjsConfig.debug sasApiClient.debug = this.sasjsConfig.debug
} else if (this.sasjsConfig.serverType === ServerType.Sas9) { } else if (this.sasjsConfig.serverType === ServerType.Sas9) {
sasApiClient = new SAS9ApiClient(serverUrl, this.jobsPath) sasApiClient = new SAS9ApiClient(serverUrl)
} }
} else { } else {
let sasClientConfig: any = null let sasClientConfig: any = null
@@ -776,22 +668,20 @@ export default class SASjs {
* @param config - provide any changes to the config here, for instance to * @param config - provide any changes to the config here, for instance to
* enable/disable `debug`. Any change provided will override the global config, * enable/disable `debug`. Any change provided will override the global config,
* for that particular function call. * for that particular function call.
* @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs. * @param accessToken - a valid access token that is authorised to execute compute jobs.
* The access token is not required when the user is authenticated via the browser. * The access token is not required when the user is authenticated via the browser.
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete. * @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/ */
public async startComputeJob( public async startComputeJob(
sasJob: string, sasJob: string,
data: any, data: any,
config: any = {}, config: any = {},
authConfig?: AuthConfig, accessToken?: string,
waitForResult?: boolean, waitForResult?: boolean,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false
variables?: MacroVar
) { ) {
config = { config = {
...this.sasjsConfig, ...this.sasjsConfig,
@@ -810,12 +700,11 @@ export default class SASjs {
config.contextName, config.contextName,
config.debug, config.debug,
data, data,
authConfig, accessToken,
!!waitForResult, !!waitForResult,
false, false,
pollOptions, pollOptions,
printPid, printPid
variables
) )
} }
@@ -926,15 +815,12 @@ export default class SASjs {
if (this.sasjsConfig.serverType === ServerType.Sas9) { if (this.sasjsConfig.serverType === ServerType.Sas9) {
if (this.sas9ApiClient) if (this.sas9ApiClient)
this.sas9ApiClient!.setConfig(this.sasjsConfig.serverUrl) this.sas9ApiClient!.setConfig(this.sasjsConfig.serverUrl)
else else this.sas9ApiClient = new SAS9ApiClient(this.sasjsConfig.serverUrl)
this.sas9ApiClient = new SAS9ApiClient(
this.sasjsConfig.serverUrl,
this.jobsPath
)
} }
this.fileUploader = new FileUploader( this.fileUploader = new FileUploader(
this.sasjsConfig, this.sasjsConfig.appLoc,
this.sasjsConfig.serverUrl,
this.jobsPath, this.jobsPath,
this.requestClient this.requestClient
) )
@@ -947,12 +833,6 @@ export default class SASjs {
this.sasViyaApiClient! this.sasViyaApiClient!
) )
this.sas9JobExecutor = new Sas9JobExecutor(
this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!,
this.jobsPath
)
this.computeJobExecutor = new ComputeJobExecutor( this.computeJobExecutor = new ComputeJobExecutor(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasViyaApiClient! this.sasViyaApiClient!
@@ -983,16 +863,6 @@ export default class SASjs {
isForced isForced
) )
break break
case 'file':
await this.createFile(
member.name,
member.code,
parentFolder,
undefined,
accessToken,
sasApiClient
)
break
case 'service': case 'service':
await this.createJobDefinition( await this.createJobDefinition(
member.name, member.name,

View File

@@ -1,5 +1,4 @@
import { Session, Context, SessionVariable } from './types' import { Session, Context, CsrfToken, SessionVariable } from './types'
import { NoSessionStateError } from './types/errors'
import { asyncForEach, 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' import { RequestClient } from './request/RequestClient'
@@ -7,6 +6,10 @@ 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
let RETRY_COUNT: number = 0 let RETRY_COUNT: number = 0
const INTERNAL_SAS_ERROR = {
status: 304,
message: 'Not Modified'
}
export class SessionManager { export class SessionManager {
constructor( constructor(
@@ -155,13 +158,11 @@ export class SessionManager {
etag: string | null, etag: string | null,
accessToken?: string accessToken?: string
) { ) {
const logger = process.logger || console
let sessionState = session.state let sessionState = session.state
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, reject) => { return new Promise(async (resolve, _) => {
if ( if (
sessionState === 'pending' || sessionState === 'pending' ||
sessionState === 'running' || sessionState === 'running' ||
@@ -169,24 +170,23 @@ export class SessionManager {
) { ) {
if (stateLink) { if (stateLink) {
if (this.debug && !this.printedSessionState.printed) { if (this.debug && !this.printedSessionState.printed) {
logger.info('Polling session status...') console.log('Polling session status...')
this.printedSessionState.printed = true this.printedSessionState.printed = true
} }
const { result: state, responseStatus: responseStatus } = const state = await this.getSessionState(
await this.getSessionState(
`${this.serverUrl}${stateLink.href}?wait=30`, `${this.serverUrl}${stateLink.href}?wait=30`,
etag!, etag!,
accessToken accessToken
).catch((err) => { ).catch((err) => {
throw prefixMessage(err, 'Error while getting session state.') throw err
}) })
sessionState = state.trim() sessionState = state.trim()
if (this.debug && this.printedSessionState.state !== sessionState) { if (this.debug && this.printedSessionState.state !== sessionState) {
logger.info(`Current session state is '${sessionState}'`) console.log(`Current session state is '${sessionState}'`)
this.printedSessionState.state = sessionState this.printedSessionState.state = sessionState
this.printedSessionState.printed = false this.printedSessionState.printed = false
@@ -194,21 +194,13 @@ export class SessionManager {
// There is an internal error present in SAS Viya 3.5 // There is an internal error present in SAS Viya 3.5
// Retry to wait for a session status in such case of SAS internal error // Retry to wait for a session status in such case of SAS internal error
if (!sessionState) { if (
if (RETRY_COUNT < RETRY_LIMIT) { sessionState === INTERNAL_SAS_ERROR.message &&
RETRY_COUNT < RETRY_LIMIT
) {
RETRY_COUNT++ RETRY_COUNT++
resolve(this.waitForSession(session, etag, accessToken)) resolve(this.waitForSession(session, etag, accessToken))
} else {
reject(
new NoSessionStateError(
responseStatus,
this.serverUrl + stateLink.href,
session.links.find((l: any) => l.rel === 'log')
?.href as string
)
)
}
} }
resolve(sessionState) resolve(sessionState)
@@ -226,11 +218,11 @@ export class SessionManager {
) { ) {
return await this.requestClient return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag }) .get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => ({ .then((res) => res.result as string)
result: res.result as string,
responseStatus: res.status
}))
.catch((err) => { .catch((err) => {
if (err.status === INTERNAL_SAS_ERROR.status)
return INTERNAL_SAS_ERROR.message
throw err throw err
}) })
} }

View File

@@ -1,4 +1,5 @@
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { isAuthorizeFormRequired } from '.'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { serialize } from '../utils' import { serialize } from '../utils'
@@ -34,7 +35,6 @@ export class AuthManager {
this.userName = loginParams.username this.userName = loginParams.username
const { isLoggedIn, loginForm } = await this.checkSession() const { isLoggedIn, loginForm } = await this.checkSession()
if (isLoggedIn) { if (isLoggedIn) {
await this.loginCallback() await this.loginCallback()
@@ -44,44 +44,6 @@ export class AuthManager {
} }
} }
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
let loggedIn = isLogInSuccess(loginResponse)
if (!loggedIn) {
if (isCredentialsVerifyError(loginResponse)) {
const newLoginForm = await this.getLoginForm(loginResponse)
loginResponse = await this.sendLoginRequest(newLoginForm, loginParams)
}
const currentSession = await this.checkSession()
loggedIn = currentSession.isLoggedIn
}
if (loggedIn) {
if (this.serverType === ServerType.Sas9) {
const casAuthenticationUrl = `${this.serverUrl}/SASStoredProcess/j_spring_cas_security_check`
await this.requestClient.get<string>(
`/SASLogon/login?service=${casAuthenticationUrl}`,
undefined
)
}
this.loginCallback()
}
return {
isLoggedIn: !!loggedIn,
userName: this.userName
}
}
private async sendLoginRequest(
loginForm: { [key: string]: any },
loginParams: { [key: string]: any }
) {
for (const key in loginForm) { for (const key in loginForm) {
loginParams[key] = loginForm[key] loginParams[key] = loginForm[key]
} }
@@ -98,7 +60,21 @@ export class AuthManager {
} }
) )
return loginResponse let loggedIn = isLogInSuccess(loginResponse)
if (!loggedIn) {
const currentSession = await this.checkSession()
loggedIn = currentSession.isLoggedIn
}
if (loggedIn) {
this.loginCallback()
}
return {
isLoggedIn: !!loggedIn,
userName: this.userName
}
} }
/** /**
@@ -192,10 +168,5 @@ export class AuthManager {
} }
} }
const isCredentialsVerifyError = (response: string): boolean =>
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
response
)
const isLogInSuccess = (response: string): boolean => const isLogInSuccess = (response: string): boolean =>
/You have signed in/gm.test(response) /You have signed in/gm.test(response)

View File

@@ -57,7 +57,7 @@ describe('AuthManager', () => {
expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout?') expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout?')
}) })
it('should call the auth callback and return when already logged in', async () => { it('should call the auth callback and return when already logged in', async (done) => {
const authManager = new AuthManager( const authManager = new AuthManager(
serverUrl, serverUrl,
serverType, serverType,
@@ -77,9 +77,10 @@ describe('AuthManager', () => {
expect(loginResponse.isLoggedIn).toBeTruthy() expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName) expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1) expect(authCallback).toHaveBeenCalledTimes(1)
done()
}) })
it('should post a login request to the server if not logged in', async () => { it('should post a login request to the server if not logged in', async (done) => {
const authManager = new AuthManager( const authManager = new AuthManager(
serverUrl, serverUrl,
serverType, serverType,
@@ -120,9 +121,10 @@ describe('AuthManager', () => {
} }
) )
expect(authCallback).toHaveBeenCalledTimes(1) expect(authCallback).toHaveBeenCalledTimes(1)
done()
}) })
it('should parse and submit the authorisation form when necessary', async () => { it('should parse and submit the authorisation form when necessary', async (done) => {
const authManager = new AuthManager( const authManager = new AuthManager(
serverUrl, serverUrl,
serverType, serverType,
@@ -158,9 +160,10 @@ describe('AuthManager', () => {
expect(requestClient.authorize).toHaveBeenCalledWith( expect(requestClient.authorize).toHaveBeenCalledWith(
mockLoginAuthoriseRequiredResponse mockLoginAuthoriseRequiredResponse
) )
done()
}) })
it('should check and return session information if logged in', async () => { it('should check and return session information if logged in', async (done) => {
const authManager = new AuthManager( const authManager = new AuthManager(
serverUrl, serverUrl,
serverType, serverType,
@@ -186,5 +189,7 @@ describe('AuthManager', () => {
} }
} }
) )
done()
}) })
}) })

View File

@@ -1,4 +1,4 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { SASViyaApiClient } from '../SASViyaApiClient' import { SASViyaApiClient } from '../SASViyaApiClient'
import { import {
ErrorResponse, ErrorResponse,
@@ -17,7 +17,7 @@ export class ComputeJobExecutor extends BaseJobExecutor {
data: any, data: any,
config: any, config: any,
loginRequiredCallback?: any, loginRequiredCallback?: any,
authConfig?: AuthConfig accessToken?: string
) { ) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve()) const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const waitForResult = true const waitForResult = true
@@ -30,7 +30,7 @@ export class ComputeJobExecutor extends BaseJobExecutor {
config.contextName, config.contextName,
config.debug, config.debug,
data, data,
authConfig, accessToken,
waitForResult, waitForResult,
expectWebout expectWebout
) )

View File

@@ -1,11 +1,10 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { SASViyaApiClient } from '../SASViyaApiClient' import { SASViyaApiClient } from '../SASViyaApiClient'
import { import {
ErrorResponse, ErrorResponse,
JobExecutionError, JobExecutionError,
LoginRequiredError LoginRequiredError
} from '../types/errors' } from '../types/errors'
import { ExtraResponseAttributes } from '@sasjs/utils/types'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'
export class JesJobExecutor extends BaseJobExecutor { export class JesJobExecutor extends BaseJobExecutor {
@@ -18,34 +17,23 @@ export class JesJobExecutor extends BaseJobExecutor {
data: any, data: any,
config: any, config: any,
loginRequiredCallback?: any, loginRequiredCallback?: any,
authConfig?: AuthConfig, accessToken?: string
extraResponseAttributes: ExtraResponseAttributes[] = []
) { ) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve()) const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const requestPromise = new Promise((resolve, reject) => { const requestPromise = new Promise((resolve, reject) => {
this.sasViyaApiClient this.sasViyaApiClient
?.executeJob(sasJob, config.contextName, config.debug, data, authConfig) ?.executeJob(
.then((response: any) => { sasJob,
config.contextName,
config.debug,
data,
accessToken
)
.then((response) => {
this.appendRequest(response, sasJob, config.debug) this.appendRequest(response, sasJob, config.debug)
let responseObject = {} resolve(response)
if (extraResponseAttributes && extraResponseAttributes.length > 0) {
const extraAttributes = extraResponseAttributes.reduce(
(map: any, obj: any) => ((map[obj] = response[obj]), map),
{}
)
responseObject = {
result: response.result,
...extraAttributes
}
} else {
responseObject = response.result
}
resolve(responseObject)
}) })
.catch(async (e: Error) => { .catch(async (e: Error) => {
if (e instanceof JobExecutionError) { if (e instanceof JobExecutionError) {
@@ -62,9 +50,7 @@ export class JesJobExecutor extends BaseJobExecutor {
sasJob, sasJob,
data, data,
config, config,
loginRequiredCallback, loginRequiredCallback
authConfig,
extraResponseAttributes
).then( ).then(
(res: any) => { (res: any) => {
resolve(res) resolve(res)

View File

@@ -1,6 +1,5 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { SASjsRequest } from '../types' import { SASjsRequest } from '../types'
import { ExtraResponseAttributes } from '@sasjs/utils/types'
import { asyncForEach, parseGeneratedCode, parseSourceCode } from '../utils' import { asyncForEach, parseGeneratedCode, parseSourceCode } from '../utils'
export type ExecuteFunction = () => Promise<any> export type ExecuteFunction = () => Promise<any>
@@ -11,8 +10,7 @@ export interface JobExecutor {
data: any, data: any,
config: any, config: any,
loginRequiredCallback?: any, loginRequiredCallback?: any,
authConfig?: AuthConfig, accessToken?: string
extraResponseAttributes?: ExtraResponseAttributes[]
) => Promise<any> ) => Promise<any>
resendWaitingRequests: () => Promise<void> resendWaitingRequests: () => Promise<void>
getRequests: () => SASjsRequest[] getRequests: () => SASjsRequest[]
@@ -30,8 +28,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
data: any, data: any,
config: any, config: any,
loginRequiredCallback?: any, loginRequiredCallback?: any,
authConfig?: AuthConfig | undefined, accessToken?: string | undefined
extraResponseAttributes?: ExtraResponseAttributes[]
): Promise<any> ): Promise<any>
resendWaitingRequests = async () => { resendWaitingRequests = async () => {
@@ -62,14 +59,14 @@ export abstract class BaseJobExecutor implements JobExecutor {
let sasWork = null let sasWork = null
if (debug) { if (debug) {
if (response?.log) { if (response?.result && response?.log) {
sourceCode = parseSourceCode(response.log) sourceCode = parseSourceCode(response.log)
generatedCode = parseGeneratedCode(response.log) generatedCode = parseGeneratedCode(response.log)
if (response?.result) { if (response.log) {
sasWork = response.result.WORK
} else {
sasWork = response.log sasWork = response.log
} else {
sasWork = response.result.WORK
} }
} else if (response?.result) { } else if (response?.result) {
sourceCode = parseSourceCode(response.result) sourceCode = parseSourceCode(response.result)

View File

@@ -1,110 +0,0 @@
import { ServerType } from '@sasjs/utils/types'
import * as NodeFormData from 'form-data'
import { ErrorResponse } from '../types/errors'
import { convertToCSV, isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
import { Sas9RequestClient } from '../request/Sas9RequestClient'
/**
* Job executor for SAS9 servers for use in Node.js environments.
* Initiates login with the provided username and password from the config
* The cookies are stored in the request client and used in subsequent
* job execution requests.
*/
export class Sas9JobExecutor extends BaseJobExecutor {
private requestClient: Sas9RequestClient
constructor(
serverUrl: string,
serverType: ServerType,
private jobsPath: string
) {
super(serverUrl, serverType)
this.requestClient = new Sas9RequestClient(serverUrl, false)
}
async execute(sasJob: string, data: any, config: any) {
const program = isRelativePath(sasJob)
? config.appLoc
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
: sasJob
let apiUrl = `${config.serverUrl}${this.jobsPath}?${'_program=' + program}`
apiUrl = `${apiUrl}${
config.username && config.password
? '&_username=' + config.username + '&_password=' + config.password
: ''
}`
let requestParams = {
...this.getRequestParams(config)
}
let formData = new NodeFormData()
if (data) {
try {
formData = generateFileUploadForm(formData, data)
} catch (e) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}
for (const key in requestParams) {
if (requestParams.hasOwnProperty(key)) {
formData.append(key, requestParams[key])
}
}
await this.requestClient.login(
config.username,
config.password,
this.jobsPath
)
const contentType =
data && Object.keys(data).length
? 'multipart/form-data; boundary=' + (formData as any)._boundary
: 'text/plain'
return await this.requestClient!.post(
apiUrl,
formData,
undefined,
contentType,
{
Accept: '*/*',
Connection: 'Keep-Alive'
}
)
}
private getRequestParams(config: any): any {
const requestParams: any = {}
if (config.debug) {
requestParams['_debug'] = 131
}
return requestParams
}
}
const generateFileUploadForm = (
formData: NodeFormData,
data: any
): NodeFormData => {
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.'
)
}
formData.append(name, csv, {
filename: `${name}.csv`,
contentType: 'application/csv'
})
}
return formData
}

View File

@@ -8,9 +8,8 @@ import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { generateTableUploadForm } from '../file/generateTableUploadForm' import { generateTableUploadForm } from '../file/generateTableUploadForm'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { SASViyaApiClient } from '../SASViyaApiClient' import { SASViyaApiClient } from '../SASViyaApiClient'
import { isRelativePath, isValidJson } from '../utils' import { isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
export interface WaitingRequstPromise { export interface WaitingRequstPromise {
promise: Promise<any> | null promise: Promise<any> | null
@@ -40,18 +39,15 @@ export class WebJobExecutor extends BaseJobExecutor {
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '') ? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob : sasJob
: sasJob : sasJob
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
if (config.serverType === ServerType.SasViya) {
const jobUri = const jobUri =
config.serverType === ServerType.SasViya config.serverType === ServerType.SasViya
? await this.getJobUri(sasJob) ? await this.getJobUri(sasJob)
: '' : ''
const apiUrl = `${config.serverUrl}${this.jobsPath}/?${
apiUrl += jobUri.length > 0 ? '&_job=' + jobUri : '' jobUri.length > 0
? '__program=' + program + '&_job=' + jobUri
apiUrl += config.contextName ? `&_contextname=${config.contextName}` : '' : '_program=' + program
} }`
let requestParams = { let requestParams = {
...this.getRequestParams(config) ...this.getRequestParams(config)
@@ -101,19 +97,6 @@ export class WebJobExecutor extends BaseJobExecutor {
this.appendRequest(res, sasJob, config.debug) this.appendRequest(res, sasJob, config.debug)
resolve(jsonResponse) resolve(jsonResponse)
} }
if (this.serverType === ServerType.Sas9 && config.debug) {
const jsonResponse = parseWeboutResponse(res.result as string)
if (jsonResponse === '') {
throw new Error(
'Valid JSON could not be extracted from response.'
)
}
isValidJson(jsonResponse)
this.appendRequest(res, sasJob, config.debug)
resolve(res.result)
}
isValidJson(res.result as string)
this.appendRequest(res, sasJob, config.debug) this.appendRequest(res, sasJob, config.debug)
resolve(res.result) resolve(res.result)
}) })

View File

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

View File

@@ -10,8 +10,6 @@ import {
} from '../types/errors' } from '../types/errors'
import { parseWeboutResponse } from '../utils/parseWeboutResponse' import { parseWeboutResponse } from '../utils/parseWeboutResponse'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
import { isValidJson } from '../utils'
export interface HttpClient { export interface HttpClient {
get<T>( get<T>(
@@ -46,11 +44,11 @@ export interface HttpClient {
} }
export class RequestClient implements HttpClient { export class RequestClient implements HttpClient {
protected csrfToken: CsrfToken = { headerName: '', value: '' } private csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined private fileUploadCsrfToken: CsrfToken | undefined
protected httpClient: AxiosInstance private httpClient: AxiosInstance
constructor(protected baseUrl: string, allowInsecure = false) { constructor(private baseUrl: string, allowInsecure = false) {
const https = require('https') const https = require('https')
if (allowInsecure && https.Agent) { if (allowInsecure && https.Agent) {
this.httpClient = axios.create({ this.httpClient = axios.create({
@@ -64,9 +62,6 @@ export class RequestClient implements HttpClient {
baseURL: baseUrl baseURL: baseUrl
}) })
} }
this.httpClient.defaults.validateStatus = (status) =>
status >= 200 && status < 305
} }
public getCsrfToken(type: 'general' | 'file' = 'general') { public getCsrfToken(type: 'general' | 'file' = 'general') {
@@ -84,7 +79,7 @@ export class RequestClient implements HttpClient {
contentType: string = 'application/json', contentType: string = 'application/json',
overrideHeaders: { [key: string]: string | number } = {}, overrideHeaders: { [key: string]: string | number } = {},
debug: boolean = false debug: boolean = false
): Promise<{ result: T; etag: string; status: number }> { ): Promise<{ result: T; etag: string }> {
const headers = { const headers = {
...this.getHeaders(accessToken, contentType), ...this.getHeaders(accessToken, contentType),
...overrideHeaders ...overrideHeaders
@@ -291,12 +286,11 @@ export class RequestClient implements HttpClient {
}) })
.then((res) => res.data) .then((res) => res.data)
.catch((error) => { .catch((error) => {
const logger = process.logger || console console.log(error)
logger.error(error)
}) })
} }
protected getHeaders = ( private getHeaders = (
accessToken: string | undefined, accessToken: string | undefined,
contentType: string contentType: string
) => { ) => {
@@ -321,7 +315,7 @@ export class RequestClient implements HttpClient {
return headers return headers
} }
protected parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => { private parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response) const token = this.parseCsrfToken(response)
if (token) { if (token) {
@@ -329,7 +323,7 @@ export class RequestClient implements HttpClient {
} }
} }
protected parseAndSetCsrfToken = (response: AxiosResponse) => { private parseAndSetCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response) const token = this.parseCsrfToken(response)
if (token) { if (token) {
@@ -353,7 +347,7 @@ export class RequestClient implements HttpClient {
} }
} }
protected handleError = async ( private handleError = async (
e: any, e: any,
callback: any, callback: any,
debug: boolean = false debug: boolean = false
@@ -411,7 +405,7 @@ export class RequestClient implements HttpClient {
throw e throw e
} }
protected parseResponse<T>(response: AxiosResponse<any>) { private parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : '' const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse let parsedResponse
let includeSAS9Log: boolean = false let includeSAS9Log: boolean = false
@@ -424,13 +418,7 @@ export class RequestClient implements HttpClient {
} }
} catch { } catch {
try { try {
const weboutResponse = parseWeboutResponse(response.data) parsedResponse = JSON.parse(parseWeboutResponse(response.data))
if (weboutResponse === '') {
throw new Error('Valid JSON could not be extracted from response.')
}
const jsonResponse = isValidJson(weboutResponse)
parsedResponse = jsonResponse
} catch { } catch {
parsedResponse = response.data parsedResponse = response.data
} }
@@ -438,15 +426,9 @@ export class RequestClient implements HttpClient {
includeSAS9Log = true includeSAS9Log = true
} }
let responseToReturn: { let responseToReturn: { result: T; etag: any; log?: string } = {
result: T
etag: any
log?: string
status: number
} = {
result: parsedResponse as T, result: parsedResponse as T,
etag, etag
status: response.status
} }
if (includeSAS9Log) { if (includeSAS9Log) {
@@ -457,7 +439,7 @@ export class RequestClient implements HttpClient {
} }
} }
export const throwIfError = (response: AxiosResponse) => { const throwIfError = (response: AxiosResponse) => {
if (response.status === 401) { if (response.status === 401) {
throw new LoginRequiredError() throw new LoginRequiredError()
} }
@@ -488,10 +470,6 @@ export const throwIfError = (response: AxiosResponse) => {
throw new AuthorizeError(response.data.message, authorizeRequestUrl) throw new AuthorizeError(response.data.message, authorizeRequestUrl)
} }
if (response.config?.url?.includes('sasAuthError')) {
throw new SAS9AuthError()
}
const error = parseError(response.data as string) const error = parseError(response.data as string)
if (error) { if (error) {

View File

@@ -1,121 +0,0 @@
import { AxiosRequestConfig } from 'axios'
import axiosCookieJarSupport from 'axios-cookiejar-support'
import * as tough from 'tough-cookie'
import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient, throwIfError } from './RequestClient'
/**
* Specific request client for SAS9 in Node.js environments.
* Handles redirects and cookie management.
*/
export class Sas9RequestClient extends RequestClient {
constructor(baseUrl: string, allowInsecure = false) {
super(baseUrl, allowInsecure)
this.httpClient.defaults.maxRedirects = 0
this.httpClient.defaults.validateStatus = (status) =>
status >= 200 && status < 303
if (axiosCookieJarSupport) {
axiosCookieJarSupport(this.httpClient)
this.httpClient.defaults.jar = new tough.CookieJar()
}
}
public async login(username: string, password: string, jobsPath: string) {
const codeInjectorPath = `/User Folders/${username}/My Folder/sasjs/runner`
if (this.httpClient.defaults.jar) {
;(this.httpClient.defaults.jar as tough.CookieJar).removeAllCookies()
await this.get(
`${jobsPath}?_program=${codeInjectorPath}&_username=${username}&_password=${password}`,
undefined,
'text/plain'
)
}
}
public async get<T>(
url: string,
accessToken: string | undefined,
contentType: string = 'application/json',
overrideHeaders: { [key: string]: string | number } = {},
debug: boolean = false
): Promise<{ result: T; etag: string; status: number }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
}
const requestConfig: AxiosRequestConfig = {
headers,
responseType: contentType === 'text/plain' ? 'text' : 'json',
withCredentials: true
}
if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined
}
return this.httpClient
.get<T>(url, requestConfig)
.then((response) => {
if (response.status === 302) {
return this.get(
response.headers['location'],
accessToken,
contentType
)
}
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(
e,
() =>
this.get<T>(url, accessToken, contentType, overrideHeaders).catch(
(err) => {
throw prefixMessage(
err,
'Error while executing handle error callback. '
)
}
),
debug
).catch((err) => {
throw prefixMessage(err, 'Error while handling error. ')
})
})
}
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(async (response) => {
if (response.status === 302) {
return await this.get(
response.headers['location'],
undefined,
contentType,
overrideHeaders
)
}
throwIfError(response)
return this.parseResponse<T>(response)
})
.catch(async (e) => {
return await this.handleError(e, () =>
this.post<T>(url, data, accessToken, contentType, overrideHeaders)
)
})
}
}

View File

@@ -1,9 +1,5 @@
/**
* @jest-environment jsdom
*/
import { FileUploader } from '../FileUploader' import { FileUploader } from '../FileUploader'
import { SASjsConfig, UploadFile } from '../types' import { UploadFile } from '../types'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import axios from 'axios' import axios from 'axios'
jest.mock('axios') jest.mock('axios')
@@ -32,51 +28,48 @@ const prepareFilesAndParams = () => {
} }
describe('FileUploader', () => { describe('FileUploader', () => {
const config: SASjsConfig = {
...new SASjsConfig(),
appLoc: '/sample/apploc'
}
const fileUploader = new FileUploader( const fileUploader = new FileUploader(
config, '/sample/apploc',
'https://sample.server.com',
'/jobs/path', '/jobs/path',
new RequestClient('https://sample.server.com') new RequestClient('https://sample.server.com')
) )
it('should upload successfully', async () => { 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(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponse }) Promise.resolve({ data: sampleResponse })
) )
const res = await fileUploader.uploadFile(sasJob, files, params) fileUploader.uploadFile(sasJob, files, params).then((res: any) => {
expect(res).toEqual(JSON.parse(sampleResponse)) expect(res).toEqual(JSON.parse(sampleResponse))
done()
})
}) })
it('should an error when no files are provided', async () => { it('should an error when no files are provided', async (done) => {
const sasJob = 'test/upload' const sasJob = 'test/upload'
const files: UploadFile[] = [] const files: UploadFile[] = []
const params = { table: 'libtable' } const params = { table: 'libtable' }
const err = await fileUploader fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('At least one file must be provided.') expect(err.error.message).toEqual('At least one file must be provided.')
done()
})
}) })
it('should throw an error when no sasJob is provided', async () => { it('should throw an error when no sasJob is provided', async (done) => {
const sasJob = '' const sasJob = ''
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
const err = await fileUploader fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('sasJob must be provided.') expect(err.error.message).toEqual('sasJob must be provided.')
done()
})
}) })
it('should throw an error when login is required', async () => { it('should throw an error when login is required', async (done) => {
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: '<form action="Logon">' }) Promise.resolve({ data: '<form action="Logon">' })
) )
@@ -84,13 +77,15 @@ describe('FileUploader', () => {
const sasJob = 'test' const sasJob = 'test'
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
const err = await fileUploader fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
.uploadFile(sasJob, files, params) expect(err.error.message).toEqual(
.catch((err: any) => err) 'You must be logged in to upload a file.'
expect(err.error.message).toEqual('You must be logged in to upload a file.') )
done()
})
}) })
it('should throw an error when invalid JSON is returned by the server', async () => { it('should throw an error when invalid JSON is returned by the server', async (done) => {
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: '{invalid: "json"' }) Promise.resolve({ data: '{invalid: "json"' })
) )
@@ -98,13 +93,13 @@ describe('FileUploader', () => {
const sasJob = 'test' const sasJob = 'test'
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
const err = await fileUploader fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('File upload request failed.') expect(err.error.message).toEqual('File upload request failed.')
done()
})
}) })
it('should throw an error when the server request fails', async () => { it('should throw an error when the server request fails', async (done) => {
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.reject({ data: '{message: "Server error"}' }) Promise.reject({ data: '{message: "Server error"}' })
) )
@@ -112,9 +107,10 @@ describe('FileUploader', () => {
const sasJob = 'test' const sasJob = 'test'
const { files, params } = prepareFilesAndParams() const { files, params } = prepareFilesAndParams()
const err = await fileUploader fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('File upload request failed.') expect(err.error.message).toEqual('File upload request failed.')
done()
})
}) })
}) })

View File

@@ -14,7 +14,7 @@ describe('FolderOperations', () => {
beforeEach(() => {}) beforeEach(() => {})
it('should move and rename folder', async () => { it('should move and rename folder', async (done) => {
mockFetchResponse(false) mockFetchResponse(false)
let res: any = await sasViyaApiClient.moveFolder( let res: any = await sasViyaApiClient.moveFolder(
@@ -26,9 +26,11 @@ describe('FolderOperations', () => {
expect(res.folder.name).toEqual('newName') expect(res.folder.name).toEqual('newName')
expect(res.folder.parentFolderUri.split('=')[1]).toEqual('/Test/toFolder') expect(res.folder.parentFolderUri.split('=')[1]).toEqual('/Test/toFolder')
done()
}) })
it('should move and keep the name of folder', async () => { it('should move and keep the name of folder', async (done) => {
mockFetchResponse(true) mockFetchResponse(true)
let res: any = await sasViyaApiClient.moveFolder( let res: any = await sasViyaApiClient.moveFolder(
@@ -40,9 +42,11 @@ describe('FolderOperations', () => {
expect(res.folder.name).toEqual('oldName') expect(res.folder.name).toEqual('oldName')
expect(res.folder.parentFolderUri.split('=')[1]).toEqual('/Test/toFolder') expect(res.folder.parentFolderUri.split('=')[1]).toEqual('/Test/toFolder')
done()
}) })
it('should only rename folder', async () => { it('should only rename folder', async (done) => {
mockFetchResponse(false) mockFetchResponse(false)
let res: any = await sasViyaApiClient.moveFolder( let res: any = await sasViyaApiClient.moveFolder(
@@ -54,6 +58,8 @@ describe('FolderOperations', () => {
expect(res.folder.name).toEqual('newName') expect(res.folder.name).toEqual('newName')
expect(res.folder.parentFolderUri.split('=')[1]).toEqual('/Test/toFolder') expect(res.folder.parentFolderUri.split('=')[1]).toEqual('/Test/toFolder')
done()
}) })
}) })

View File

@@ -1,9 +1,7 @@
import { SessionManager } from '../SessionManager' import { SessionManager } from '../SessionManager'
import { RequestClient } from '../request/RequestClient'
import { NoSessionStateError } from '../types/errors'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios' import axios from 'axios'
jest.mock('axios') jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -45,38 +43,4 @@ describe('SessionManager', () => {
).resolves.toEqual(expectedResponse) ).resolves.toEqual(expectedResponse)
}) })
}) })
describe('waitForSession', () => {
it('should reject with NoSessionStateError if SAS server did not provide session state', async () => {
const responseStatus = 304
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: '', status: responseStatus })
)
await expect(
sessionManager['waitForSession'](
{
id: 'id',
state: '',
links: [
{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }
],
attributes: {
sessionInactiveTimeout: 0
},
creationTimeStamp: ''
},
null,
'access_token'
)
).rejects.toEqual(
new NoSessionStateError(
responseStatus,
process.env.SERVER_URL as string,
'logUrl'
)
)
})
})
}) })

View File

@@ -1,31 +0,0 @@
import { isValidJson } from '../../utils'
describe('jsonValidator', () => {
it('should not throw an error with an valid json', () => {
const json = {
test: 'test'
}
expect(isValidJson(json)).toBe(json)
})
it('should not throw an error with an valid json string', () => {
const json = {
test: 'test'
}
expect(isValidJson(JSON.stringify(json))).toStrictEqual(json)
})
it('should throw an error with an invalid json', () => {
const json = `{\"test\":\"test\"\"test2\":\"test\"}`
expect(() => {
try {
isValidJson(json)
} catch (err) {
throw new Error()
}
}).toThrowError
})
})

View File

@@ -1,6 +1,6 @@
import { parseGeneratedCode } from '../../utils/index' import { parseGeneratedCode } from '../../utils/index'
it('should parse generated code', () => { it('should parse generated code', async (done) => {
expect(sampleResponse).toBeTruthy() expect(sampleResponse).toBeTruthy()
const parsedGeneratedCode = parseGeneratedCode(sampleResponse) const parsedGeneratedCode = parseGeneratedCode(sampleResponse)
@@ -15,6 +15,8 @@ it('should parse generated code', () => {
expect(generatedCodeLines[2].startsWith('MPRINT(MM_WEBOUT)')).toBeTruthy() expect(generatedCodeLines[2].startsWith('MPRINT(MM_WEBOUT)')).toBeTruthy()
expect(generatedCodeLines[3].startsWith('MPRINT(MM_WEBRIGHT)')).toBeTruthy() expect(generatedCodeLines[3].startsWith('MPRINT(MM_WEBRIGHT)')).toBeTruthy()
expect(generatedCodeLines[4].startsWith('MPRINT(MM_WEBOUT)')).toBeTruthy() expect(generatedCodeLines[4].startsWith('MPRINT(MM_WEBOUT)')).toBeTruthy()
done()
}) })
/* tslint:disable */ /* tslint:disable */

View File

@@ -1,6 +1,6 @@
import { parseSourceCode } from '../../utils/index' import { parseSourceCode } from '../../utils/index'
it('should parse SAS9 source code', async () => { it('should parse SAS9 source code', async (done) => {
expect(sampleResponse).toBeTruthy() expect(sampleResponse).toBeTruthy()
const parsedSourceCode = parseSourceCode(sampleResponse) const parsedSourceCode = parseSourceCode(sampleResponse)
@@ -15,6 +15,8 @@ it('should parse SAS9 source code', async () => {
expect(sourceCodeLines[2].startsWith('8')).toBeTruthy() expect(sourceCodeLines[2].startsWith('8')).toBeTruthy()
expect(sourceCodeLines[3].startsWith('9')).toBeTruthy() expect(sourceCodeLines[3].startsWith('9')).toBeTruthy()
expect(sourceCodeLines[4].startsWith('10')).toBeTruthy() expect(sourceCodeLines[4].startsWith('10')).toBeTruthy()
done()
}) })
/* tslint:disable */ /* tslint:disable */

View File

@@ -1,8 +0,0 @@
import { Link } from './Link'
export interface File {
id: string
name: string
parentUri: string
links: Link[]
}

View File

@@ -1,5 +0,0 @@
declare namespace NodeJS {
export interface Process {
logger?: import('@sasjs/utils/logger').Logger
}
}

View File

@@ -40,19 +40,23 @@ export class SASjsConfig {
*/ */
debug: boolean = true debug: boolean = true
/** /**
* The name of the compute context to use when calling the Viya services directly. * The name of the compute context to use when calling the Viya APIs directly.
* Example value: 'SAS Job Execution compute context' * Example value: 'SAS Job Execution compute context'
* If set to missing or empty, and useComputeApi is true, the adapter will use
* the JES APIs. If provided, the Job Code will be executed in pooled
* compute sessions on this named context.
*/ */
contextName: string = '' contextName: string = ''
/** /**
* If it's `false` adapter will use the JES API as connection approach. To enhance VIYA * Set to `false` to use the Job Execution Web Service. To enhance VIYA
* performance, set to `true` and provide a `contextName` on which to run * performance, set to `true` and provide a `contextName` on which to run
* the code. When running on a named context, the code executes under the * the code. When running on a named context, the code executes under the
* user identity. When running as a Job Execution service, the code runs * user identity. When running as a Job Execution service, the code runs
* under the identity in the JES context. If `useComputeApi` is `null` or `undefined`, the service will run as a Job, except * under the identity in the JES context. If no `contextName` is provided,
* and `useComputeApi` is `true`, then the service will run as a Job, except
* 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: boolean | null = null useComputeApi = false
/** /**
* Defaults to `false`. * Defaults to `false`.
* When set to `true`, the adapter will allow requests to SAS servers that use a self-signed SSL certificate. * When set to `true`, the adapter will allow requests to SAS servers that use a self-signed SSL certificate.

View File

@@ -1,15 +0,0 @@
export class NoSessionStateError extends Error {
constructor(
public serverResponseStatus: number,
public sessionStateUrl: string,
public logUrl: string
) {
super(
`Could not get session state. Server responded with ${serverResponseStatus} whilst checking state: ${sessionStateUrl}`
)
this.name = 'NoSessionStatus'
Object.setPrototypeOf(this, NoSessionStateError.prototype)
}
}

View File

@@ -1,9 +0,0 @@
export class SAS9AuthError extends Error {
constructor() {
super(
'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.'
)
this.name = 'AuthorizeError'
Object.setPrototypeOf(this, SAS9AuthError.prototype)
}
}

View File

@@ -5,4 +5,3 @@ export * from './JobExecutionError'
export * from './LoginRequiredError' export * from './LoginRequiredError'
export * from './NotFoundError' export * from './NotFoundError'
export * from './ErrorResponse' export * from './ErrorResponse'
export * from './NoSessionStateError'

View File

@@ -1,7 +1,6 @@
export * from './Context' export * from './Context'
export * from './CsrfToken' export * from './CsrfToken'
export * from './Folder' export * from './Folder'
export * from './File'
export * from './Job' export * from './Job'
export * from './JobDefinition' export * from './JobDefinition'
export * from './JobResult' export * from './JobResult'

View File

@@ -15,14 +15,12 @@ export const fetchLogByChunks = async (
logUrl: string, logUrl: string,
logCount: number logCount: number
): Promise<string> => { ): Promise<string> => {
const logger = process.logger || console
let log: string = '' let log: string = ''
const loglimit = logCount < 10000 ? logCount : 10000 const loglimit = logCount < 10000 ? logCount : 10000
let start = 0 let start = 0
do { do {
logger.info( console.log(
`Fetching logs from line no: ${start + 1} to ${ `Fetching logs from line no: ${start + 1} to ${
start + loglimit start + loglimit
} of ${logCount}.` } of ${logCount}.`

View File

@@ -12,4 +12,3 @@ export * from './serialize'
export * from './splitChunks' export * from './splitChunks'
export * from './parseWeboutResponse' export * from './parseWeboutResponse'
export * from './fetchLogByChunks' export * from './fetchLogByChunks'
export * from './isValidJson'

View File

@@ -1,13 +0,0 @@
/**
* Checks if string is in valid JSON format else throw error.
* @param str - string to check.
*/
export const isValidJson = (str: string | object) => {
try {
if (typeof str === 'object') return str
return JSON.parse(str)
} catch (e) {
throw new Error('Invalid JSON response.')
}
}

View File

@@ -1,32 +1,21 @@
const path = require('path') const path = require('path')
const webpack = require('webpack') const webpack = require('webpack')
const terserPlugin = require('terser-webpack-plugin') const terserPlugin = require('terser-webpack-plugin')
const nodePolyfillPlugin = require('node-polyfill-webpack-plugin')
const defaultPlugins = [
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/),
new webpack.SourceMapDevToolPlugin({
filename: null,
exclude: [/node_modules/],
test: /\.ts($|\?)/i
})
]
const optimization = {
minimize: true,
minimizer: [
new terserPlugin({
parallel: true,
terserOptions: {}
})
]
}
const browserConfig = { const browserConfig = {
entry: './src/index.ts', entry: './src/index.ts',
devtool: 'inline-source-map', devtool: 'inline-source-map',
mode: 'production', mode: 'production',
optimization: optimization, optimization: {
minimizer: [
new terserPlugin({
cache: true,
parallel: true,
sourceMap: true,
terserOptions: {}
})
]
},
module: { module: {
rules: [ rules: [
{ {
@@ -38,7 +27,7 @@ const browserConfig = {
}, },
resolve: { resolve: {
extensions: ['.ts', '.js'], extensions: ['.ts', '.js'],
fallback: { https: false, fs: false, readline: false } fallback: { https: false }
}, },
output: { output: {
filename: 'index.js', filename: 'index.js',
@@ -47,27 +36,17 @@ const browserConfig = {
library: 'SASjs' library: 'SASjs'
}, },
plugins: [ plugins: [
...defaultPlugins, new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/),
new webpack.ProvidePlugin({ new webpack.SourceMapDevToolPlugin({
process: 'process/browser' filename: null,
}), exclude: [/node_modules/],
new nodePolyfillPlugin() test: /\.ts($|\?)/i
})
] ]
} }
const browserConfigWithoutProcessPlugin = {
entry: browserConfig.entry,
devtool: browserConfig.devtool,
mode: browserConfig.mode,
optimization: optimization,
module: browserConfig.module,
resolve: browserConfig.resolve,
output: browserConfig.output,
plugins: defaultPlugins
}
const nodeConfig = { const nodeConfig = {
...browserConfigWithoutProcessPlugin, ...browserConfig,
target: 'node', target: 'node',
entry: './node/index.ts', entry: './node/index.ts',
output: { output: {