1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 09:24:35 +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
75 changed files with 4529 additions and 42463 deletions

View File

@@ -1,24 +0,0 @@
{
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "medjedovicm",
"name": "Mihajlo Medjedovic",
"avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4",
"profile": "https://github.com/medjedovicm",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"projectName": "adapter",
"projectOwner": "sasjs",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true
}

View File

@@ -6,7 +6,7 @@ GREEN="\033[1;32m"
# temporary file which holds the message).
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"
exit 0
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
- medjedovicm
- allanbowe
- sabhas
- name: SASjs QA
reviewers: 1
usernames:
- VladislavParhomchik

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node-version: [15.x]
node-version: [12.x]
steps:
- uses: actions/checkout@v2
@@ -27,10 +27,6 @@ jobs:
run: npm run lint
- name: Run unit tests
run: npm test
- name: Generate coverage report
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Package
run: npm run package:lib
env:

View File

@@ -12,9 +12,7 @@ What code changes have been made to achieve the intent.
## Checks
No PR (that involves a non-trivial code change) should be merged, unless all items below are confirmed! If an urgent fix is needed - use a tar file.
- [ ] Code is formatted correctly (`npm run lint:fix`).
- [ ] All unit tests are passing (`npm test`).
- [ ] All `sasjs-cli` unit tests are passing (`npm test`).
- [ ] All `sasjs-tests` are passing (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)).
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya

View File

@@ -172,43 +172,35 @@ Configuration on the client side involves passing an object on startup, which ca
* `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.
* `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.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
* `useComputeApi` - if `true` and the serverType is `SASVIYA` then the REST APIs will be called directly (rather than using the JES web service).
* `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).
### 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",
serverType:"SASVIYA",
contextName: 'yourComputeContext'
serverType:"SASVIYA"
}
```
Note - to use the web approach, the `useComputeApi` property must be `undefined` or `null`.
### 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.
This approach (`useComputeApi: false`) also ensures that jobs are displayed in Environment Manager.
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.
```
{
appLoc:"/Your/Path",
serverType:"SASVIYA",
useComputeApi: false,
contextName: 'yourComputeContext'
useComputeApi: true
}
```
### 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.
With this approach (`useComputeApi: true`), the requests/logs will _not_ appear in the list in Environment manager.
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.
```
{
@@ -233,24 +225,4 @@ If you are a SAS 9 or SAS Viya customer you can also request a copy of [Data Con
If you find this library useful, help us grow our star graph!
![](https://starchart.cc/sasjs/adapter.svg)
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
![](https://starchart.cc/sasjs/adapter.svg)

20225
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",
"homepage": "https://adapter.sasjs.io",
"scripts": {
"build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./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",
"build": "rimraf build && rimraf node && mkdir node && cp -r src/* node && webpack && rimraf build/src && rimraf node",
"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",
"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: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}'",
"test": "jest --silent --coverage",
"prepublishOnly": "cp -r ./build/* . && rm -rf ./build",
"postpublish": "git clean -fd",
"semantic-release": "semantic-release",
"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": {
"access": "public"
@@ -38,43 +38,31 @@
},
"license": "ISC",
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/form-data": "^2.5.0",
"@types/jest": "^26.0.24",
"@types/mime": "^2.0.3",
"@types/tough-cookie": "^4.0.1",
"copyfiles": "^2.4.1",
"@types/jest": "^26.0.22",
"cp": "^0.2.0",
"dotenv": "^10.0.0",
"jest": "^27.0.6",
"dotenv": "^8.2.0",
"jest": "^26.6.3",
"jest-extended": "^0.11.5",
"node-polyfill-webpack-plugin": "^1.1.4",
"path": "^0.12.7",
"process": "^0.11.10",
"rimraf": "^3.0.2",
"semantic-release": "^17.4.4",
"terser-webpack-plugin": "^5.1.4",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.2",
"semantic-release": "^17.4.2",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^25.5.1",
"ts-loader": "^9.1.2",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typedoc": "^0.21.4",
"typedoc-neo-theme": "^1.1.1",
"typedoc": "^0.20.35",
"typedoc-neo-theme": "^1.1.0",
"typedoc-plugin-external-module-name": "^4.0.6",
"typescript": "^4.3.5",
"webpack": "^5.44.0",
"webpack-cli": "^4.7.2"
"typescript": "^3.9.9",
"webpack": "^5.33.2",
"webpack-cli": "^4.7.0"
},
"main": "index.js",
"dependencies": {
"@sasjs/utils": "^2.27.1",
"@sasjs/utils": "^2.10.2",
"axios": "^0.21.1",
"axios-cookiejar-support": "^1.0.1",
"form-data": "^4.0.0",
"https": "^1.0.0",
"tough-cookie": "^4.0.0"
},
"engines": {
"node": ">=15"
"https": "^1.0.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.
```bash
```
npm run update:adapter
```
@@ -37,23 +37,10 @@ To be able to run the `deploy` script, two environment variables need to be set:
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
```
If you are on `WINDOWS`, you will first need to install one dependency:
```bash
npm i -g copyfiles
```
and then run to build:
```bash
npm run update:adapter && npm run build
```
when it finishes run to deploy:
```bash
scp -rp ./build/* me@my-sas-server.com:/var/www/html/my-folder/sasjs-tests
```
If you'd like to deploy just `sasjs-tests` without changing the adapter version, you can use the `deploy:tests` script, while also setting the same environment variables as above.
## 3. Creating the required SAS services
@@ -62,7 +49,8 @@ The below services need to be created on your SAS server, at the location specif
### SAS 9
```sas
```
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc;
filename ft15f001 temp;
@@ -84,24 +72,11 @@ parmcards4;
%webout(CLOSE)
;;;;
%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
```
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc;
filename ft15f001 temp;
@@ -140,15 +115,6 @@ If you can trust yourself when all men doubt you,
But make allowance for their doubting too;
;;;;
%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.

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/test-framework": "^1.4.0",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.41",
"@types/node": "^14.14.25",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7",
@@ -23,8 +23,7 @@
"test": "react-scripts test",
"eject": "react-scripts eject",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
"deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
"deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH",
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
},
"eslintConfig": {

View File

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

View File

@@ -145,29 +145,6 @@ export const basicTests = (
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',
data,
{},
undefined,
'',
true
)
},

View File

@@ -176,59 +176,11 @@ export const sendObjTests = (adapter: SASjs): TestSuite => ({
name: 'sendObj',
tests: [
{
title: 'Table name starts with numeric',
title: 'Invalid column name',
description: 'Should throw an error',
test: async () => {
const invalidData: any = {
'1InvalidTable': [{ 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' }]]
'1 invalid table': [{ col1: 42 }]
}
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 { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from './request/RequestClient'
import { AuthConfig } from '@sasjs/utils/types'
export class ContextManager {
private defaultComputeContexts = [
@@ -329,12 +328,12 @@ export class ContextManager {
public async getExecutableContexts(
executeScript: Function,
authConfig?: AuthConfig
accessToken?: string
) {
const { result: contexts } = await this.requestClient
.get<{ items: Context[] }>(
`${this.serverUrl}/compute/contexts?limit=10000`,
authConfig?.access_token
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while fetching compute contexts.')
@@ -351,7 +350,7 @@ export class ContextManager {
`test-${context.name}`,
linesOfCode,
context.name,
authConfig,
accessToken,
null,
false,
true,

View File

@@ -1,20 +1,16 @@
import { isUrl, getValidJson, parseSasViyaDebugResponse } from './utils'
import { isUrl } from './utils'
import { UploadFile } from './types/UploadFile'
import { ErrorResponse, LoginRequiredError } from './types/errors'
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 {
constructor(
private sasjsConfig: SASjsConfig,
private appLoc: string,
serverUrl: string,
private jobsPath: string,
private requestClient: RequestClient
) {
if (this.sasjsConfig.serverUrl) isUrl(this.sasjsConfig.serverUrl)
if (serverUrl) isUrl(serverUrl)
}
public uploadFile(sasJob: string, files: UploadFile[], params: any) {
@@ -33,8 +29,8 @@ export class FileUploader {
}
}
const program = this.sasjsConfig.appLoc
? this.sasjsConfig.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
const program = this.appLoc
? this.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.jobsPath}/?${
'_program=' + program
@@ -48,12 +44,6 @@ export class FileUploader {
const csrfToken = this.requestClient.getCsrfToken('file')
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 = {
'cache-control': 'no-cache',
@@ -63,30 +53,9 @@ export class FileUploader {
return this.requestClient
.post(uploadUrl, formData, undefined, 'application/json', headers)
.then(async (res) => {
// for web approach on Viya
if (
this.sasjsConfig.debug &&
(this.sasjsConfig.useComputeApi === null ||
this.sasjsConfig.useComputeApi === undefined) &&
this.sasjsConfig.serverType === ServerType.SasViya
) {
const jsonResponse = await parseSasViyaDebugResponse(
res.result as string,
this.requestClient,
this.sasjsConfig.serverUrl
)
return typeof jsonResponse === 'string'
? getValidJson(jsonResponse)
: jsonResponse
}
return typeof res.result === 'string'
? getValidJson(res.result)
: res.result
//TODO: append to SASjs requests
})
.then((res) =>
typeof res.result === 'string' ? JSON.parse(res.result) : res.result
)
.catch((err: Error) => {
if (err instanceof LoginRequiredError) {
return Promise.reject(

View File

@@ -1,6 +1,4 @@
import { generateTimestamp } from '@sasjs/utils/time'
import * as NodeFormData from 'form-data'
import { Sas9RequestClient } from './request/Sas9RequestClient'
import axios, { AxiosInstance } from 'axios'
import { isUrl } from './utils'
/**
@@ -8,11 +6,11 @@ import { isUrl } from './utils'
*
*/
export class SAS9ApiClient {
private requestClient: Sas9RequestClient
private httpClient: AxiosInstance
constructor(private serverUrl: string, private jobsPath: string) {
constructor(private serverUrl: string) {
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.
* @param linesOfCode - an array of code lines to execute.
* @param userName - the user name to log into the current SAS server.
* @param password - the password to log into the current SAS server.
* @param serverName - the server to execute the code on.
* @param repositoryName - the repository to execute the code in.
*/
public async executeScript(
linesOfCode: string[],
userName: string,
password: string
serverName: 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 forceOutputCode = [
'data _null_;',
'file _webout;',
`put 'Executed sasjs run';`,
'run;'
]
const formData = generateFileUploadForm(
[...linesOfCode, ...forceOutputCode].join('\n')
const executeScriptResponse = await this.httpClient.put(
`/sas/servers/${serverName}/cmd?repositoryName=${repositoryName}`,
`command=${requestPayload}`,
{
headers: {
Accept: 'application/json'
},
responseType: 'text'
}
)
const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner`
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
return executeScriptResponse.data
}
}
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

@@ -1,4 +1,10 @@
import { isRelativePath, isUri, isUrl } from './utils'
import {
convertToCSV,
isRelativePath,
isUri,
isUrl,
fetchLogByChunks
} from './utils'
import * as NodeFormData from 'form-data'
import {
Job,
@@ -6,24 +12,24 @@ import {
Context,
ContextAllAttributes,
Folder,
File,
EditContextInput,
JobDefinition,
PollOptions
} from './types'
import { JobExecutionError } from './types/errors'
import {
ComputeJobExecutionError,
JobExecutionError,
NotFoundError
} from './types/errors'
import { formatDataForRequest } from './utils/formatDataForRequest'
import { SessionManager } from './SessionManager'
import { ContextManager } from './ContextManager'
import { SasAuthResponse, MacroVar, AuthConfig } from '@sasjs/utils/types'
import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
import { Logger, LogLevel } from '@sasjs/utils/logger'
import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
import { RequestClient } from './request/RequestClient'
import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { pollJobState } from './api/viya/pollJobState'
import { getTokens } from './auth/getTokens'
import { uploadTables } from './api/viya/uploadTables'
import { executeScript } from './api/viya/executeScript'
import { getAccessToken } from './auth/getAccessToken'
import { refreshTokens } from './auth/refreshTokens'
/**
* A client for interfacing with the SAS Viya REST API.
@@ -122,14 +128,14 @@ export class SASViyaApiClient {
/**
* 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)
return await this.contextManager.getExecutableContexts(
bindedExecuteScript,
authConfig
accessToken
)
}
@@ -159,6 +165,13 @@ export class SASViyaApiClient {
throw new Error(`Execution context ${contextName} not found.`)
}
const createSessionRequest = {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
const { result: createdSession } = await this.requestClient.post<Session>(
`/compute/contexts/${executionContext.id}/sessions`,
{},
@@ -251,44 +264,261 @@ export class SASViyaApiClient {
* @param jobPath - the path to the file being submitted for execution.
* @param linesOfCode - an array of code lines to execute.
* @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 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 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 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(
jobPath: string,
linesOfCode: string[],
contextName: string,
authConfig?: AuthConfig,
accessToken?: string,
data = null,
debug: boolean = false,
expectWebout = false,
waitForResult = true,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar
printPid = false
): Promise<any> {
return executeScript(
this.requestClient,
this.sessionManager,
this.rootFolderName,
jobPath,
linesOfCode,
contextName,
authConfig,
data,
debug,
expectWebout,
waitForResult,
pollOptions,
printPid,
variables
)
try {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
let executionSessionId: string
const session = await this.sessionManager
.getSession(accessToken)
.catch((err) => {
throw prefixMessage(err, 'Error while getting session. ')
})
executionSessionId = session!.id
if (printPid) {
const { result: jobIdVariable } = await this.sessionManager
.getVariable(executionSessionId, 'SYSJOBID', accessToken)
.catch((err) => {
throw prefixMessage(err, 'Error while getting session variable. ')
})
if (jobIdVariable && jobIdVariable.value) {
const relativeJobPath = this.rootFolderName
? jobPath.split(this.rootFolderName).join('').replace(/^\//, '')
: jobPath
const logger = new Logger(debug ? LogLevel.Debug : LogLevel.Info)
logger.info(
`Triggered '${relativeJobPath}' with PID ${
jobIdVariable.value
} at ${timestampToYYYYMMDDHHMMSS()}`
)
}
}
const jobArguments: { [key: string]: any } = {
_contextName: contextName,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
if (debug) {
jobArguments['_OMITTEXTLOG'] = false
jobArguments['_OMITSESSIONRESULTS'] = false
jobArguments['_DEBUG'] = 131
}
let fileName
if (isRelativePath(jobPath)) {
fileName = `exec-${
jobPath.includes('/') ? jobPath.split('/')[1] : jobPath
}`
} else {
const jobPathParts = jobPath.split('/')
fileName = jobPathParts.pop()
}
let jobVariables: any = {
SYS_JES_JOB_URI: '',
_program: isRelativePath(jobPath)
? this.rootFolderName + '/' + jobPath
: jobPath
}
let files: any[] = []
if (data) {
if (JSON.stringify(data).includes(';')) {
files = await this.uploadTables(data, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while uploading tables. ')
})
jobVariables['_webin_file_count'] = files.length
files.forEach((fileInfo, index) => {
jobVariables[
`_webin_fileuri${index + 1}`
] = `/files/files/${fileInfo.file.id}`
jobVariables[`_webin_name${index + 1}`] = fileInfo.tableName
})
} else {
jobVariables = { ...jobVariables, ...formatDataForRequest(data) }
}
}
// Execute job in session
const jobRequestBody = {
name: fileName,
description: 'Powered by SASjs',
code: linesOfCode,
variables: jobVariables,
arguments: jobArguments
}
const { result: postedJob, etag } = await this.requestClient
.post<Job>(
`/compute/sessions/${executionSessionId}/jobs`,
jobRequestBody,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while posting job. ')
})
if (!waitForResult) return session
if (debug) {
console.log(`Job has been submitted for '${fileName}'.`)
console.log(
`You can monitor the job progress at '${this.serverUrl}${
postedJob.links.find((l: any) => l.rel === 'state')!.href
}'.`
)
}
const jobStatus = await this.pollJobState(
postedJob,
etag,
accessToken,
pollOptions
).catch(async (err) => {
const error = err?.response?.data
const result = /err=[0-9]*,/.exec(error)
const errorCode = '5113'
if (result?.[0]?.slice(4, -1) === errorCode) {
const sessionLogUrl =
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
const logCount = 1000000
err.log = await fetchLogByChunks(
this.requestClient,
accessToken!,
sessionLogUrl,
logCount
)
}
throw prefixMessage(err, 'Error while polling job status. ')
})
const { result: currentJob } = await this.requestClient
.get<Job>(
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while getting job. ')
})
let jobResult
let log = ''
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (debug && logLink) {
const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks(
this.requestClient,
accessToken!,
logUrl,
logCount
)
}
if (jobStatus === 'failed' || jobStatus === 'error') {
return Promise.reject(new ComputeJobExecutionError(currentJob, log))
}
let resultLink
if (expectWebout) {
resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
} else {
return { job: currentJob, log }
}
if (resultLink) {
jobResult = await this.requestClient
.get<any>(resultLink, accessToken, 'text/plain')
.catch(async (e) => {
if (e instanceof NotFoundError) {
if (logLink) {
const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks(
this.requestClient,
accessToken!,
logUrl,
logCount
)
return Promise.reject({
status: 500,
log
})
}
}
return {
result: JSON.stringify(e)
}
})
}
await this.sessionManager
.clearSession(executionSessionId, accessToken)
.catch((err) => {
throw prefixMessage(err, 'Error while clearing session. ')
})
return { result: jobResult?.result, log }
} catch (e) {
if (e && e.status === 404) {
return this.executeScript(
jobPath,
linesOfCode,
contextName,
accessToken,
data,
debug,
false,
true
)
} else {
throw prefixMessage(e, 'Error while executing script. ')
}
}
}
/**
@@ -302,50 +532,6 @@ export class SASViyaApiClient {
.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)
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.
* @param folderName - the name of the new folder.
@@ -363,7 +549,6 @@ export class SASViyaApiClient {
accessToken?: string,
isForced?: boolean
): Promise<Folder> {
const logger = process.logger || console
if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.')
}
@@ -371,7 +556,7 @@ export class SASViyaApiClient {
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
if (!parentFolderUri) {
logger.info(
console.log(
`Parent folder at path '${parentFolderPath}' is not present.`
)
@@ -383,7 +568,7 @@ export class SASViyaApiClient {
if (newParentFolderPath === '') {
throw new Error('Root folder has to be present on the server.')
}
logger.info(
console.log(
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
)
const parentFolder = await this.createFolder(
@@ -392,7 +577,7 @@ export class SASViyaApiClient {
undefined,
accessToken
)
logger.info(
console.log(
`Parent folder '${newFolderName}' has been successfully created.`
)
parentFolderUri = `/folders/folders/${parentFolder.id}`
@@ -520,7 +705,39 @@ export class SASViyaApiClient {
clientSecret: string,
authCode: string
): Promise<SasAuthResponse> {
return getAccessToken(this.requestClient, clientId, clientSecret, authCode)
const url = this.serverUrl + '/SASLogon/oauth/token'
let token
if (typeof Buffer === 'undefined') {
token = btoa(clientId + ':' + clientSecret)
} else {
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
}
const headers = {
Authorization: 'Basic ' + token
}
let formData
if (typeof FormData === 'undefined') {
formData = new NodeFormData()
formData.append('grant_type', 'authorization_code')
formData.append('code', authCode)
} else {
formData = new FormData()
formData.append('grant_type', 'authorization_code')
formData.append('code', authCode)
}
const authResponse = await this.requestClient
.post(
url,
formData,
undefined,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then((res) => res.result as SasAuthResponse)
return authResponse
}
/**
@@ -534,12 +751,39 @@ export class SASViyaApiClient {
clientSecret: string,
refreshToken: string
) {
return refreshTokens(
this.requestClient,
clientId,
clientSecret,
refreshToken
)
const url = this.serverUrl + '/SASLogon/oauth/token'
let token
if (typeof Buffer === 'undefined') {
token = btoa(clientId + ':' + clientSecret)
} else {
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
}
const headers = {
Authorization: 'Basic ' + token
}
let formData
if (typeof FormData === 'undefined') {
formData = new NodeFormData()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)
} else {
formData = new FormData()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)
}
const authResponse = await this.requestClient
.post<SasAuthResponse>(
url,
formData,
undefined,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then((res) => res.result)
return authResponse
}
/**
@@ -570,25 +814,18 @@ export class SASViyaApiClient {
* @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 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(
sasJob: string,
contextName: string,
debug?: boolean,
data?: any,
authConfig?: AuthConfig,
accessToken?: string,
waitForResult = true,
expectWebout = false,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar
printPid = false
) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(this.requestClient, authConfig))
}
if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error(
'Relative paths cannot be used without specifying a root folder name'
@@ -602,7 +839,7 @@ export class SASViyaApiClient {
? `${this.rootFolderName}/${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. ')
})
@@ -614,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)
if (!jobToExecute) {
@@ -634,7 +877,7 @@ export class SASViyaApiClient {
const { result: jobDefinition } = await this.requestClient
.get<JobDefinition>(
`${this.serverUrl}${jobDefinitionLink.href}`,
access_token
accessToken
)
.catch((err) => {
throw prefixMessage(err, 'Error while getting job definition. ')
@@ -654,14 +897,13 @@ export class SASViyaApiClient {
sasJob,
linesToExecute,
contextName,
authConfig,
accessToken,
data,
debug,
expectWebout,
waitForResult,
pollOptions,
printPid,
variables
printPid
)
}
@@ -678,12 +920,8 @@ export class SASViyaApiClient {
contextName: string,
debug: boolean,
data?: any,
authConfig?: AuthConfig
accessToken?: string
) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(this.requestClient, authConfig))
}
if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error(
'Relative paths cannot be used without specifying a root folder name.'
@@ -696,7 +934,7 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}`
: folderPath
await this.populateFolderMap(fullFolderPath, access_token)
await this.populateFolderMap(fullFolderPath, accessToken)
const jobFolder = this.folderMap.get(fullFolderPath)
if (!jobFolder) {
@@ -709,7 +947,7 @@ export class SASViyaApiClient {
let files: any[] = []
if (data && Object.keys(data).length) {
files = await this.uploadTables(data, access_token)
files = await this.uploadTables(data, accessToken)
}
if (!jobToExecute) {
@@ -721,7 +959,7 @@ export class SASViyaApiClient {
const { result: jobDefinition } = await this.requestClient.get<Job>(
`${this.serverUrl}${jobDefinitionLink}`,
access_token
accessToken
)
const jobArguments: { [key: string]: any } = {
@@ -754,19 +992,21 @@ export class SASViyaApiClient {
jobDefinition,
arguments: jobArguments
}
const { result: postedJob } = await this.requestClient.post<Job>(
const { result: postedJob, etag } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody,
access_token
)
const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
(err) => {
throw prefixMessage(err, 'Error while polling job status. ')
}
accessToken
)
const jobStatus = await this.pollJobState(
postedJob,
etag,
accessToken
).catch((err) => {
throw prefixMessage(err, 'Error while polling job status. ')
})
const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token
accessToken
)
let jobResult
@@ -777,13 +1017,13 @@ export class SASViyaApiClient {
if (resultLink) {
jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`,
access_token,
accessToken,
'text/plain'
)
}
if (debug && logLink) {
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'))
}
if (jobStatus === 'failed') {
@@ -829,22 +1069,147 @@ export class SASViyaApiClient {
this.folderMap.set(path, itemsAtRoot)
}
// REFACTOR: set default value for 'pollOptions' attribute
private async pollJobState(
postedJob: Job,
authConfig?: AuthConfig,
postedJob: any,
etag: string | null,
accessToken?: string,
pollOptions?: PollOptions
) {
return pollJobState(
this.requestClient,
postedJob,
this.debug,
authConfig,
pollOptions
)
let POLL_INTERVAL = 300
let MAX_POLL_COUNT = 1000
let MAX_ERROR_COUNT = 5
if (pollOptions) {
POLL_INTERVAL = pollOptions.POLL_INTERVAL || POLL_INTERVAL
MAX_POLL_COUNT = pollOptions.MAX_POLL_COUNT || MAX_POLL_COUNT
}
let postedJobState = ''
let pollCount = 0
let errorCount = 0
const headers: any = {
'Content-Type': 'application/json',
'If-None-Match': etag
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
if (!stateLink) {
Promise.reject(`Job state link was not found.`)
}
const { result: state } = await this.requestClient
.get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
accessToken,
'text/plain',
{},
this.debug
)
.catch((err) => {
console.error(
`Error fetching job state from ${this.serverUrl}${stateLink.href}. Starting poll, assuming job to be running.`,
err
)
return { result: 'unavailable' }
})
const currentState = state.trim()
if (currentState === 'completed') {
return Promise.resolve(currentState)
}
return new Promise(async (resolve, _) => {
let printedState = ''
const interval = setInterval(async () => {
if (
postedJobState === 'running' ||
postedJobState === '' ||
postedJobState === 'pending' ||
postedJobState === 'unavailable'
) {
if (stateLink) {
const { result: jobState } = await this.requestClient
.get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
accessToken,
'text/plain',
{},
this.debug
)
.catch((err) => {
errorCount++
if (
pollCount >= MAX_POLL_COUNT ||
errorCount >= MAX_ERROR_COUNT
) {
throw prefixMessage(
err,
'Error while getting job state after interval. '
)
}
console.error(
`Error fetching job state from ${this.serverUrl}${stateLink.href}. Resuming poll, assuming job to be running.`,
err
)
return { result: 'unavailable' }
})
postedJobState = jobState.trim()
if (postedJobState != 'unavailable' && errorCount > 0) {
errorCount = 0
}
if (this.debug && printedState !== postedJobState) {
console.log('Polling job status...')
console.log(`Current job state: ${postedJobState}`)
printedState = postedJobState
}
pollCount++
if (pollCount >= MAX_POLL_COUNT) {
resolve(postedJobState)
}
}
} else {
clearInterval(interval)
resolve(postedJobState)
}
}, POLL_INTERVAL)
})
}
private async uploadTables(data: any, accessToken?: string) {
return uploadTables(this.requestClient, data, accessToken)
const uploadedFiles = []
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
for (const tableName in data) {
const csv = convertToCSV(data[tableName])
if (csv === 'ERROR: LARGE STRING LENGTH') {
throw new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
}
const uploadResponse = await this.requestClient
.uploadFile(`${this.serverUrl}/files/files#rawUpload`, csv, accessToken)
.catch((err) => {
throw prefixMessage(err, 'Error while uploading file. ')
})
uploadedFiles.push({ tableName, file: uploadResponse.result })
}
return uploadedFiles
}
private async getFolderDetails(
@@ -933,6 +1298,14 @@ export class SASViyaApiClient {
? sourceFolder
: await this.getFolderUri(sourceFolder, accessToken)
const requestInfo = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessToken
}
}
const { result: members } = await this.requestClient.get<{ items: any[] }>(
`${this.serverUrl}${sourceFolderUri}/members?limit=${limit}`,
accessToken
@@ -982,9 +1355,6 @@ export class SASViyaApiClient {
accessToken
)
if (!sourceFolderUri) {
return undefined
}
const sourceFolderId = sourceFolderUri?.split('/').pop()
const { result: folder } = await this.requestClient

View File

@@ -4,19 +4,13 @@ import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient'
import { FileUploader } from './FileUploader'
import { AuthManager } from './auth'
import {
ServerType,
MacroVar,
AuthConfig,
ExtraResponseAttributes
} from '@sasjs/utils/types'
import { ServerType } from '@sasjs/utils/types'
import { RequestClient } from './request/RequestClient'
import {
JobExecutor,
WebJobExecutor,
ComputeJobExecutor,
JesJobExecutor,
Sas9JobExecutor
JesJobExecutor
} from './job-execution'
import { ErrorResponse } from './types/errors'
@@ -28,7 +22,7 @@ const defaultConfig: SASjsConfig = {
serverType: ServerType.SasViya,
debug: false,
contextName: 'SAS Job Execution compute context',
useComputeApi: null,
useComputeApi: false,
allowInsecureRequests: false
}
@@ -47,7 +41,6 @@ export default class SASjs {
private webJobExecutor: JobExecutor | null = null
private computeJobExecutor: JobExecutor | null = null
private jesJobExecutor: JobExecutor | null = null
private sas9JobExecutor: JobExecutor | null = null
constructor(config?: any) {
this.sasjsConfig = {
@@ -64,15 +57,15 @@ export default class SASjs {
public async executeScriptSAS9(
linesOfCode: string[],
userName: string,
password: string
serverName: string,
repositoryName: string
) {
this.isMethodSupported('executeScriptSAS9', ServerType.Sas9)
return await this.sas9ApiClient?.executeScript(
linesOfCode,
userName,
password
serverName,
repositoryName
)
}
@@ -107,12 +100,12 @@ export default class SASjs {
/**
* 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)
return await this.sasViyaApiClient!.getExecutableContexts(authConfig)
return await this.sasViyaApiClient!.getExecutableContexts(accessToken)
}
/**
@@ -244,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 linesOfCode - lines of sas code from the file to run.
* @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
*/
public async executeScriptSASViya(
fileName: string,
linesOfCode: string[],
contextName: string,
authConfig?: AuthConfig,
accessToken?: string,
debug?: boolean
) {
this.isMethodSupported('executeScriptSASViya', ServerType.SasViya)
@@ -265,14 +258,14 @@ export default class SASjs {
fileName,
linesOfCode,
contextName,
authConfig,
accessToken,
null,
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 parentFolderPath - the full path (eg `/Public/example/myFolder`) of the parent folder.
* @param parentFolderUri - the URI of the parent folder.
@@ -304,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.
* @param folderPath - path of the folder to be fetched.
@@ -544,22 +503,16 @@ export default class SASjs {
* Process). Is prepended at runtime with the value of `appLoc`.
* @param files - array of files to be uploaded, including File object and file name.
* @param params - request URL parameters.
* @param overrideSasjsConfig - object to override existing config (optional)
*/
public uploadFile(
sasJob: string,
files: UploadFile[],
params: any,
overrideSasjsConfig?: any
) {
const fileUploader = overrideSasjsConfig
? new FileUploader(
{ ...this.sasjsConfig, ...overrideSasjsConfig },
this.jobsPath,
this.requestClient!
)
: this.fileUploader ||
new FileUploader(this.sasjsConfig, this.jobsPath, this.requestClient!)
public uploadFile(sasJob: string, files: UploadFile[], params: any) {
const fileUploader =
this.fileUploader ||
new FileUploader(
this.sasjsConfig.appLoc,
this.sasjsConfig.serverUrl,
this.jobsPath,
this.requestClient!
)
return fileUploader.uploadFile(sasJob, files, params)
}
@@ -585,38 +538,30 @@ export default class SASjs {
* `await request(sasJobPath, data, config, () => setIsLoggedIn(false))`
* If you are not passing in any data and configuration, it will look like so:
* `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(
sasJob: string,
data: { [key: string]: any } | null,
data: { [key: string]: any },
config: { [key: string]: any } = {},
loginRequiredCallback?: () => any,
authConfig?: AuthConfig,
extraResponseAttributes: ExtraResponseAttributes[] = []
accessToken?: string
) {
config = {
...this.sasjsConfig,
...config
}
const validationResult = this.validateInput(data)
if (validationResult.status) {
if (
config.serverType !== ServerType.Sas9 &&
config.useComputeApi !== undefined &&
config.useComputeApi !== null
) {
if (
typeof loginRequiredCallback === 'function' ||
typeof loginRequiredCallback === 'undefined'
) {
if (config.serverType === ServerType.SasViya && config.contextName) {
if (config.useComputeApi) {
return await this.computeJobExecutor!.execute(
sasJob,
data,
config,
loginRequiredCallback,
authConfig
accessToken
)
} else {
return await this.jesJobExecutor!.execute(
@@ -624,91 +569,23 @@ export default class SASjs {
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
accessToken
)
}
} else if (
config.serverType === ServerType.Sas9 &&
config.username &&
config.password
) {
return await this.sas9JobExecutor!.execute(sasJob, data, config)
} else {
return await this.webJobExecutor!.execute(
sasJob,
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
loginRequiredCallback
)
}
} else {
return Promise.reject(new ErrorResponse(validationResult.msg))
}
}
/**
* 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
return Promise.reject(
new ErrorResponse(
`Invalid loginRequiredCallback parameter was provided. Expected Callback function but found ${typeof loginRequiredCallback}`
)
)
}
}
@@ -749,7 +626,7 @@ export default class SASjs {
)
sasApiClient.debug = this.sasjsConfig.debug
} else if (this.sasjsConfig.serverType === ServerType.Sas9) {
sasApiClient = new SAS9ApiClient(serverUrl, this.jobsPath)
sasApiClient = new SAS9ApiClient(serverUrl)
}
} else {
let sasClientConfig: any = null
@@ -791,22 +668,20 @@ export default class SASjs {
* @param config - provide any changes to the config here, for instance to
* enable/disable `debug`. Any change provided will override the global config,
* 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.
* @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 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(
sasJob: string,
data: any,
config: any = {},
authConfig?: AuthConfig,
accessToken?: string,
waitForResult?: boolean,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar
printPid = false
) {
config = {
...this.sasjsConfig,
@@ -825,12 +700,11 @@ export default class SASjs {
config.contextName,
config.debug,
data,
authConfig,
accessToken,
!!waitForResult,
false,
pollOptions,
printPid,
variables
printPid
)
}
@@ -941,15 +815,12 @@ export default class SASjs {
if (this.sasjsConfig.serverType === ServerType.Sas9) {
if (this.sas9ApiClient)
this.sas9ApiClient!.setConfig(this.sasjsConfig.serverUrl)
else
this.sas9ApiClient = new SAS9ApiClient(
this.sasjsConfig.serverUrl,
this.jobsPath
)
else this.sas9ApiClient = new SAS9ApiClient(this.sasjsConfig.serverUrl)
}
this.fileUploader = new FileUploader(
this.sasjsConfig,
this.sasjsConfig.appLoc,
this.sasjsConfig.serverUrl,
this.jobsPath,
this.requestClient
)
@@ -962,12 +833,6 @@ export default class SASjs {
this.sasViyaApiClient!
)
this.sas9JobExecutor = new Sas9JobExecutor(
this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!,
this.jobsPath
)
this.computeJobExecutor = new ComputeJobExecutor(
this.sasjsConfig.serverUrl,
this.sasViyaApiClient!
@@ -998,16 +863,6 @@ export default class SASjs {
isForced
)
break
case 'file':
await this.createFile(
member.name,
member.code,
parentFolder,
undefined,
accessToken,
sasApiClient
)
break
case 'service':
await this.createJobDefinition(
member.name,

View File

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

View File

@@ -1,293 +0,0 @@
import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
import { AuthConfig, MacroVar } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import {
PollOptions,
Job,
ComputeJobExecutionError,
NotFoundError
} from '../..'
import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient'
import { SessionManager } from '../../SessionManager'
import { isRelativePath, fetchLogByChunks } from '../../utils'
import { formatDataForRequest } from '../../utils/formatDataForRequest'
import { pollJobState } from './pollJobState'
import { uploadTables } from './uploadTables'
/**
* Executes code on the current SAS Viya server.
* @param jobPath - the path to the file being submitted for execution.
* @param linesOfCode - an array of code lines to execute.
* @param contextName - the context to execute the code in.
* @param authConfig - an object containing an access token, refresh token, client ID and secret.
* @param data - execution data.
* @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 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 printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/
export async function executeScript(
requestClient: RequestClient,
sessionManager: SessionManager,
rootFolderName: string,
jobPath: string,
linesOfCode: string[],
contextName: string,
authConfig?: AuthConfig,
data: any = null,
debug: boolean = false,
expectWebout = false,
waitForResult = true,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar
): Promise<any> {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(requestClient, authConfig))
}
const logger = process.logger || console
try {
let executionSessionId: string
const session = await sessionManager
.getSession(access_token)
.catch((err) => {
throw prefixMessage(err, 'Error while getting session. ')
})
executionSessionId = session!.id
if (printPid) {
const { result: jobIdVariable } = await sessionManager
.getVariable(executionSessionId, 'SYSJOBID', access_token)
.catch((err) => {
throw prefixMessage(err, 'Error while getting session variable. ')
})
if (jobIdVariable && jobIdVariable.value) {
const relativeJobPath = rootFolderName
? jobPath.split(rootFolderName).join('').replace(/^\//, '')
: jobPath
const logger = process.logger || console
logger.info(
`Triggered '${relativeJobPath}' with PID ${
jobIdVariable.value
} at ${timestampToYYYYMMDDHHMMSS()}`
)
}
}
const jobArguments: { [key: string]: any } = {
_contextName: contextName,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
if (debug) {
jobArguments['_OMITTEXTLOG'] = false
jobArguments['_OMITSESSIONRESULTS'] = false
}
let fileName
if (isRelativePath(jobPath)) {
fileName = `exec-${
jobPath.includes('/') ? jobPath.split('/')[1] : jobPath
}`
} else {
const jobPathParts = jobPath.split('/')
fileName = jobPathParts.pop()
}
let jobVariables: any = {
SYS_JES_JOB_URI: '',
_program: isRelativePath(jobPath)
? rootFolderName + '/' + jobPath
: jobPath
}
if (variables) jobVariables = { ...jobVariables, ...variables }
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
let files: any[] = []
if (data) {
if (JSON.stringify(data).includes(';')) {
files = await uploadTables(requestClient, data, access_token).catch(
(err) => {
throw prefixMessage(err, 'Error while uploading tables. ')
}
)
jobVariables['_webin_file_count'] = files.length
files.forEach((fileInfo, index) => {
jobVariables[
`_webin_fileuri${index + 1}`
] = `/files/files/${fileInfo.file.id}`
jobVariables[`_webin_name${index + 1}`] = fileInfo.tableName
})
} else {
jobVariables = { ...jobVariables, ...formatDataForRequest(data) }
}
}
// Execute job in session
const jobRequestBody = {
name: fileName,
description: 'Powered by SASjs',
code: linesOfCode,
variables: jobVariables,
arguments: jobArguments
}
const { result: postedJob, etag } = await requestClient
.post<Job>(
`/compute/sessions/${executionSessionId}/jobs`,
jobRequestBody,
access_token
)
.catch((err) => {
throw prefixMessage(err, 'Error while posting job. ')
})
if (!waitForResult) return session
if (debug) {
logger.info(`Job has been submitted for '${fileName}'.`)
logger.info(
`You can monitor the job progress at '${requestClient.getBaseUrl()}${
postedJob.links.find((l: any) => l.rel === 'state')!.href
}'.`
)
}
const jobStatus = await pollJobState(
requestClient,
postedJob,
debug,
authConfig,
pollOptions
).catch(async (err) => {
const error = err?.response?.data
const result = /err=[0-9]*,/.exec(error)
const errorCode = '5113'
if (result?.[0]?.slice(4, -1) === errorCode) {
const sessionLogUrl =
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
const logCount = 1000000
err.log = await fetchLogByChunks(
requestClient,
access_token!,
sessionLogUrl,
logCount
)
}
throw prefixMessage(err, 'Error while polling job status. ')
})
if (authConfig) {
;({ access_token } = await getTokens(requestClient, authConfig))
}
const { result: currentJob } = await requestClient
.get<Job>(
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
access_token
)
.catch((err) => {
throw prefixMessage(err, 'Error while getting job. ')
})
let jobResult
let log = ''
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (debug && logLink) {
const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks(
requestClient,
access_token!,
logUrl,
logCount
)
}
if (jobStatus === 'failed' || jobStatus === 'error') {
throw new ComputeJobExecutionError(currentJob, log)
}
if (!expectWebout) {
return { job: currentJob, log }
}
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
jobResult = await requestClient
.get<any>(resultLink, access_token, 'text/plain')
.catch(async (e) => {
if (e instanceof NotFoundError) {
if (logLink) {
const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks(
requestClient,
access_token!,
logUrl,
logCount
)
return Promise.reject({
status: 500,
log
})
}
}
return {
result: JSON.stringify(e)
}
})
await sessionManager
.clearSession(executionSessionId, access_token)
.catch((err) => {
throw prefixMessage(err, 'Error while clearing session. ')
})
return { result: jobResult?.result, log }
} catch (e) {
if (e && e.status === 404) {
return executeScript(
requestClient,
sessionManager,
rootFolderName,
jobPath,
linesOfCode,
contextName,
authConfig,
data,
debug,
false,
true
)
} else {
throw prefixMessage(e, 'Error while executing script. ')
}
}
}

View File

@@ -1,17 +0,0 @@
import { isFolder } from '@sasjs/utils/file'
import { generateTimestamp } from '@sasjs/utils/time'
import { Job } from '../../types'
export const getFileStream = async (job: Job, filePath?: string) => {
const { createWriteStream } = require('@sasjs/utils/file')
const logPath = filePath || process.cwd()
const isFolderPath = await isFolder(logPath)
if (isFolderPath) {
const logFileName = `${job.name || 'job'}-${generateTimestamp()}.log`
const path = require('path')
const logFilePath = path.join(filePath || process.cwd(), logFileName)
return await createWriteStream(logFilePath)
} else {
return await createWriteStream(logPath)
}
}

View File

@@ -1,250 +0,0 @@
import { AuthConfig } from '@sasjs/utils/types'
import { Job, PollOptions } from '../..'
import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors'
import { Link, WriteStream } from '../../types'
import { isNode } from '../../utils'
export async function pollJobState(
requestClient: RequestClient,
postedJob: Job,
debug: boolean,
authConfig?: AuthConfig,
pollOptions?: PollOptions
) {
const logger = process.logger || console
let pollInterval = 300
let maxPollCount = 1000
const defaultPollOptions: PollOptions = {
maxPollCount,
pollInterval,
streamLog: false
}
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
let currentState = await getJobState(
requestClient,
postedJob,
'',
debug,
authConfig
).catch((err) => {
logger.error(
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
err
)
return 'unavailable'
})
let pollCount = 0
if (currentState === 'completed') {
return Promise.resolve(currentState)
}
let logFileStream
if (pollOptions.streamLog && isNode()) {
const { getFileStream } = require('./getFileStream')
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
}
// Poll up to the first 100 times with the specified poll interval
let result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
authConfig,
{
...pollOptions,
maxPollCount:
pollOptions.maxPollCount <= 100 ? pollOptions.maxPollCount : 100
},
logFileStream
)
currentState = result.state
pollCount = result.pollCount
if (!needsRetry(currentState) || pollCount >= pollOptions.maxPollCount) {
return currentState
}
// If we get to this point, this is a long-running job that needs longer polling.
// We will resume polling with a bigger interval of 1 minute
let longJobPollOptions: PollOptions = {
maxPollCount: 24 * 60,
pollInterval: 60000,
streamLog: false
}
if (pollOptions) {
longJobPollOptions.streamLog = pollOptions.streamLog
longJobPollOptions.logFolderPath = pollOptions.logFolderPath
}
result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
authConfig,
longJobPollOptions,
logFileStream
)
currentState = result.state
pollCount = result.pollCount
if (logFileStream) {
logFileStream.end()
}
return currentState
}
const getJobState = async (
requestClient: RequestClient,
job: Job,
currentState: string,
debug: boolean,
authConfig?: AuthConfig
) => {
const stateLink = job.links.find((l: any) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
if (needsRetry(currentState)) {
let tokens
if (authConfig) {
tokens = await getTokens(requestClient, authConfig)
}
const { result: jobState } = await requestClient
.get<string>(
`${stateLink.href}?_action=wait&wait=300`,
tokens?.access_token,
'text/plain',
{},
debug
)
.catch((err) => {
throw new JobStatePollError(job.id, err)
})
return jobState.trim()
} else {
return currentState
}
}
const needsRetry = (state: string) =>
state === 'running' ||
state === '' ||
state === 'pending' ||
state === 'unavailable'
const doPoll = async (
requestClient: RequestClient,
postedJob: Job,
currentState: string,
debug: boolean,
pollCount: number,
authConfig?: AuthConfig,
pollOptions?: PollOptions,
logStream?: WriteStream
): Promise<{ state: string; pollCount: number }> => {
let pollInterval = 300
let maxPollCount = 1000
let maxErrorCount = 5
let errorCount = 0
let state = currentState
let printedState = ''
let startLogLine = 0
const logger = process.logger || console
if (pollOptions) {
pollInterval = pollOptions.pollInterval || pollInterval
maxPollCount = pollOptions.maxPollCount || maxPollCount
}
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
while (needsRetry(state) && pollCount <= maxPollCount) {
state = await getJobState(
requestClient,
postedJob,
state,
debug,
authConfig
).catch((err) => {
errorCount++
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
throw err
}
logger.error(
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
err
)
return 'unavailable'
})
pollCount++
if (pollOptions?.streamLog) {
const jobUrl = postedJob.links.find((l: Link) => l.rel === 'self')
const { result: job } = await requestClient.get<Job>(
jobUrl!.href,
authConfig?.access_token
)
const endLogLine = job.logStatistics?.lineCount ?? 1000000
const { saveLog } = isNode() ? require('./saveLog') : { saveLog: null }
if (saveLog) {
await saveLog(
postedJob,
requestClient,
startLogLine,
endLogLine,
logStream,
authConfig?.access_token
)
}
startLogLine += endLogLine
}
if (debug && printedState !== state) {
logger.info('Polling job status...')
logger.info(`Current job state: ${state}`)
printedState = state
}
if (state != 'unavailable' && errorCount > 0) {
errorCount = 0
}
await delay(pollInterval)
}
return { state, pollCount }
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -1,55 +0,0 @@
import { Job } from '../..'
import { RequestClient } from '../../request/RequestClient'
import { fetchLog } from '../../utils'
import { WriteStream } from '../../types'
import { writeStream } from './writeStream'
/**
* Appends logs to a supplied write stream.
* This is useful for getting quick feedback on longer running jobs.
* @param job - the job to fetch logs for
* @param requestClient - the pre-configured HTTP request client
* @param startLine - the line at which to start fetching the log
* @param endLine - the line at which to stop fetching the log
* @param logFileStream - the write stream to which the log is appended
* @accessToken - an optional access token for authentication/authorization
* The access token is not required when fetching logs from the browser.
*/
export async function saveLog(
job: Job,
requestClient: RequestClient,
startLine: number,
endLine: number,
logFileStream?: WriteStream,
accessToken?: string
) {
if (!accessToken) {
throw new Error(
`Logs for job ${job.id} cannot be fetched without a valid access token.`
)
}
if (!logFileStream) {
throw new Error(
`Logs for job ${job.id} cannot be written without a valid write stream.`
)
}
const logger = process.logger || console
const jobLogUrl = job.links.find((l) => l.rel === 'log')
if (!jobLogUrl) {
throw new Error(`Log URL for job ${job.id} was not found.`)
}
const log = await fetchLog(
requestClient,
accessToken,
`${jobLogUrl.href}/content`,
startLine,
endLine
)
logger.info(`Writing logs to ${logFileStream.path}`)
await writeStream(logFileStream, log || '')
}

View File

@@ -1,675 +0,0 @@
import { RequestClient } from '../../../request/RequestClient'
import { SessionManager } from '../../../SessionManager'
import { executeScript } from '../executeScript'
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
import * as pollJobStateModule from '../pollJobState'
import * as uploadTablesModule from '../uploadTables'
import * as getTokensModule from '../../../auth/getTokens'
import * as formatDataModule from '../../../utils/formatDataForRequest'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import { PollOptions } from '../../../types'
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
import { Logger, LogLevel } from '@sasjs/utils'
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const defaultPollOptions: PollOptions = {
maxPollCount: 100,
pollInterval: 500,
streamLog: false
}
describe('executeScript', () => {
beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off)
setupMocks()
})
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context'
)
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
})
it('should try to get fresh tokens if an authConfig is provided', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context',
mockAuthConfig
)
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
requestClient,
mockAuthConfig
)
})
it('should get a session from the session manager before executing', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context'
)
expect(sessionManager.getSession).toHaveBeenCalledWith(undefined)
})
it('should handle errors while getting a session', async () => {
jest
.spyOn(sessionManager, 'getSession')
.mockImplementation(() => Promise.reject('Test Error'))
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context'
).catch((e) => e)
expect(error).toContain('Error while getting session.')
})
it('should fetch the PID when printPid is true', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context',
mockAuthConfig,
null,
false,
false,
false,
defaultPollOptions,
true
)
expect(sessionManager.getVariable).toHaveBeenCalledWith(
mockSession.id,
'SYSJOBID',
mockAuthConfig.access_token
)
})
it('should handle errors while getting the job PID', async () => {
jest
.spyOn(sessionManager, 'getVariable')
.mockImplementation(() => Promise.reject('Test Error'))
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context',
mockAuthConfig,
null,
false,
false,
false,
defaultPollOptions,
true
).catch((e) => e)
expect(error).toContain('Error while getting session variable.')
})
it('should use the file upload approach when data contains semicolons', async () => {
jest
.spyOn(uploadTablesModule, 'uploadTables')
.mockImplementation(() =>
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
)
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context',
mockAuthConfig,
{ foo: 'bar;' },
false,
false,
false,
defaultPollOptions,
true
)
expect(uploadTablesModule.uploadTables).toHaveBeenCalledWith(
requestClient,
{ foo: 'bar;' },
mockAuthConfig.access_token
)
})
it('should format data as CSV when it does not contain semicolons', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put hello'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
false,
false,
defaultPollOptions,
true
)
expect(formatDataModule.formatDataForRequest).toHaveBeenCalledWith({
foo: 'bar'
})
})
it('should submit a job for execution via the compute API', async () => {
jest
.spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
false,
false,
defaultPollOptions,
true
)
expect(requestClient.post).toHaveBeenCalledWith(
`/compute/sessions/${mockSession.id}/jobs`,
{
name: 'exec-test',
description: 'Powered by SASjs',
code: ['%put "hello";'],
variables: {
SYS_JES_JOB_URI: '',
_program: 'test/test',
sasjs_tables: 'foo',
sasjs0data: 'bar'
},
arguments: {
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
},
mockAuthConfig.access_token
)
})
it('should set the correct variables when debug is true', async () => {
jest
.spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
true,
false,
false,
defaultPollOptions,
true
)
expect(requestClient.post).toHaveBeenCalledWith(
`/compute/sessions/${mockSession.id}/jobs`,
{
name: 'exec-test',
description: 'Powered by SASjs',
code: ['%put "hello";'],
variables: {
SYS_JES_JOB_URI: '',
_program: 'test/test',
sasjs_tables: 'foo',
sasjs0data: 'bar',
_DEBUG: 131
},
arguments: {
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: false,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: false
}
},
mockAuthConfig.access_token
)
})
it('should handle errors during job submission', async () => {
jest
.spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Test Error'))
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
true,
false,
false,
defaultPollOptions,
true
).catch((e) => e)
expect(error).toContain('Error while posting job')
})
it('should immediately return the session when waitForResult is false', async () => {
const result = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
true,
false,
false,
defaultPollOptions,
true
)
expect(result).toEqual(mockSession)
})
it('should poll for job completion when waitForResult is true', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
false,
true,
defaultPollOptions,
true
)
expect(pollJobStateModule.pollJobState).toHaveBeenCalledWith(
requestClient,
mockJob,
false,
mockAuthConfig,
defaultPollOptions
)
})
it('should handle general errors when polling for job status', async () => {
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.reject('Poll Error'))
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
false,
true,
defaultPollOptions,
true
).catch((e) => e)
expect(error).toContain('Error while polling job status.')
})
it('should fetch the log and append it to the error in case of a 5113 error code', async () => {
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() =>
Promise.reject({ response: { data: 'err=5113,' } })
)
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
false,
true,
defaultPollOptions,
true
).catch((e) => e)
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
requestClient,
mockAuthConfig.access_token,
mockJob.links.find((l) => l.rel === 'up')!.href + '/log',
1000000
)
expect(error.log).toEqual('Test Log')
})
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
true,
false,
true,
defaultPollOptions,
true
)
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
requestClient,
mockAuthConfig.access_token,
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
mockJob.logStatistics.lineCount
)
})
it('should not fetch the logs for the job if debug is false', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
false,
true,
defaultPollOptions,
true
)
expect(fetchLogsModule.fetchLogByChunks).not.toHaveBeenCalled()
})
it('should throw a ComputeJobExecutionError if the job has failed', async () => {
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.resolve('failed'))
const error: ComputeJobExecutionError = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
true,
false,
true,
defaultPollOptions,
true
).catch((e) => e)
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
requestClient,
mockAuthConfig.access_token,
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
mockJob.logStatistics.lineCount
)
expect(error).toBeInstanceOf(ComputeJobExecutionError)
expect(error.log).toEqual('Test Log')
expect(error.job).toEqual(mockJob)
})
it('should throw a ComputeJobExecutionError if the job has errored out', async () => {
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.resolve('error'))
const error: ComputeJobExecutionError = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
true,
false,
true,
defaultPollOptions,
true
).catch((e) => e)
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
requestClient,
mockAuthConfig.access_token,
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
mockJob.logStatistics.lineCount
)
expect(error).toBeInstanceOf(ComputeJobExecutionError)
expect(error.log).toEqual('Test Log')
expect(error.job).toEqual(mockJob)
})
it('should fetch the result if expectWebout is true', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
true,
true,
defaultPollOptions,
true
)
expect(requestClient.get).toHaveBeenCalledWith(
`/compute/sessions/${mockSession.id}/filerefs/_webout/content`,
mockAuthConfig.access_token,
'text/plain'
)
})
it('should fetch the logs if the webout file was not found', async () => {
jest.spyOn(requestClient, 'get').mockImplementation((url, ...rest) => {
if (url.includes('_webout')) {
return Promise.reject(new NotFoundError(url))
}
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
})
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
true,
true,
defaultPollOptions,
true
).catch((e) => e)
expect(requestClient.get).toHaveBeenCalledWith(
`/compute/sessions/${mockSession.id}/filerefs/_webout/content`,
mockAuthConfig.access_token,
'text/plain'
)
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
requestClient,
mockAuthConfig.access_token,
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
mockJob.logStatistics.lineCount
)
expect(error.status).toEqual(500)
expect(error.log).toEqual('Test Log')
})
it('should clear the session after execution is complete', async () => {
await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
true,
true,
defaultPollOptions,
true
)
expect(sessionManager.clearSession).toHaveBeenCalledWith(
mockSession.id,
mockAuthConfig.access_token
)
})
it('should handle errors while clearing a session', async () => {
jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.reject('Clear Session Error'))
const error = await executeScript(
requestClient,
sessionManager,
'test',
'test',
['%put "hello";'],
'test context',
mockAuthConfig,
{ foo: 'bar' },
false,
true,
true,
defaultPollOptions,
true
).catch((e) => e)
expect(error).toContain('Error while clearing session.')
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../../request/RequestClient')
jest.mock('../../../SessionManager')
jest.mock('../../../auth/getTokens')
jest.mock('../pollJobState')
jest.mock('../uploadTables')
jest.mock('../../../utils/formatDataForRequest')
jest.mock('../../../utils/fetchLogByChunks')
jest
.spyOn(requestClient, 'post')
.mockImplementation(() => Promise.resolve({ result: mockJob, etag: '' }))
jest
.spyOn(requestClient, 'get')
.mockImplementation(() =>
Promise.resolve({ result: mockJob, etag: '', status: 200 })
)
jest
.spyOn(requestClient, 'delete')
.mockImplementation(() => Promise.resolve({ result: {}, etag: '' }))
jest
.spyOn(getTokensModule, 'getTokens')
.mockImplementation(() => Promise.resolve(mockAuthConfig))
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.resolve('completed'))
jest
.spyOn(sessionManager, 'getVariable')
.mockImplementation(() =>
Promise.resolve({ result: { value: 'test' }, etag: 'test', status: 200 })
)
jest
.spyOn(sessionManager, 'getSession')
.mockImplementation(() => Promise.resolve(mockSession))
jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
jest
.spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'test', sasjs0data: 'test' }))
jest
.spyOn(fetchLogsModule, 'fetchLogByChunks')
.mockImplementation(() => Promise.resolve('Test Log'))
}

View File

@@ -1,41 +0,0 @@
import { Logger, LogLevel } from '@sasjs/utils/logger'
import * as path from 'path'
import * as fileModule from '@sasjs/utils/file'
import { getFileStream } from '../getFileStream'
import { mockJob } from './mockResponses'
import { WriteStream } from '../../../types'
describe('getFileStream', () => {
beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off)
setupMocks()
})
it('should use the given log path if it points to a file', async () => {
const { createWriteStream } = require('@sasjs/utils/file')
await getFileStream(mockJob, path.join(__dirname, 'test.log'))
expect(createWriteStream).toHaveBeenCalledWith(
path.join(__dirname, 'test.log')
)
})
it('should generate a log file path with a timestamp if it points to a folder', async () => {
const { createWriteStream } = require('@sasjs/utils/file')
await getFileStream(mockJob, __dirname)
expect(createWriteStream).not.toHaveBeenCalledWith(__dirname)
expect(createWriteStream).toHaveBeenCalledWith(
expect.stringContaining(path.join(__dirname, 'test job-20'))
)
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('@sasjs/utils/file/file')
jest
.spyOn(fileModule, 'createWriteStream')
.mockImplementation(() => Promise.resolve({} as unknown as WriteStream))
}

View File

@@ -1,73 +0,0 @@
import { AuthConfig } from '@sasjs/utils/types'
import { Job, Session } from '../../../types'
export const mockSession: Session = {
id: 's35510n',
state: 'idle',
links: [],
attributes: {
sessionInactiveTimeout: 1
},
creationTimeStamp: new Date().valueOf().toString()
}
export const mockJob: Job = {
id: 'j0b',
name: 'test job',
uri: '/j0b',
createdBy: 'test user',
results: {
'_webout.json': 'test'
},
logStatistics: {
lineCount: 100,
modifiedTimeStamp: new Date().valueOf().toString()
},
links: [
{
rel: 'log',
href: '/log',
method: 'GET',
type: 'log',
uri: 'log'
},
{
rel: 'self',
href: '/job',
method: 'GET',
type: 'job',
uri: 'job'
},
{
rel: 'state',
href: '/state',
method: 'GET',
type: 'state',
uri: 'state'
},
{
rel: 'up',
href: '/job',
method: 'GET',
type: 'up',
uri: 'job'
}
]
}
export const mockAuthConfig: AuthConfig = {
client: 'cl13nt',
secret: '53cr3t',
access_token: 'acc355',
refresh_token: 'r3fr35h'
}
export class MockStream {
_write(chunk: string, _: any, next: Function) {
next()
}
reset() {}
destroy() {}
}

View File

@@ -1,346 +0,0 @@
import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient'
import { mockAuthConfig, mockJob } from './mockResponses'
import { pollJobState } from '../pollJobState'
import * as getTokensModule from '../../../auth/getTokens'
import * as saveLogModule from '../saveLog'
import * as getFileStreamModule from '../getFileStream'
import * as isNodeModule from '../../../utils/isNode'
import { PollOptions } from '../../../types'
import { WriteStream } from 'fs'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const defaultPollOptions: PollOptions = {
maxPollCount: 100,
pollInterval: 500,
streamLog: false
}
describe('pollJobState', () => {
beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off)
setupMocks()
})
it('should get valid tokens if the authConfig has been provided', async () => {
await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
defaultPollOptions
)
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
requestClient,
mockAuthConfig
)
})
it('should not attempt to get tokens if the authConfig has not been provided', async () => {
await pollJobState(
requestClient,
mockJob,
false,
undefined,
defaultPollOptions
)
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
})
it('should throw an error if the job does not have a state link', async () => {
const error = await pollJobState(
requestClient,
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
false,
undefined,
defaultPollOptions
).catch((e) => e)
expect((error as Error).message).toContain('Job state link was not found.')
})
it('should attempt to refresh tokens before each poll', async () => {
mockSimplePoll()
await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
defaultPollOptions
)
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
})
it('should attempt to fetch and save the log after each poll when streamLog is true', async () => {
mockSimplePoll()
const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollOptions,
streamLog: true
})
expect(saveLog).toHaveBeenCalledTimes(2)
})
it('should create a write stream in Node.js environment when streamLog is true', async () => {
mockSimplePoll()
const { getFileStream } = require('../getFileStream')
const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollOptions,
streamLog: true
})
expect(getFileStream).toHaveBeenCalled()
expect(saveLog).toHaveBeenCalledTimes(2)
})
it('should not create a write stream in a non-Node.js environment', async () => {
mockSimplePoll()
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
const { saveLog } = require('../saveLog')
const { getFileStream } = require('../getFileStream')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollOptions,
streamLog: true
})
expect(getFileStream).not.toHaveBeenCalled()
expect(saveLog).not.toHaveBeenCalled()
})
it('should not attempt to fetch and save the log after each poll when streamLog is false', async () => {
mockSimplePoll()
await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
defaultPollOptions
)
expect(saveLogModule.saveLog).not.toHaveBeenCalled()
})
it('should return the current status when the max poll count is reached', async () => {
mockRunningPoll()
const state = await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
{
...defaultPollOptions,
maxPollCount: 1
}
)
expect(state).toEqual('running')
})
it('should poll with a larger interval for longer running jobs', async () => {
mockLongPoll()
const state = await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
{
...defaultPollOptions,
maxPollCount: 200,
pollInterval: 10
}
)
expect(state).toEqual('completed')
}, 200000)
it('should continue polling until the job completes or errors', async () => {
mockSimplePoll(1)
const state = await pollJobState(
requestClient,
mockJob,
false,
undefined,
defaultPollOptions
)
expect(requestClient.get).toHaveBeenCalledTimes(2)
expect(state).toEqual('completed')
})
it('should print the state to the console when debug is on', async () => {
jest.spyOn((process as any).logger, 'info')
mockSimplePoll()
await pollJobState(
requestClient,
mockJob,
true,
undefined,
defaultPollOptions
)
expect((process as any).logger.info).toHaveBeenCalledTimes(4)
expect((process as any).logger.info).toHaveBeenNthCalledWith(
1,
'Polling job status...'
)
expect((process as any).logger.info).toHaveBeenNthCalledWith(
2,
'Current job state: running'
)
expect((process as any).logger.info).toHaveBeenNthCalledWith(
3,
'Polling job status...'
)
expect((process as any).logger.info).toHaveBeenNthCalledWith(
4,
'Current job state: completed'
)
})
it('should continue polling when there is a single error in between', async () => {
mockPollWithSingleError()
const state = await pollJobState(
requestClient,
mockJob,
false,
undefined,
defaultPollOptions
)
expect(requestClient.get).toHaveBeenCalledTimes(2)
expect(state).toEqual('completed')
})
it('should throw an error when the error count exceeds the set value of 5', async () => {
mockErroredPoll()
const error = await pollJobState(
requestClient,
mockJob,
false,
undefined,
defaultPollOptions
).catch((e) => e)
expect(error.message).toEqual(
'Error while polling job state for job j0b: Status Error'
)
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../../request/RequestClient')
jest.mock('../../../auth/getTokens')
jest.mock('../saveLog')
jest.mock('../getFileStream')
jest.mock('../../../utils/isNode')
jest
.spyOn(requestClient, 'get')
.mockImplementation(() =>
Promise.resolve({ result: 'completed', etag: '', status: 200 })
)
jest
.spyOn(getTokensModule, 'getTokens')
.mockImplementation(() => Promise.resolve(mockAuthConfig))
jest
.spyOn(saveLogModule, 'saveLog')
.mockImplementation(() => Promise.resolve())
jest
.spyOn(getFileStreamModule, 'getFileStream')
.mockImplementation(() => Promise.resolve({} as unknown as WriteStream))
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => true)
}
const mockSimplePoll = (runningCount = 2) => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result:
count === 0
? 'pending'
: count <= runningCount
? 'running'
: 'completed',
etag: '',
status: 200
})
})
}
const mockRunningPoll = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result: count === 0 ? 'pending' : 'running',
etag: '',
status: 200
})
})
}
const mockLongPoll = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result: count <= 102 ? 'running' : 'completed',
etag: '',
status: 200
})
})
}
const mockPollWithSingleError = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
if (count === 1) {
return Promise.reject('Status Error')
}
return Promise.resolve({
result: count === 0 ? 'pending' : 'completed',
etag: '',
status: 200
})
})
}
const mockErroredPoll = () => {
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.reject('Status Error')
})
}

View File

@@ -1,73 +0,0 @@
import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import * as writeStreamModule from '../writeStream'
import { saveLog } from '../saveLog'
import { mockJob } from './mockResponses'
import { WriteStream } from '../../../types'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const stream = {} as unknown as WriteStream
describe('saveLog', () => {
beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off)
setupMocks()
})
it('should throw an error when a valid access token is not provided', async () => {
const error = await saveLog(mockJob, requestClient, 0, 100, stream).catch(
(e) => e
)
expect(error.message).toContain(
`Logs for job ${mockJob.id} cannot be fetched without a valid access token.`
)
})
it('should throw an error when the log URL is not available', async () => {
const error = await saveLog(
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'log') },
requestClient,
0,
100,
stream,
't0k3n'
).catch((e) => e)
expect(error.message).toContain(
`Log URL for job ${mockJob.id} was not found.`
)
})
it('should fetch and save logs to the given path', async () => {
await saveLog(mockJob, requestClient, 0, 100, stream, 't0k3n')
expect(fetchLogsModule.fetchLog).toHaveBeenCalledWith(
requestClient,
't0k3n',
'/log/content',
0,
100
)
expect(writeStreamModule.writeStream).toHaveBeenCalledWith(
stream,
'Test Log'
)
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../../request/RequestClient')
jest.mock('../../../utils/fetchLogByChunks')
jest.mock('@sasjs/utils')
jest.mock('../writeStream')
jest
.spyOn(fetchLogsModule, 'fetchLog')
.mockImplementation(() => Promise.resolve('Test Log'))
jest
.spyOn(writeStreamModule, 'writeStream')
.mockImplementation(() => Promise.resolve())
}

View File

@@ -1,67 +0,0 @@
import { RequestClient } from '../../../request/RequestClient'
import * as convertToCsvModule from '../../../utils/convertToCsv'
import { uploadTables } from '../uploadTables'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('uploadTables', () => {
beforeEach(() => {
setupMocks()
})
it('should return a list of uploaded files', async () => {
const data = { foo: 'bar' }
const files = await uploadTables(requestClient, data, 't0k3n')
expect(files).toEqual([{ tableName: 'foo', file: 'test-file' }])
expect(requestClient.uploadFile).toHaveBeenCalledTimes(1)
expect(requestClient.uploadFile).toHaveBeenCalledWith(
'/files/files#rawUpload',
'Test CSV',
't0k3n'
)
})
it('should throw an error when the CSV exceeds the maximum length', async () => {
const data = { foo: 'bar' }
jest
.spyOn(convertToCsvModule, 'convertToCSV')
.mockImplementation(() => 'ERROR: LARGE STRING LENGTH')
const error = await uploadTables(requestClient, data, 't0k3n').catch(
(e) => e
)
expect(requestClient.uploadFile).not.toHaveBeenCalled()
expect(error.message).toEqual(
'The max length of a string value in SASjs is 32765 characters.'
)
})
it('should throw an error when the file upload fails', async () => {
const data = { foo: 'bar' }
jest
.spyOn(requestClient, 'uploadFile')
.mockImplementation(() => Promise.reject('Upload Error'))
const error = await uploadTables(requestClient, data, 't0k3n').catch(
(e) => e
)
expect(error).toContain('Error while uploading file.')
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../../utils/convertToCsv')
jest
.spyOn(convertToCsvModule, 'convertToCSV')
.mockImplementation(() => 'Test CSV')
jest
.spyOn(requestClient, 'uploadFile')
.mockImplementation(() =>
Promise.resolve({ result: 'test-file', etag: '' })
)
}

View File

@@ -1,25 +0,0 @@
import { WriteStream } from '../../../types'
import { writeStream } from '../writeStream'
import 'jest-extended'
describe('writeStream', () => {
const stream: WriteStream = {
write: jest.fn(),
path: 'test'
}
it('should resolve when the stream is written successfully', async () => {
expect(writeStream(stream, 'test')).toResolve()
expect(stream.write).toHaveBeenCalledWith('test\n', expect.anything())
})
it('should reject when the write errors out', async () => {
jest
.spyOn(stream, 'write')
.mockImplementation((_, callback) => callback(new Error('Test Error')))
const error = await writeStream(stream, 'test').catch((e) => e)
expect(error.message).toEqual('Test Error')
})
})

View File

@@ -1,37 +0,0 @@
import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from '../../request/RequestClient'
import { convertToCSV } from '../../utils/convertToCsv'
/**
* Uploads tables to SAS as specially formatted CSVs.
* This is more compact than JSON, and easier to read within SAS.
* @param requestClient - the pre-configured HTTP request client
* @param data - the JSON representation of the data to be uploaded
* @param accessToken - an optional access token for authentication/authorization
* The access token is not required when uploading tables from the browser.
*/
export async function uploadTables(
requestClient: RequestClient,
data: any,
accessToken?: string
) {
const uploadedFiles = []
for (const tableName in data) {
const csv = convertToCSV(data[tableName])
if (csv === 'ERROR: LARGE STRING LENGTH') {
throw new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
}
const uploadResponse = await requestClient
.uploadFile(`/files/files#rawUpload`, csv, accessToken)
.catch((err) => {
throw prefixMessage(err, 'Error while uploading file. ')
})
uploadedFiles.push({ tableName, file: uploadResponse.result })
}
return uploadedFiles
}

View File

@@ -1,15 +0,0 @@
import { WriteStream } from '../../types'
export const writeStream = async (
stream: WriteStream,
content: string
): Promise<void> => {
return new Promise((resolve, reject) => {
stream.write(content + '\n', (e) => {
if (e) {
return reject(e)
}
return resolve()
})
})
}

View File

@@ -1,4 +1,5 @@
import { ServerType } from '@sasjs/utils/types'
import { isAuthorizeFormRequired } from '.'
import { RequestClient } from '../request/RequestClient'
import { serialize } from '../utils'
@@ -34,7 +35,6 @@ export class AuthManager {
this.userName = loginParams.username
const { isLoggedIn, loginForm } = await this.checkSession()
if (isLoggedIn) {
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) {
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 =>
/You have signed in/gm.test(response)

View File

@@ -1,53 +0,0 @@
import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import * as NodeFormData from 'form-data'
import { RequestClient } from '../request/RequestClient'
/**
* Exchanges the auth code for an access token for the given client.
* @param requestClient - the pre-configured HTTP request client
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param authCode - the auth code received from the server.
*/
export async function getAccessToken(
requestClient: RequestClient,
clientId: string,
clientSecret: string,
authCode: string
): Promise<SasAuthResponse> {
const url = '/SASLogon/oauth/token'
let token
if (typeof Buffer === 'undefined') {
token = btoa(clientId + ':' + clientSecret)
} else {
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
}
const headers = {
Authorization: 'Basic ' + token
}
let formData
if (typeof FormData === 'undefined') {
formData = new NodeFormData()
} else {
formData = new FormData()
}
formData.append('grant_type', 'authorization_code')
formData.append('code', authCode)
const authResponse = await requestClient
.post(
url,
formData,
undefined,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then((res) => res.result as SasAuthResponse)
.catch((err) => {
throw prefixMessage(err, 'Error while getting access token')
})
return authResponse
}

View File

@@ -1,40 +0,0 @@
import {
isAccessTokenExpiring,
isRefreshTokenExpiring,
hasTokenExpired
} from '@sasjs/utils/auth'
import { AuthConfig } from '@sasjs/utils/types'
import { RequestClient } from '../request/RequestClient'
import { refreshTokens } from './refreshTokens'
/**
* Returns the auth configuration, refreshing the tokens if necessary.
* @param requestClient - the pre-configured HTTP request client
* @param authConfig - an object containing a client ID, secret, access token and refresh token
*/
export async function getTokens(
requestClient: RequestClient,
authConfig: AuthConfig
): Promise<AuthConfig> {
const logger = process.logger || console
let { access_token, refresh_token, client, secret } = authConfig
if (
isAccessTokenExpiring(access_token) ||
isRefreshTokenExpiring(refresh_token)
) {
if (hasTokenExpired(refresh_token)) {
const error =
'Unable to obtain new access token. Your refresh token has expired.'
logger.error(error)
throw new Error(error)
}
logger.info('Refreshing access and refresh tokens.')
;({ access_token, refresh_token } = await refreshTokens(
requestClient,
client,
secret,
refresh_token
))
}
return { access_token, refresh_token, client, secret }
}

View File

@@ -1,49 +0,0 @@
import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import * as NodeFormData from 'form-data'
import { RequestClient } from '../request/RequestClient'
/**
* Exchanges the refresh token for an access token for the given client.
* @param requestClient - the pre-configured HTTP request client
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param authCode - the refresh token received from the server.
*/
export async function refreshTokens(
requestClient: RequestClient,
clientId: string,
clientSecret: string,
refreshToken: string
) {
const url = '/SASLogon/oauth/token'
let token
token =
typeof Buffer === 'undefined'
? btoa(clientId + ':' + clientSecret)
: Buffer.from(clientId + ':' + clientSecret).toString('base64')
const headers = {
Authorization: 'Basic ' + token
}
const formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)
const authResponse = await requestClient
.post<SasAuthResponse>(
url,
formData,
undefined,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then((res) => res.result)
.catch((err) => {
throw prefixMessage(err, 'Error while refreshing tokens')
})
return authResponse
}

View File

@@ -57,7 +57,7 @@ describe('AuthManager', () => {
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(
serverUrl,
serverType,
@@ -77,9 +77,10 @@ describe('AuthManager', () => {
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1)
done()
})
it('should post a login request to the server if not logged in', async () => {
it('should post a login request to the server if not logged in', async (done) => {
const authManager = new AuthManager(
serverUrl,
serverType,
@@ -120,9 +121,10 @@ describe('AuthManager', () => {
}
)
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(
serverUrl,
serverType,
@@ -158,9 +160,10 @@ describe('AuthManager', () => {
expect(requestClient.authorize).toHaveBeenCalledWith(
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(
serverUrl,
serverType,
@@ -186,5 +189,7 @@ describe('AuthManager', () => {
}
}
)
done()
})
})

View File

@@ -1,75 +0,0 @@
import { AuthConfig } from '@sasjs/utils'
import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { getAccessToken } from '../getAccessToken'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('getAccessToken', () => {
it('should attempt to refresh tokens', async () => {
setupMocks()
const access_token = generateToken(30)
const refresh_token = generateToken(30)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
jest
.spyOn(requestClient, 'post')
.mockImplementation(() =>
Promise.resolve({ result: mockAuthResponse, etag: '' })
)
const token = Buffer.from(
authConfig.client + ':' + authConfig.secret
).toString('base64')
await getAccessToken(
requestClient,
authConfig.client,
authConfig.secret,
authConfig.refresh_token
)
expect(requestClient.post).toHaveBeenCalledWith(
'/SASLogon/oauth/token',
expect.any(NodeFormData),
undefined,
expect.stringContaining('multipart/form-data; boundary='),
{
Authorization: 'Basic ' + token
}
)
})
it('should handle errors while refreshing tokens', async () => {
setupMocks()
const access_token = generateToken(30)
const refresh_token = generateToken(30)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
jest
.spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Token Error'))
const error = await getAccessToken(
requestClient,
authConfig.client,
authConfig.secret,
authConfig.refresh_token
).catch((e) => e)
expect(error).toContain('Error while getting access token')
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../request/RequestClient')
}

View File

@@ -1,79 +0,0 @@
import { AuthConfig } from '@sasjs/utils'
import * as refreshTokensModule from '../refreshTokens'
import { generateToken, mockAuthResponse } from './mockResponses'
import { getTokens } from '../getTokens'
import { RequestClient } from '../../request/RequestClient'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('getTokens', () => {
it('should attempt to refresh tokens if the access token is expiring', async () => {
setupMocks()
const access_token = generateToken(30)
const refresh_token = generateToken(86400000)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
await getTokens(requestClient, authConfig)
expect(refreshTokensModule.refreshTokens).toHaveBeenCalledWith(
requestClient,
authConfig.client,
authConfig.secret,
authConfig.refresh_token
)
})
it('should attempt to refresh tokens if the refresh token is expiring', async () => {
setupMocks()
const access_token = generateToken(86400000)
const refresh_token = generateToken(30)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
await getTokens(requestClient, authConfig)
expect(refreshTokensModule.refreshTokens).toHaveBeenCalledWith(
requestClient,
authConfig.client,
authConfig.secret,
authConfig.refresh_token
)
})
it('should throw an error if the refresh token has already expired', async () => {
setupMocks()
const access_token = generateToken(86400000)
const refresh_token = generateToken(-36000)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
const expectedError =
'Unable to obtain new access token. Your refresh token has expired.'
const error = await getTokens(requestClient, authConfig).catch((e) => e)
expect(error.message).toEqual(expectedError)
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../request/RequestClient')
jest.mock('../refreshTokens')
jest
.spyOn(refreshTokensModule, 'refreshTokens')
.mockImplementation(() => Promise.resolve(mockAuthResponse))
}

View File

@@ -1,24 +1,2 @@
import { SasAuthResponse } from '@sasjs/utils/types'
export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
export const mockLoginSuccessResponse = `You have signed in`
export const mockAuthResponse: SasAuthResponse = {
access_token: 'acc355',
refresh_token: 'r3fr35h',
id_token: 'id',
token_type: 'bearer',
expires_in: new Date().valueOf(),
scope: 'default',
jti: 'test'
}
export const generateToken = (timeToLiveSeconds: number): string => {
const exp =
new Date(new Date().getTime() + timeToLiveSeconds * 1000).getTime() / 1000
const header = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9'
const payload = Buffer.from(JSON.stringify({ exp })).toString('base64')
const signature = '4-iaDojEVl0pJQMjrbM1EzUIfAZgsbK_kgnVyVxFSVo'
const token = `${header}.${payload}.${signature}`
return token
}

View File

@@ -1,75 +0,0 @@
import { AuthConfig } from '@sasjs/utils'
import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { refreshTokens } from '../refreshTokens'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('refreshTokens', () => {
it('should attempt to refresh tokens', async () => {
setupMocks()
const access_token = generateToken(30)
const refresh_token = generateToken(30)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
jest
.spyOn(requestClient, 'post')
.mockImplementation(() =>
Promise.resolve({ result: mockAuthResponse, etag: '' })
)
const token = Buffer.from(
authConfig.client + ':' + authConfig.secret
).toString('base64')
await refreshTokens(
requestClient,
authConfig.client,
authConfig.secret,
authConfig.refresh_token
)
expect(requestClient.post).toHaveBeenCalledWith(
'/SASLogon/oauth/token',
expect.any(NodeFormData),
undefined,
expect.stringContaining('multipart/form-data; boundary='),
{
Authorization: 'Basic ' + token
}
)
})
it('should handle errors while refreshing tokens', async () => {
setupMocks()
const access_token = generateToken(30)
const refresh_token = generateToken(30)
const authConfig: AuthConfig = {
access_token,
refresh_token,
client: 'cl13nt',
secret: 's3cr3t'
}
jest
.spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Token Error'))
const error = await refreshTokens(
requestClient,
authConfig.client,
authConfig.secret,
authConfig.refresh_token
).catch((e) => e)
expect(error).toContain('Error while refreshing tokens')
})
})
const setupMocks = () => {
jest.restoreAllMocks()
jest.mock('../../request/RequestClient')
}

View File

@@ -1,4 +1,4 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types'
import { ServerType } from '@sasjs/utils/types'
import { SASViyaApiClient } from '../SASViyaApiClient'
import {
ErrorResponse,
@@ -17,7 +17,7 @@ export class ComputeJobExecutor extends BaseJobExecutor {
data: any,
config: any,
loginRequiredCallback?: any,
authConfig?: AuthConfig
accessToken?: string
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const waitForResult = true
@@ -30,7 +30,7 @@ export class ComputeJobExecutor extends BaseJobExecutor {
config.contextName,
config.debug,
data,
authConfig,
accessToken,
waitForResult,
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 {
ErrorResponse,
JobExecutionError,
LoginRequiredError
} from '../types/errors'
import { ExtraResponseAttributes } from '@sasjs/utils/types'
import { BaseJobExecutor } from './JobExecutor'
export class JesJobExecutor extends BaseJobExecutor {
@@ -18,34 +17,23 @@ export class JesJobExecutor extends BaseJobExecutor {
data: any,
config: any,
loginRequiredCallback?: any,
authConfig?: AuthConfig,
extraResponseAttributes: ExtraResponseAttributes[] = []
accessToken?: string
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const requestPromise = new Promise((resolve, reject) => {
this.sasViyaApiClient
?.executeJob(sasJob, config.contextName, config.debug, data, authConfig)
.then((response: any) => {
?.executeJob(
sasJob,
config.contextName,
config.debug,
data,
accessToken
)
.then((response) => {
this.appendRequest(response, sasJob, config.debug)
let responseObject = {}
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)
resolve(response)
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
@@ -62,9 +50,7 @@ export class JesJobExecutor extends BaseJobExecutor {
sasJob,
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
loginRequiredCallback
).then(
(res: any) => {
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 { ExtraResponseAttributes } from '@sasjs/utils/types'
import { asyncForEach, parseGeneratedCode, parseSourceCode } from '../utils'
export type ExecuteFunction = () => Promise<any>
@@ -11,8 +10,7 @@ export interface JobExecutor {
data: any,
config: any,
loginRequiredCallback?: any,
authConfig?: AuthConfig,
extraResponseAttributes?: ExtraResponseAttributes[]
accessToken?: string
) => Promise<any>
resendWaitingRequests: () => Promise<void>
getRequests: () => SASjsRequest[]
@@ -30,8 +28,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
data: any,
config: any,
loginRequiredCallback?: any,
authConfig?: AuthConfig | undefined,
extraResponseAttributes?: ExtraResponseAttributes[]
accessToken?: string | undefined
): Promise<any>
resendWaitingRequests = async () => {
@@ -62,14 +59,14 @@ export abstract class BaseJobExecutor implements JobExecutor {
let sasWork = null
if (debug) {
if (response?.log) {
if (response?.result && response?.log) {
sourceCode = parseSourceCode(response.log)
generatedCode = parseGeneratedCode(response.log)
if (response?.result) {
sasWork = response.result.WORK
} else {
if (response.log) {
sasWork = response.log
} else {
sasWork = response.result.WORK
}
} else if (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,13 +8,8 @@ import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { generateTableUploadForm } from '../file/generateTableUploadForm'
import { RequestClient } from '../request/RequestClient'
import { SASViyaApiClient } from '../SASViyaApiClient'
import {
isRelativePath,
getValidJson,
parseSasViyaDebugResponse
} from '../utils'
import { isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
export interface WaitingRequstPromise {
promise: Promise<any> | null
@@ -44,18 +39,15 @@ export class WebJobExecutor extends BaseJobExecutor {
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
: sasJob
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
if (config.serverType === ServerType.SasViya) {
const jobUri =
config.serverType === ServerType.SasViya
? await this.getJobUri(sasJob)
: ''
apiUrl += jobUri.length > 0 ? '&_job=' + jobUri : ''
apiUrl += config.contextName ? `&_contextname=${config.contextName}` : ''
}
const jobUri =
config.serverType === ServerType.SasViya
? await this.getJobUri(sasJob)
: ''
const apiUrl = `${config.serverUrl}${this.jobsPath}/?${
jobUri.length > 0
? '__program=' + program + '&_job=' + jobUri
: '_program=' + program
}`
let requestParams = {
...this.getRequestParams(config)
@@ -99,27 +91,12 @@ export class WebJobExecutor extends BaseJobExecutor {
this.requestClient!.post(apiUrl, formData, undefined)
.then(async (res) => {
if (this.serverType === ServerType.SasViya && config.debug) {
const jsonResponse = await parseSasViyaDebugResponse(
res.result as string,
this.requestClient,
this.serverUrl
const jsonResponse = await this.parseSasViyaDebugResponse(
res.result as string
)
this.appendRequest(res, sasJob, config.debug)
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.'
)
}
getValidJson(jsonResponse)
this.appendRequest(res, sasJob, config.debug)
resolve(res.result)
}
getValidJson(res.result as string)
this.appendRequest(res, sasJob, config.debug)
resolve(res.result)
})
@@ -157,6 +134,20 @@ export class WebJobExecutor extends BaseJobExecutor {
return requestPromise
}
private parseSasViyaDebugResponse = async (response: string) => {
const iframeStart = response.split(
'<iframe style="width: 99%; height: 500px" src="'
)[1]
const jsonUrl = iframeStart ? iframeStart.split('"></iframe>')[0] : null
if (!jsonUrl) {
throw new Error('Unable to find webout file URL.')
}
return this.requestClient
.get(this.serverUrl + jsonUrl, undefined)
.then((res) => res.result)
}
private async getJobUri(sasJob: string) {
if (!this.sasViyaApiClient) return ''
let uri = ''

View File

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

View File

@@ -10,8 +10,6 @@ import {
} from '../types/errors'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
import { prefixMessage } from '@sasjs/utils/error'
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
import { getValidJson } from '../utils'
export interface HttpClient {
get<T>(
@@ -43,15 +41,14 @@ export interface HttpClient {
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
clearCsrfTokens(): void
getBaseUrl(): string
}
export class RequestClient implements HttpClient {
protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined
protected httpClient: AxiosInstance
private csrfToken: CsrfToken = { headerName: '', value: '' }
private fileUploadCsrfToken: CsrfToken | undefined
private httpClient: AxiosInstance
constructor(protected baseUrl: string, allowInsecure = false) {
constructor(private baseUrl: string, allowInsecure = false) {
const https = require('https')
if (allowInsecure && https.Agent) {
this.httpClient = axios.create({
@@ -65,9 +62,6 @@ export class RequestClient implements HttpClient {
baseURL: baseUrl
})
}
this.httpClient.defaults.validateStatus = (status) =>
status >= 200 && status < 305
}
public getCsrfToken(type: 'general' | 'file' = 'general') {
@@ -79,17 +73,13 @@ export class RequestClient implements HttpClient {
this.fileUploadCsrfToken = { headerName: '', value: '' }
}
public getBaseUrl() {
return this.httpClient.defaults.baseURL || ''
}
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 }> {
): Promise<{ result: T; etag: string }> {
const headers = {
...this.getHeaders(accessToken, contentType),
...overrideHeaders
@@ -296,12 +286,11 @@ export class RequestClient implements HttpClient {
})
.then((res) => res.data)
.catch((error) => {
const logger = process.logger || console
logger.error(error)
console.log(error)
})
}
protected getHeaders = (
private getHeaders = (
accessToken: string | undefined,
contentType: string
) => {
@@ -326,7 +315,7 @@ export class RequestClient implements HttpClient {
return headers
}
protected parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => {
private parseAndSetFileUploadCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
@@ -334,7 +323,7 @@ export class RequestClient implements HttpClient {
}
}
protected parseAndSetCsrfToken = (response: AxiosResponse) => {
private parseAndSetCsrfToken = (response: AxiosResponse) => {
const token = this.parseCsrfToken(response)
if (token) {
@@ -358,7 +347,7 @@ export class RequestClient implements HttpClient {
}
}
protected handleError = async (
private handleError = async (
e: any,
callback: any,
debug: boolean = false
@@ -416,7 +405,7 @@ export class RequestClient implements HttpClient {
throw e
}
protected parseResponse<T>(response: AxiosResponse<any>) {
private parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse
let includeSAS9Log: boolean = false
@@ -429,13 +418,7 @@ export class RequestClient implements HttpClient {
}
} catch {
try {
const weboutResponse = parseWeboutResponse(response.data)
if (weboutResponse === '') {
throw new Error('Valid JSON could not be extracted from response.')
}
const jsonResponse = getValidJson(weboutResponse)
parsedResponse = jsonResponse
parsedResponse = JSON.parse(parseWeboutResponse(response.data))
} catch {
parsedResponse = response.data
}
@@ -443,15 +426,9 @@ export class RequestClient implements HttpClient {
includeSAS9Log = true
}
let responseToReturn: {
result: T
etag: any
log?: string
status: number
} = {
let responseToReturn: { result: T; etag: any; log?: string } = {
result: parsedResponse as T,
etag,
status: response.status
etag
}
if (includeSAS9Log) {
@@ -462,7 +439,7 @@ export class RequestClient implements HttpClient {
}
}
export const throwIfError = (response: AxiosResponse) => {
const throwIfError = (response: AxiosResponse) => {
if (response.status === 401) {
throw new LoginRequiredError()
}
@@ -493,10 +470,6 @@ export const throwIfError = (response: AxiosResponse) => {
throw new AuthorizeError(response.data.message, authorizeRequestUrl)
}
if (response.config?.url?.includes('sasAuthError')) {
throw new SAS9AuthError()
}
const error = parseError(response.data as string)
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 { SASjsConfig, UploadFile } from '../types'
import { UploadFile } from '../types'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
jest.mock('axios')
@@ -32,51 +28,48 @@ const prepareFilesAndParams = () => {
}
describe('FileUploader', () => {
const config: SASjsConfig = {
...new SASjsConfig(),
appLoc: '/sample/apploc'
}
const fileUploader = new FileUploader(
config,
'/sample/apploc',
'https://sample.server.com',
'/jobs/path',
new RequestClient('https://sample.server.com')
)
it('should upload successfully', async () => {
it('should upload successfully', async (done) => {
const sasJob = 'test/upload'
const { files, params } = prepareFilesAndParams()
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const res = await fileUploader.uploadFile(sasJob, files, params)
expect(res).toEqual(JSON.parse(sampleResponse))
fileUploader.uploadFile(sasJob, files, params).then((res: any) => {
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 files: UploadFile[] = []
const params = { table: 'libtable' }
const err = await fileUploader
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('At least one file must be provided.')
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
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 { files, params } = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('sasJob must be provided.')
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
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(() =>
Promise.resolve({ data: '<form action="Logon">' })
)
@@ -84,13 +77,15 @@ describe('FileUploader', () => {
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('You must be logged in to upload a file.')
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
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(() =>
Promise.resolve({ data: '{invalid: "json"' })
)
@@ -98,13 +93,13 @@ describe('FileUploader', () => {
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('File upload request failed.')
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
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(() =>
Promise.reject({ data: '{message: "Server error"}' })
)
@@ -112,9 +107,10 @@ describe('FileUploader', () => {
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('File upload request failed.')
fileUploader.uploadFile(sasJob, files, params).catch((err: any) => {
expect(err.error.message).toEqual('File upload request failed.')
done()
})
})
})

View File

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

View File

@@ -1,9 +1,7 @@
import { SessionManager } from '../SessionManager'
import { RequestClient } from '../request/RequestClient'
import { NoSessionStateError } from '../types/errors'
import * as dotenv from 'dotenv'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -45,38 +43,4 @@ describe('SessionManager', () => {
).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,41 +0,0 @@
import { getValidJson } from '../../utils'
describe('jsonValidator', () => {
it('should not throw an error with a valid json', () => {
const json = {
test: 'test'
}
expect(getValidJson(json)).toBe(json)
})
it('should not throw an error with a valid json string', () => {
const json = {
test: 'test'
}
expect(getValidJson(JSON.stringify(json))).toStrictEqual(json)
})
it('should throw an error with an invalid json', () => {
const json = `{\"test\":\"test\"\"test2\":\"test\"}`
let errorThrown = false
try {
getValidJson(json)
} catch (error) {
errorThrown = true
}
expect(errorThrown).toBe(true)
})
it('should throw an error when an array is passed', () => {
const array = ['hello', 'world']
let errorThrown = false
try {
getValidJson(array)
} catch (error) {
errorThrown = true
}
expect(errorThrown).toBe(true)
})
})

View File

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

View File

@@ -1,6 +1,6 @@
import { parseSourceCode } from '../../utils/index'
it('should parse SAS9 source code', async () => {
it('should parse SAS9 source code', async (done) => {
expect(sampleResponse).toBeTruthy()
const parsedSourceCode = parseSourceCode(sampleResponse)
@@ -15,6 +15,8 @@ it('should parse SAS9 source code', async () => {
expect(sourceCodeLines[2].startsWith('8')).toBeTruthy()
expect(sourceCodeLines[3].startsWith('9')).toBeTruthy()
expect(sourceCodeLines[4].startsWith('10')).toBeTruthy()
done()
})
/* 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,6 +1,4 @@
export interface PollOptions {
maxPollCount: number
pollInterval: number
streamLog: boolean
logFolderPath?: string
MAX_POLL_COUNT?: number
POLL_INTERVAL?: number
}

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
/**
* 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'
* 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 = ''
/**
* 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
* 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
* 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.
*/
useComputeApi: boolean | null = null
useComputeApi = false
/**
* Defaults to `false`.
* When set to `true`, the adapter will allow requests to SAS servers that use a self-signed SSL certificate.

View File

@@ -1,4 +0,0 @@
export interface WriteStream {
write: (content: string, callback: (err?: Error) => any) => void
path: string
}

View File

@@ -1,11 +0,0 @@
export class JobStatePollError extends Error {
constructor(id: string, public originalError: Error) {
super(
`Error while polling job state for job ${id}: ${
originalError.message || originalError
}`
)
this.name = 'JobStatePollError'
Object.setPrototypeOf(this, JobStatePollError.prototype)
}
}

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

@@ -2,8 +2,6 @@ export * from './AuthorizeError'
export * from './ComputeJobExecutionError'
export * from './InternalServerError'
export * from './JobExecutionError'
export * from './JobStatePollError'
export * from './LoginRequiredError'
export * from './NotFoundError'
export * from './ErrorResponse'
export * from './NoSessionStateError'

View File

@@ -1,7 +1,6 @@
export * from './Context'
export * from './CsrfToken'
export * from './Folder'
export * from './File'
export * from './Job'
export * from './JobDefinition'
export * from './JobResult'
@@ -11,4 +10,3 @@ export * from './SASjsRequest'
export * from './Session'
export * from './UploadFile'
export * from './PollOptions'
export * from './WriteStream'

View File

@@ -15,35 +15,15 @@ export const fetchLogByChunks = async (
logUrl: string,
logCount: number
): Promise<string> => {
return await fetchLog(requestClient, accessToken, logUrl, 0, logCount)
}
/**
* Fetches a section of the log file delineated by start and end lines
* @param {object} requestClient - client object of Request Client.
* @param {string} accessToken - an access token for an authorized user.
* @param {string} logUrl - url of the log file.
* @param {number} start - the line at which to start fetching the log.
* @param {number} end - the line at which to stop fetching the log.
* @returns an string containing log lines.
*/
export const fetchLog = async (
requestClient: RequestClient,
accessToken: string,
logUrl: string,
start: number,
end: number
): Promise<string> => {
const logger = process.logger || console
let log: string = ''
const loglimit = end < 10000 ? end : 10000
const loglimit = logCount < 10000 ? logCount : 10000
let start = 0
do {
logger.info(
console.log(
`Fetching logs from line no: ${start + 1} to ${
start + loglimit
} of ${end}.`
} of ${logCount}.`
)
const logChunkJson = await requestClient!
.get<any>(`${logUrl}?start=${start}&limit=${loglimit}`, accessToken)
@@ -58,6 +38,6 @@ export const fetchLog = async (
log += logChunk
start += loglimit
} while (start < end)
} while (start < logCount)
return log
}

View File

@@ -1,16 +0,0 @@
/**
* if string passed then parse the string to json else if throw error for all other types unless it is not a valid json object.
* @param str - string to check.
*/
export const getValidJson = (str: string | object) => {
try {
if (Array.isArray(str)) {
throw new Error('Can not parse array object to json.')
}
if (typeof str === 'object') return str
return JSON.parse(str)
} catch (e) {
throw new Error('Invalid JSON response.')
}
}

View File

@@ -1,7 +1,6 @@
export * from './asyncForEach'
export * from './compareTimestamps'
export * from './convertToCsv'
export * from './isNode'
export * from './isRelativePath'
export * from './isUri'
export * from './isUrl'
@@ -13,5 +12,3 @@ export * from './serialize'
export * from './splitChunks'
export * from './parseWeboutResponse'
export * from './fetchLogByChunks'
export * from './getValidJson'
export * from './parseViyaDebugResponse'

View File

@@ -1,4 +0,0 @@
export const isNode = () =>
typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null

View File

@@ -1,29 +0,0 @@
import { RequestClient } from '../request/RequestClient'
/**
* When querying a Viya job using the Web approach (as opposed to using the APIs) with _DEBUG enabled,
* the first response contains the log with the content in an iframe. Therefore when debug is enabled,
* and the serverType is VIYA, and useComputeApi is null (WEB), we call this function to extract the
* (_webout) content from the iframe.
* @param response - first response from viya job
* @param requestClient
* @param serverUrl
* @returns
*/
export const parseSasViyaDebugResponse = async (
response: string,
requestClient: RequestClient,
serverUrl: string
) => {
const iframeStart = response.split(
'<iframe style="width: 99%; height: 500px" src="'
)[1]
const jsonUrl = iframeStart ? iframeStart.split('"></iframe>')[0] : null
if (!jsonUrl) {
throw new Error('Unable to find webout file URL.')
}
return requestClient
.get(serverUrl + jsonUrl, undefined)
.then((res) => res.result)
}

View File

@@ -9,5 +9,5 @@
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "**/*.spec.ts"]
}

View File

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