mirror of
https://github.com/sasjs/adapter.git
synced 2025-12-11 09:24:35 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4aaeba31c | |||
|
|
c0f78d0c1e | ||
| 7d826685f7 | |||
| f42f6bca00 | |||
|
|
bd872e0e75 | ||
|
|
a14a1663fc | ||
|
|
2482a0c674 | ||
|
|
a19de50e67 | ||
|
|
860c9f907c | ||
|
|
d7126a6878 | ||
|
|
a69ebd0fd3 | ||
|
|
0f47326bb6 | ||
|
|
2d6efa2437 | ||
|
|
da7579a2bb | ||
|
|
657e415c0c | ||
|
|
8fa908a201 | ||
|
|
a29b7f3b92 | ||
|
|
8360519408 | ||
|
|
a71d422528 | ||
|
|
7d37e4d79d | ||
|
|
7ae21c160a | ||
| bfefdb65a3 | |||
| bbe52562da | |||
| 48917bb4d9 | |||
| 81c3d2a3dc | |||
| 0052d5d340 | |||
| a633bda24d | |||
| e951ea0ab2 | |||
| b75ab13eb7 | |||
| 4de34cc8b0 | |||
| 23a789b383 | |||
| 156f1b1180 | |||
| 491e36d703 | |||
| 06b6e48a16 | |||
| 9bebd356ca | |||
| 171f9bc7b9 | |||
|
|
7cbc2bda08 | ||
|
|
5bc2afba8c | ||
| dd3a7fe393 | |||
|
|
ba1b1e6e80 | ||
|
|
3daa85a74a | ||
|
|
d1139857a4 | ||
|
|
cb106c76cb | ||
|
|
88a1f3eac6 | ||
|
|
b00db86811 | ||
|
|
96caadc6b1 | ||
|
|
074a21c4bb | ||
|
|
588a4169bb | ||
|
|
3d4c01622e | ||
|
|
33bc8f92cb | ||
|
|
c1704fff78 | ||
|
|
891cc13362 |
25
.github/vpn/config.ovpn
vendored
Normal file
25
.github/vpn/config.ovpn
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Client
|
||||
client
|
||||
tls-client
|
||||
dev tun
|
||||
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
|
||||
proto tcp
|
||||
remote vpn.4gl.io 7494
|
||||
resolv-retry infinite
|
||||
cipher AES-256-CBC
|
||||
auth SHA256
|
||||
script-security 2
|
||||
keepalive 10 120
|
||||
remote-cert-tls server
|
||||
|
||||
# Keys
|
||||
ca ca.crt
|
||||
cert user.crt
|
||||
key user.key
|
||||
tls-auth tls.key 1
|
||||
|
||||
# Security
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
verb 3
|
||||
38
.github/workflows/build.yml
vendored
38
.github/workflows/build.yml
vendored
@@ -39,11 +39,33 @@ jobs:
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Install SSH Key
|
||||
uses: shimataro/ssh-key-action@v2
|
||||
with:
|
||||
key: ${{ secrets.DCGITLAB_KEY }}
|
||||
known_hosts: 'placeholder'
|
||||
- name: Write VPN Files
|
||||
run: |
|
||||
echo "$CA_CRT" > .github/vpn/ca.crt
|
||||
echo "$USER_CRT" > .github/vpn/user.crt
|
||||
echo "$USER_KEY" > .github/vpn/user.key
|
||||
echo "$TLS_KEY" > .github/vpn/tls.key
|
||||
shell: bash
|
||||
env:
|
||||
CA_CRT: ${{ secrets.CA_CRT}}
|
||||
USER_CRT: ${{ secrets.USER_CRT }}
|
||||
USER_KEY: ${{ secrets.USER_KEY }}
|
||||
TLS_KEY: ${{ secrets.TLS_KEY }}
|
||||
|
||||
- name: Install Open VPN
|
||||
run: |
|
||||
sudo apt install apt-transport-https
|
||||
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
|
||||
sudo apt-key add openvpn-repo-pkg-key.pub
|
||||
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list
|
||||
sudo apt update
|
||||
sudo apt install openvpn3=17~betaUb22042+jammy
|
||||
|
||||
- name: Start Open VPN 3
|
||||
run: openvpn3 session-start --config .github/vpn/config.ovpn
|
||||
|
||||
- name: install pm2
|
||||
run: npm i -g pm2
|
||||
|
||||
- name: Deploy sasjs-tests
|
||||
run: |
|
||||
@@ -55,12 +77,12 @@ jobs:
|
||||
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
|
||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
|
||||
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
|
||||
npm run update:adapter && npm run build
|
||||
scp -o stricthostkeychecking=no -r ./build/* ${{ secrets.DCGITLAB_DEPLOY_PATH_VIYA }}
|
||||
npm run update:adapter
|
||||
pm2 start --name sasjs-test npm -- start
|
||||
|
||||
- name: Run cypress on sasjs
|
||||
run: |
|
||||
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json
|
||||
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
|
||||
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
|
||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
|
||||
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
||||
|
||||
@@ -153,6 +153,14 @@ The response object will contain returned tables and columns. Table names are a
|
||||
|
||||
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
|
||||
|
||||
### Session Manager
|
||||
|
||||
To execute a script on Viya a session has to be created first which is time-consuming (~15sec). That is why a Session Manager has been created which is implementing the following logic:
|
||||
|
||||
1. When the first session is requested, we also create one more session (hot session) for future requests. Please notice two pending POST requests to create a session within the same context: 
|
||||
2. When a subsequent request for a session is received and there is a hot session available (not expired), this session is returned and an asynchronous request to create another hot session is sent. Please notice that there is a pending POST request to create a new session while a job has been already finished execution (POST request with status 201): 
|
||||
3. When a subsequent request for a session is received and there is no available hot session, 2 requests are sent asynchronously to create a session. The first created session will be returned and another session will be reserved for future requests.
|
||||
|
||||
### Variable Types
|
||||
|
||||
The SAS type (char/numeric) of the values is determined according to a set of rules:
|
||||
|
||||
@@ -14,65 +14,83 @@ context('sasjs-tests', function () {
|
||||
|
||||
it('Should have all tests successfull', (done) => {
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('input[placeholder="User Name"]').length > 0) {
|
||||
cy.get('input[placeholder="User Name"]').type(username)
|
||||
cy.get('input[placeholder="Password"]').type(password)
|
||||
cy.get('.submit-button').click()
|
||||
}
|
||||
cy.wait(1000).then(() => {
|
||||
const startButton = $body.find(
|
||||
'.ui.massive.icon.primary.left.labeled.button'
|
||||
)[0]
|
||||
|
||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.loading.primary.button', {
|
||||
timeout: testingFinishTimeout
|
||||
})
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('span.icon.failed')
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
done()
|
||||
})
|
||||
if (
|
||||
!startButton ||
|
||||
(startButton && !Cypress.dom.isVisible(startButton))
|
||||
) {
|
||||
cy.get('input[placeholder="User Name"]').type(username)
|
||||
cy.get('input[placeholder="Password"]').type(password)
|
||||
cy.get('.submit-button').click()
|
||||
}
|
||||
|
||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.loading.primary.button', {
|
||||
timeout: testingFinishTimeout
|
||||
})
|
||||
})
|
||||
})
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('span.icon.failed')
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Should have all tests successfull with debug on', (done) => {
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('input[placeholder="User Name"]').length > 0) {
|
||||
cy.get('input[placeholder="User Name"]').type(username)
|
||||
cy.get('input[placeholder="Password"]').type(password)
|
||||
cy.get('.submit-button').click()
|
||||
}
|
||||
cy.wait(1000).then(() => {
|
||||
const startButton = $body.find(
|
||||
'.ui.massive.icon.primary.left.labeled.button'
|
||||
)[0]
|
||||
|
||||
cy.get('.ui.fitted.toggle.checkbox label')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.loading.primary.button', {
|
||||
timeout: testingFinishTimeout
|
||||
})
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('span.icon.failed')
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
done()
|
||||
})
|
||||
if (
|
||||
!startButton ||
|
||||
(startButton && !Cypress.dom.isVisible(startButton))
|
||||
) {
|
||||
cy.get('input[placeholder="User Name"]').type(username)
|
||||
cy.get('input[placeholder="Password"]').type(password)
|
||||
cy.get('.submit-button').click()
|
||||
}
|
||||
|
||||
cy.get('.ui.fitted.toggle.checkbox label')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ui.massive.loading.primary.button', {
|
||||
timeout: testingFinishTimeout
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
cy.get('span.icon.failed')
|
||||
.should('not.exist')
|
||||
.then(() => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
25
cypress/support/commands.js
Normal file
25
cypress/support/commands.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
20
cypress/support/index.js
Normal file
20
cypress/support/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/combine/npm/chart.js@2.9.3,npm/jquery@3.5.1,npm/@sasjs/adapter@1"></script>
|
||||
<script src="https://cdn.jsdelivr.net/combine/npm/chart.js@2.9.3,npm/jquery@3.5.1,npm/@sasjs/adapter@4"></script>
|
||||
<script>
|
||||
var sasJs = new SASjs.default({
|
||||
appLoc: "/Public/app/readme"
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -32,7 +32,7 @@
|
||||
"node-polyfill-webpack-plugin": "1.1.4",
|
||||
"path": "0.12.7",
|
||||
"pem": "1.14.5",
|
||||
"prettier": "2.7.1",
|
||||
"prettier": "2.8.7",
|
||||
"process": "0.11.10",
|
||||
"rimraf": "3.0.2",
|
||||
"semantic-release": "19.0.3",
|
||||
@@ -13925,9 +13925,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
|
||||
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
|
||||
"integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
@@ -27416,9 +27416,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
|
||||
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
|
||||
"integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-bytes": {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"node-polyfill-webpack-plugin": "1.1.4",
|
||||
"path": "0.12.7",
|
||||
"pem": "1.14.5",
|
||||
"prettier": "2.7.1",
|
||||
"prettier": "2.8.7",
|
||||
"process": "0.11.10",
|
||||
"rimraf": "3.0.2",
|
||||
"semantic-release": "19.0.3",
|
||||
|
||||
8
sasjs-tests/.gitignore
vendored
8
sasjs-tests/.gitignore
vendored
@@ -13,11 +13,11 @@
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
sasjsbuild
|
||||
sasjsresults
|
||||
19
sasjs-tests/.sasjslint
Normal file
19
sasjs-tests/.sasjslint
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"lineEndings": "off",
|
||||
"noTrailingSpaces": true,
|
||||
"noEncodedPasswords": true,
|
||||
"hasDoxygenHeader": true,
|
||||
"noSpacesInFileNames": true,
|
||||
"lowerCaseFileNames": true,
|
||||
"maxLineLength": 80,
|
||||
"maxHeaderLineLength": 80,
|
||||
"maxDataLineLength": 80,
|
||||
"noTabIndentation": true,
|
||||
"indentationMultiple": 2,
|
||||
"hasMacroNameInMend": true,
|
||||
"noNestedMacros": true,
|
||||
"hasMacroParentheses": true,
|
||||
"strictMacroDefinition": true,
|
||||
"noGremlins": true,
|
||||
"defaultHeader": "/**{lineEnding} @file{lineEnding} @brief <Your brief here>{lineEnding} <h4> SAS Macros </h4>{lineEnding}**/"
|
||||
}
|
||||
@@ -65,6 +65,7 @@ The code below will work on ALL SAS platforms (Viya, SAS 9 EBI, SASjs Server).
|
||||
```sas
|
||||
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
|
||||
%inc mc;
|
||||
%let apploc=/Public/app/adapter-tests;
|
||||
filename ft15f001 temp lrecl=1000;
|
||||
parmcards4;
|
||||
%webout(FETCH)
|
||||
@@ -80,7 +81,7 @@ parmcards4;
|
||||
%mend; %x()
|
||||
%webout(CLOSE)
|
||||
;;;;
|
||||
%mx_createwebservice(path=/Public/app/common,name=sendObj)
|
||||
%mx_createwebservice(path=&apploc/services/common,name=sendObj)
|
||||
parmcards4;
|
||||
%webout(FETCH)
|
||||
%webout(OPEN)
|
||||
@@ -95,7 +96,7 @@ parmcards4;
|
||||
%mend; %x()
|
||||
%webout(CLOSE)
|
||||
;;;;
|
||||
%mx_createwebservice(path=/Public/app/common,name=sendArr)
|
||||
%mx_createwebservice(path=&apploc/services/common,name=sendArr)
|
||||
parmcards4;
|
||||
data work.macvars;
|
||||
set sashelp.vmacro;
|
||||
@@ -104,14 +105,14 @@ parmcards4;
|
||||
%webout(OBJ,macvars)
|
||||
%webout(CLOSE)
|
||||
;;;;
|
||||
%mx_createwebservice(path=/Public/app/common,name=sendMacVars)
|
||||
%mx_createwebservice(path=&apploc/services/common,name=sendMacVars)
|
||||
parmcards4;
|
||||
If you can keep your head when all about you
|
||||
Are losing theirs and blaming it on you,
|
||||
If you can trust yourself when all men doubt you,
|
||||
But make allowance for their doubting too;
|
||||
;;;;
|
||||
%mx_createwebservice(path=/Public/app/common,name=makeErr)
|
||||
%mx_createwebservice(path=&apploc/services/common,name=makeErr)
|
||||
parmcards4;
|
||||
%webout(OPEN)
|
||||
data _null_;
|
||||
@@ -120,7 +121,7 @@ data _null_;
|
||||
run;
|
||||
%webout(CLOSE)
|
||||
;;;;
|
||||
%mx_createwebservice(path=/Public/app/common,name=invalidJSON)
|
||||
%mx_createwebservice(path=&apploc/services/common,name=invalidJSON)
|
||||
```
|
||||
|
||||
You should now be able to access the tests in your browser at the deployed path on your server.
|
||||
|
||||
1357
sasjs-tests/package-lock.json
generated
1357
sasjs-tests/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,4 +45,4 @@
|
||||
"devDependencies": {
|
||||
"node-sass": "7.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
"password": "",
|
||||
"sasJsConfig": {
|
||||
"serverUrl": "",
|
||||
"appLoc": "/Public/app",
|
||||
"serverType": "SASVIYA",
|
||||
"appLoc": "/Public/app/adapter-tests/services",
|
||||
"serverType": "SASJS",
|
||||
"debug": false,
|
||||
"contextName": "sasjs adapter compute context",
|
||||
"useComputeApi": true
|
||||
|
||||
13
sasjs-tests/sasjs/common/invalidJSON.sas
Normal file
13
sasjs-tests/sasjs/common/invalidJSON.sas
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
@file
|
||||
@brief Makes an invalid JSON file
|
||||
|
||||
<h4> SAS Macros </h4>
|
||||
**/
|
||||
|
||||
%webout(OPEN)
|
||||
data _null_;
|
||||
file _webout;
|
||||
put ' the discovery channel ';
|
||||
run;
|
||||
%webout(CLOSE)
|
||||
11
sasjs-tests/sasjs/common/makeErr.sas
Normal file
11
sasjs-tests/sasjs/common/makeErr.sas
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
@file
|
||||
@brief Makes an error
|
||||
|
||||
<h4> SAS Macros </h4>
|
||||
**/
|
||||
|
||||
If you can keep your head when all about you
|
||||
Are losing theirs and blaming it on you,
|
||||
If you can trust yourself when all men doubt you,
|
||||
But make allowance for their doubting too;
|
||||
21
sasjs-tests/sasjs/common/sendArr.sas
Normal file
21
sasjs-tests/sasjs/common/sendArr.sas
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
@file
|
||||
@brief Returns JSON in Array format
|
||||
|
||||
<h4> SAS Macros </h4>
|
||||
**/
|
||||
|
||||
%webout(FETCH)
|
||||
%webout(OPEN)
|
||||
%macro x();
|
||||
%if %symexist(sasjs_tables) %then
|
||||
%do i=1 %to %sysfunc(countw(&sasjs_tables));
|
||||
%let table=%scan(&sasjs_tables,&i);
|
||||
%webout(ARR,&table,missing=STRING,showmeta=YES)
|
||||
%end;
|
||||
%else %do i=1 %to &_webin_file_count;
|
||||
%webout(ARR,&&_webin_name&i,missing=STRING,showmeta=YES)
|
||||
%end;
|
||||
%mend x;
|
||||
%x()
|
||||
%webout(CLOSE)
|
||||
13
sasjs-tests/sasjs/common/sendMacVars.sas
Normal file
13
sasjs-tests/sasjs/common/sendMacVars.sas
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
@file
|
||||
@brief Returns Macro Variables
|
||||
|
||||
<h4> SAS Macros </h4>
|
||||
**/
|
||||
|
||||
data work.macvars;
|
||||
set sashelp.vmacro;
|
||||
run;
|
||||
%webout(OPEN)
|
||||
%webout(OBJ,macvars)
|
||||
%webout(CLOSE)
|
||||
21
sasjs-tests/sasjs/common/sendObj.sas
Normal file
21
sasjs-tests/sasjs/common/sendObj.sas
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
@file
|
||||
@brief Returns JSON in Object format
|
||||
|
||||
<h4> SAS Macros </h4>
|
||||
**/
|
||||
|
||||
%webout(FETCH)
|
||||
%webout(OPEN)
|
||||
%macro x();
|
||||
%if %symexist(sasjs_tables) %then
|
||||
%do i=1 %to %sysfunc(countw(&sasjs_tables));
|
||||
%let table=%scan(&sasjs_tables,&i);
|
||||
%webout(OBJ,&table,missing=STRING,showmeta=YES)
|
||||
%end;
|
||||
%else %do i=1 %to &_webin_file_count;
|
||||
%webout(OBJ,&&_webin_name&i,missing=STRING,showmeta=YES)
|
||||
%end;
|
||||
%mend x;
|
||||
%x()
|
||||
%webout(CLOSE)
|
||||
40
sasjs-tests/sasjs/doxy/Doxyfile
Normal file
40
sasjs-tests/sasjs/doxy/Doxyfile
Normal file
@@ -0,0 +1,40 @@
|
||||
ALPHABETICAL_INDEX = NO
|
||||
|
||||
ENABLE_PREPROCESSING = NO
|
||||
EXTENSION_MAPPING = sas=Java ddl=Java
|
||||
EXTRACT_LOCAL_CLASSES = NO
|
||||
FILE_PATTERNS = *.sas \
|
||||
*.ddl \
|
||||
*.dox
|
||||
GENERATE_LATEX = NO
|
||||
GENERATE_TREEVIEW = YES
|
||||
HIDE_FRIEND_COMPOUNDS = YES
|
||||
HIDE_IN_BODY_DOCS = YES
|
||||
HIDE_SCOPE_NAMES = YES
|
||||
HIDE_UNDOC_CLASSES = YES
|
||||
HIDE_UNDOC_MEMBERS = YES
|
||||
HTML_OUTPUT = $(DOXY_HTML_OUTPUT)
|
||||
HTML_HEADER = $(HTML_HEADER)
|
||||
HTML_EXTRA_FILES = $(HTML_EXTRA_FILES)
|
||||
HTML_FOOTER = $(HTML_FOOTER)
|
||||
HTML_EXTRA_STYLESHEET = $(HTML_EXTRA_STYLESHEET)
|
||||
INHERIT_DOCS = NO
|
||||
INLINE_INFO = NO
|
||||
INPUT = $(DOXY_INPUT)
|
||||
LAYOUT_FILE = $(LAYOUT_FILE)
|
||||
USE_MDFILE_AS_MAINPAGE = README.md
|
||||
MAX_INITIALIZER_LINES = 0
|
||||
PROJECT_NAME = $(PROJECT_NAME)
|
||||
PROJECT_LOGO = $(PROJECT_LOGO)
|
||||
PROJECT_BRIEF = $(PROJECT_BRIEF)
|
||||
RECURSIVE = YES
|
||||
REPEAT_BRIEF = NO
|
||||
SHOW_NAMESPACES = NO
|
||||
SHOW_USED_FILES = NO
|
||||
SOURCE_BROWSER = YES
|
||||
SOURCE_TOOLTIPS = NO
|
||||
STRICT_PROTO_MATCHING = YES
|
||||
STRIP_CODE_COMMENTS = NO
|
||||
SUBGROUPING = NO
|
||||
TAB_SIZE = 2
|
||||
VERBATIM_HEADERS = NO
|
||||
112
sasjs-tests/sasjs/doxy/DoxygenLayout.xml
Normal file
112
sasjs-tests/sasjs/doxy/DoxygenLayout.xml
Normal file
@@ -0,0 +1,112 @@
|
||||
<doxygenlayout version="1.0">
|
||||
<!-- Generated by doxygen 1.8.14 -->
|
||||
<!-- Navigation index tabs for HTML output -->
|
||||
<navindex>
|
||||
<tab type="mainpage" visible="yes" title="Home"/>
|
||||
<tab type="pages" visible="no" title="" intro=""/>
|
||||
<tab type="modules" visible="no" title="" intro=""/>
|
||||
<tab type="namespaces" visible="no" title="">
|
||||
<tab type="namespacelist" visible="no" title="" intro=""/>
|
||||
<tab type="namespacemembers" visible="no" title="" intro=""/>
|
||||
</tab>
|
||||
<tab type="classes" visible="no" title="">
|
||||
<tab type="classlist" visible="no" title="" intro=""/>
|
||||
<tab type="classindex" visible="no" title=""/>
|
||||
<tab type="hierarchy" visible="no" title="" intro=""/>
|
||||
<tab type="classmembers" visible="no" title="" intro=""/>
|
||||
</tab>
|
||||
|
||||
<tab type="filelist" visible="yes" title="" intro="List of Files"/>
|
||||
|
||||
<tab type="examples" visible="no" title="" intro=""/>
|
||||
<tab type="user" url="data_lineage.svg" title="Lineage"/>
|
||||
</navindex>
|
||||
|
||||
|
||||
<!-- Layout definition for a file page -->
|
||||
<file>
|
||||
<briefdescription visible="yes"/>
|
||||
<includes visible="$SHOW_INCLUDE_FILES"/>
|
||||
<includegraph visible="$INCLUDE_GRAPH"/>
|
||||
<includedbygraph visible="$INCLUDED_BY_GRAPH"/>
|
||||
<sourcelink visible="yes"/>
|
||||
<memberdecl>
|
||||
<classes visible="no" title=""/>
|
||||
<namespaces visible="no" title=""/>
|
||||
<constantgroups visible="no" title=""/>
|
||||
<defines title=""/>
|
||||
<typedefs title=""/>
|
||||
<enums title=""/>
|
||||
<functions title=""/>
|
||||
<variables title=""/>
|
||||
<membergroups visible="no"/>
|
||||
</memberdecl>
|
||||
<detaileddescription title=""/>
|
||||
<memberdef>
|
||||
<inlineclasses title=""/>
|
||||
<defines title=""/>
|
||||
<typedefs title=""/>
|
||||
<enums title=""/>
|
||||
<functions title=""/>
|
||||
<variables title=""/>
|
||||
</memberdef>
|
||||
<authorsection/>
|
||||
</file>
|
||||
|
||||
<!-- Layout definition for a group page -->
|
||||
<group>
|
||||
<briefdescription visible="no"/>
|
||||
<groupgraph visible="$GROUP_GRAPHS"/>
|
||||
<memberdecl>
|
||||
<nestedgroups visible="no" title=""/>
|
||||
<dirs visible="yes" title=""/>
|
||||
<files visible="yes" title=""/>
|
||||
<namespaces visible="no" title=""/>
|
||||
<classes visible="no" title=""/>
|
||||
<defines title=""/>
|
||||
<typedefs title=""/>
|
||||
<enums title=""/>
|
||||
<enumvalues title=""/>
|
||||
<functions title=""/>
|
||||
<variables title=""/>
|
||||
<signals title=""/>
|
||||
<publicslots title=""/>
|
||||
<protectedslots title=""/>
|
||||
<privateslots title=""/>
|
||||
<events title=""/>
|
||||
<properties title=""/>
|
||||
<friends title=""/>
|
||||
<membergroups visible="yes"/>
|
||||
</memberdecl>
|
||||
<detaileddescription title=""/>
|
||||
<memberdef>
|
||||
<pagedocs/>
|
||||
<inlineclasses title=""/>
|
||||
<defines title=""/>
|
||||
<typedefs title=""/>
|
||||
<enums title=""/>
|
||||
<enumvalues title=""/>
|
||||
<functions title=""/>
|
||||
<variables title=""/>
|
||||
<signals title=""/>
|
||||
<publicslots title=""/>
|
||||
<protectedslots title=""/>
|
||||
<privateslots title=""/>
|
||||
<events title=""/>
|
||||
<properties title=""/>
|
||||
<friends title=""/>
|
||||
</memberdef>
|
||||
<authorsection visible="yes"/>
|
||||
</group>
|
||||
|
||||
<!-- Layout definition for a directory page -->
|
||||
<directory>
|
||||
<briefdescription visible="yes"/>
|
||||
<detaileddescription visible="yes" title=""/>
|
||||
<directorygraph visible="yes"/>
|
||||
<memberdecl>
|
||||
<dirs visible="yes"/>
|
||||
<files visible="yes"/>
|
||||
</memberdecl>
|
||||
</directory>
|
||||
</doxygenlayout>
|
||||
26
sasjs-tests/sasjs/doxy/doxygen.svg
Normal file
26
sasjs-tests/sasjs/doxy/doxygen.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
BIN
sasjs-tests/sasjs/doxy/favicon.ico
Normal file
BIN
sasjs-tests/sasjs/doxy/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
sasjs-tests/sasjs/doxy/logo.png
Normal file
BIN
sasjs-tests/sasjs/doxy/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
33
sasjs-tests/sasjs/doxy/new_footer.html
Normal file
33
sasjs-tests/sasjs/doxy/new_footer.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!-- HTML footer for doxygen 1.8.17-->
|
||||
<!-- start footer part -->
|
||||
<!--BEGIN GENERATE_TREEVIEW-->
|
||||
<div id="nav-path" class="navpath">
|
||||
<!-- id is needed for treeview function! -->
|
||||
<ul>
|
||||
$navpath
|
||||
<li class="footer">
|
||||
$generatedby
|
||||
<a href="https://www.doxygen.org/index.html">
|
||||
<img class="footer" src="$relpath^doxygen.svg" alt="doxygen"
|
||||
/></a>
|
||||
$doxygenversion
|
||||
</li>
|
||||
<li>
|
||||
<i> For more information visit the </i>
|
||||
<a href="https://cli.sasjs.io">SASjs cli</a> documentation.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--END GENERATE_TREEVIEW-->
|
||||
<!--BEGIN !GENERATE_TREEVIEW-->
|
||||
<hr class="footer" />
|
||||
<address class="footer">
|
||||
<small>
|
||||
$generatedby  <a href="http://www.doxygen.org/index.html">
|
||||
<img class="footer" src="$relpath^doxygen.svg" alt="doxygen" />
|
||||
</a>
|
||||
$doxygenversion
|
||||
</small>
|
||||
</address>
|
||||
|
||||
<!--END !GENERATE_TREEVIEW-->
|
||||
57
sasjs-tests/sasjs/doxy/new_header.html
Normal file
57
sasjs-tests/sasjs/doxy/new_header.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<!-- HTML header for doxygen 1.8.17-->
|
||||
<html xmlns="https://www.w3.org/1999/xhtml" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/xhtml;charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=9" />
|
||||
<meta name="generator" content="Doxygen $doxygenversion" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--BEGIN PROJECT_NAME-->
|
||||
<title>$projectname: $title</title>
|
||||
<meta name="description" content="$projectbrief" />
|
||||
<!--END PROJECT_NAME-->
|
||||
<!--BEGIN !PROJECT_NAME-->
|
||||
<title>$title</title>
|
||||
<!--END !PROJECT_NAME-->
|
||||
<link href="$relpath^tabs.css" rel="stylesheet" type="text/css" />
|
||||
<script type="text/javascript" src="$relpath^jquery.js"></script>
|
||||
<script type="text/javascript" src="$relpath^dynsections.js"></script>
|
||||
$treeview $search $mathjax
|
||||
<link href="$relpath^$stylesheet" rel="stylesheet" type="text/css" />
|
||||
<link rel="shortcut icon" href="$relpath^favicon.ico" type="image/x-icon" />
|
||||
$extrastylesheet
|
||||
</head>
|
||||
<body>
|
||||
<div id="top">
|
||||
<!-- do not remove this div, it is closed by doxygen! -->
|
||||
|
||||
<!--BEGIN TITLEAREA-->
|
||||
<div id="titlearea">
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr style="height: 56px">
|
||||
<!--BEGIN PROJECT_LOGO-->
|
||||
<td id="projectlogo">
|
||||
<a href="$relpath^"
|
||||
><img alt="Logo" src="$relpath^$projectlogo"
|
||||
/></a>
|
||||
</td>
|
||||
<!--END PROJECT_LOGO-->
|
||||
<td id="projectalign" style="padding-left: 0.5em">
|
||||
<div id="projectname">$projectname</div>
|
||||
<div id="projectbrief">$projectbrief</div>
|
||||
</td>
|
||||
<!--BEGIN DISABLE_INDEX-->
|
||||
<!--BEGIN SEARCHENGINE-->
|
||||
<td>$searchbox</td>
|
||||
<!--END SEARCHENGINE-->
|
||||
<!--END DISABLE_INDEX-->
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--END TITLEAREA-->
|
||||
<!-- end header part -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
4
sasjs-tests/sasjs/doxy/new_stylesheet.css
Normal file
4
sasjs-tests/sasjs/doxy/new_stylesheet.css
Normal file
@@ -0,0 +1,4 @@
|
||||
#projectlogo img {
|
||||
border: 0px none;
|
||||
max-height: 70px;
|
||||
}
|
||||
29
sasjs-tests/sasjs/sasjsconfig.json
Normal file
29
sasjs-tests/sasjs/sasjsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://cli.sasjs.io/sasjsconfig-schema.json",
|
||||
"serviceConfig": {
|
||||
"serviceFolders": ["sasjs/common"]
|
||||
},
|
||||
"defaultTarget": "4gl",
|
||||
"targets": [
|
||||
{
|
||||
"name": "4gl",
|
||||
"serverUrl": "https://sas9.4gl.io",
|
||||
"serverType": "SASJS",
|
||||
"httpsAgentOptions": {
|
||||
"allowInsecureRequests": false
|
||||
},
|
||||
"appLoc": "/Public/app/adapter-tests",
|
||||
"deployConfig": {
|
||||
"deployServicePack": true,
|
||||
"deployScripts": []
|
||||
},
|
||||
"streamConfig": {
|
||||
"streamWeb": true,
|
||||
"streamWebFolder": "webv",
|
||||
"webSourcePath": "build",
|
||||
"streamServiceName": "adapter-tests",
|
||||
"assetPaths": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export const computeTests = (adapter: SASjs, appLoc: string): TestSuite => ({
|
||||
test: () => {
|
||||
const data: any = { table1: [{ col1: 'first col value' }] }
|
||||
return adapter.startComputeJob(
|
||||
'/Public/app/common/sendArr',
|
||||
'/Public/app/adapter-tests/services/common/sendArr',
|
||||
data,
|
||||
{},
|
||||
undefined,
|
||||
|
||||
BIN
screenshots/session-manager-first-request.png
Normal file
BIN
screenshots/session-manager-first-request.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
BIN
screenshots/subsequent-session-request.png
Normal file
BIN
screenshots/subsequent-session-request.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
@@ -29,11 +29,33 @@ describe('SASViyaApiClient', () => {
|
||||
jest
|
||||
.spyOn(requestClient, 'get')
|
||||
.mockImplementation(() => Promise.reject('Not Found'))
|
||||
|
||||
const error = await sasViyaApiClient
|
||||
.createFolder('test', '/foo')
|
||||
.catch((e: any) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||
})
|
||||
|
||||
it('should throw an error when ', async () => {
|
||||
const testMessage1 = 'test message 1'
|
||||
const testMessage2 = 'test message 2.'
|
||||
|
||||
jest.spyOn(requestClient, 'post').mockImplementation(() => {
|
||||
return Promise.reject({
|
||||
message: testMessage1,
|
||||
response: { data: { message: testMessage2 }, status: 409 }
|
||||
})
|
||||
})
|
||||
|
||||
const error = await sasViyaApiClient
|
||||
.createFolder('test', '/foo')
|
||||
.catch((e: any) => e)
|
||||
|
||||
const expectedError = `${testMessage1}. ${testMessage2} To override, please set "isForced" to "true".`
|
||||
|
||||
expect(error).toEqual(expectedError)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
|
||||
@@ -378,12 +378,14 @@ export class SASViyaApiClient {
|
||||
isForced?: boolean
|
||||
): Promise<Folder> {
|
||||
const logger = process.logger || console
|
||||
|
||||
if (!parentFolderPath && !parentFolderUri) {
|
||||
throw new Error('Path or URI of the parent folder is required.')
|
||||
}
|
||||
|
||||
if (!parentFolderUri && parentFolderPath) {
|
||||
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
|
||||
|
||||
if (!parentFolderUri) {
|
||||
logger.info(
|
||||
`Parent folder at path '${parentFolderPath}' is not present.`
|
||||
@@ -394,6 +396,7 @@ export class SASViyaApiClient {
|
||||
parentFolderPath.lastIndexOf('/')
|
||||
)
|
||||
const newFolderName = `${parentFolderPath.split('/').pop()}`
|
||||
|
||||
if (newParentFolderPath === '') {
|
||||
throw new RootFolderNotFoundError(
|
||||
parentFolderPath,
|
||||
@@ -401,20 +404,24 @@ export class SASViyaApiClient {
|
||||
accessToken
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
|
||||
)
|
||||
|
||||
const parentFolder = await this.createFolder(
|
||||
newFolderName,
|
||||
newParentFolderPath,
|
||||
undefined,
|
||||
accessToken
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`Parent folder '${newFolderName}' has been successfully created.`
|
||||
)
|
||||
|
||||
parentFolderUri = `/folders/folders/${parentFolder.id}`
|
||||
} else if (isForced && accessToken) {
|
||||
} else if (isForced) {
|
||||
const folderPath = parentFolderPath + '/' + folderName
|
||||
const folderUri = await this.getFolderUri(folderPath, accessToken)
|
||||
|
||||
@@ -427,8 +434,8 @@ export class SASViyaApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
const { result: createFolderResponse } =
|
||||
await this.requestClient.post<Folder>(
|
||||
const { result: createFolderResponse } = await this.requestClient
|
||||
.post<Folder>(
|
||||
`/folders/folders?parentFolderUri=${parentFolderUri}`,
|
||||
{
|
||||
name: folderName,
|
||||
@@ -436,12 +443,34 @@ export class SASViyaApiClient {
|
||||
},
|
||||
accessToken
|
||||
)
|
||||
.catch((err) => {
|
||||
const { message, response } = err
|
||||
|
||||
if (message && response && response.data && response.data.message) {
|
||||
const { status } = response
|
||||
const { message: responseMessage } = response.data
|
||||
const messages = [message, responseMessage].map((mes: string) =>
|
||||
/\.$/.test(mes) ? mes : `${mes}.`
|
||||
)
|
||||
|
||||
if (!isForced && status === 409) {
|
||||
messages.push(`To override, please set "isForced" to "true".`)
|
||||
}
|
||||
|
||||
const errMessage = messages.join(' ')
|
||||
|
||||
throw errMessage
|
||||
}
|
||||
|
||||
throw err
|
||||
})
|
||||
|
||||
// update folder map with newly created folder.
|
||||
await this.populateFolderMap(
|
||||
`${parentFolderPath}/${folderName}`,
|
||||
accessToken
|
||||
)
|
||||
|
||||
return createFolderResponse
|
||||
}
|
||||
|
||||
@@ -548,6 +577,7 @@ export class SASViyaApiClient {
|
||||
|
||||
/**
|
||||
* Exchanges the refresh token for an access token for the given client.
|
||||
* This method can only be used by Node.
|
||||
* @param clientId - the client ID to authenticate with.
|
||||
* @param clientSecret - the client secret to authenticate with.
|
||||
* @param refreshToken - the refresh token received from the server.
|
||||
@@ -899,7 +929,7 @@ export class SASViyaApiClient {
|
||||
return `/folders/folders/${folderDetails.id}`
|
||||
}
|
||||
|
||||
private async getRecycleBinUri(accessToken: string) {
|
||||
private async getRecycleBinUri(accessToken?: string) {
|
||||
const url = '/folders/folders/@myRecycleBin'
|
||||
|
||||
const { result: folder } = await this.requestClient
|
||||
@@ -983,7 +1013,7 @@ export class SASViyaApiClient {
|
||||
sourceFolder: string,
|
||||
targetParentFolder: string,
|
||||
targetFolderName: string,
|
||||
accessToken: string
|
||||
accessToken?: string
|
||||
) {
|
||||
// If target path is an existing folder, than keep source folder name, othervise rename it with given target folder name
|
||||
const sourceFolderName = sourceFolder.split('/').pop() as string
|
||||
@@ -1050,7 +1080,7 @@ export class SASViyaApiClient {
|
||||
* @param folderPath - the full path (eg `/Public/example/deleteThis`) of the folder to be deleted.
|
||||
* @param accessToken - an access token for authorizing the request.
|
||||
*/
|
||||
public async deleteFolder(folderPath: string, accessToken: string) {
|
||||
public async deleteFolder(folderPath: string, accessToken?: string) {
|
||||
const recycleBinUri = await this.getRecycleBinUri(accessToken)
|
||||
const folderName = folderPath.split('/').pop() || ''
|
||||
const date = new Date()
|
||||
|
||||
19
src/SASjs.ts
19
src/SASjs.ts
@@ -337,13 +337,16 @@ export default class SASjs {
|
||||
sasApiClient?: SASViyaApiClient,
|
||||
isForced?: boolean
|
||||
) {
|
||||
if (sasApiClient)
|
||||
if (sasApiClient) {
|
||||
return await sasApiClient.createFolder(
|
||||
folderName,
|
||||
parentFolderPath,
|
||||
parentFolderUri,
|
||||
accessToken
|
||||
accessToken,
|
||||
isForced
|
||||
)
|
||||
}
|
||||
|
||||
return await this.sasViyaApiClient!.createFolder(
|
||||
folderName,
|
||||
parentFolderPath,
|
||||
@@ -783,13 +786,11 @@ export default class SASjs {
|
||||
this.isMethodSupported('deployServicePack', [ServerType.SasViya])
|
||||
|
||||
let sasApiClient: any = null
|
||||
|
||||
if (serverUrl || appLoc) {
|
||||
if (!serverUrl) {
|
||||
serverUrl = this.sasjsConfig.serverUrl
|
||||
}
|
||||
if (!appLoc) {
|
||||
appLoc = this.sasjsConfig.appLoc
|
||||
}
|
||||
if (!serverUrl) serverUrl = this.sasjsConfig.serverUrl
|
||||
if (!appLoc) appLoc = this.sasjsConfig.appLoc
|
||||
|
||||
if (this.sasjsConfig.serverType === ServerType.SasViya) {
|
||||
sasApiClient = new SASViyaApiClient(
|
||||
serverUrl,
|
||||
@@ -807,11 +808,13 @@ export default class SASjs {
|
||||
}
|
||||
} else {
|
||||
let sasClientConfig: any = null
|
||||
|
||||
if (this.sasjsConfig.serverType === ServerType.SasViya) {
|
||||
sasClientConfig = this.sasViyaApiClient!.getConfig()
|
||||
} else if (this.sasjsConfig.serverType === ServerType.Sas9) {
|
||||
sasClientConfig = this.sas9ApiClient!.getConfig()
|
||||
}
|
||||
|
||||
serverUrl = sasClientConfig.serverUrl
|
||||
appLoc = sasClientConfig.rootFolderName as string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import { ExecutionQuery } from './types'
|
||||
import { RequestClient } from './request/RequestClient'
|
||||
import { getAccessTokenForSasjs } from './auth/getAccessTokenForSasjs'
|
||||
import { refreshTokensForSasjs } from './auth/refreshTokensForSasjs'
|
||||
import { getTokens } from './auth/getTokens'
|
||||
|
||||
// TODO: move to sasjs/utils
|
||||
export interface SASjsAuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface ScriptExecutionResult {
|
||||
log: string
|
||||
webout?: string
|
||||
printOutput?: string
|
||||
}
|
||||
|
||||
export class SASjsApiClient {
|
||||
constructor(private requestClient: RequestClient) {}
|
||||
|
||||
@@ -118,18 +131,28 @@ export class SASjsApiClient {
|
||||
code: string,
|
||||
runTime: string = 'sas',
|
||||
authConfig?: AuthConfig
|
||||
) {
|
||||
): Promise<ScriptExecutionResult> {
|
||||
const access_token = await this.getAccessTokenForRequest(authConfig)
|
||||
|
||||
let parsedSasjsServerLog = ''
|
||||
const executionResult: ScriptExecutionResult = { log: '' }
|
||||
|
||||
await this.requestClient
|
||||
.post('SASjsApi/code/execute', { code, runTime }, access_token)
|
||||
.then((res: any) => {
|
||||
if (res.log) parsedSasjsServerLog = res.log
|
||||
const { log, printOutput, result: webout } = res
|
||||
|
||||
executionResult.log = log
|
||||
|
||||
if (printOutput) executionResult.printOutput = printOutput
|
||||
if (webout) executionResult.webout = webout
|
||||
})
|
||||
.catch((err) => {
|
||||
throw prefixMessage(
|
||||
err,
|
||||
'Error while sending POST request to execute code. '
|
||||
)
|
||||
})
|
||||
|
||||
return parsedSasjsServerLog
|
||||
return executionResult
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,9 +175,3 @@ export class SASjsApiClient {
|
||||
return refreshTokensForSasjs(this.requestClient, refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// todo move to sasjs/utils
|
||||
export interface SASjsAuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { RequestClient } from './request/RequestClient'
|
||||
|
||||
const MAX_SESSION_COUNT = 1
|
||||
|
||||
interface ApiErrorResponse {
|
||||
response: { status: number | string; data: { message: string } }
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private loggedErrors: NoSessionStateError[] = []
|
||||
|
||||
@@ -17,8 +21,10 @@ export class SessionManager {
|
||||
if (serverUrl) isUrl(serverUrl)
|
||||
}
|
||||
|
||||
// INFO: session pool
|
||||
private sessions: Session[] = []
|
||||
private currentContext: Context | null = null
|
||||
private settingContext: boolean = false
|
||||
private _debug: boolean = false
|
||||
private printedSessionState = {
|
||||
printed: false,
|
||||
@@ -33,71 +39,230 @@ export class SessionManager {
|
||||
this._debug = value
|
||||
}
|
||||
|
||||
async getSession(accessToken?: string) {
|
||||
await this.createSessions(accessToken)
|
||||
await this.createAndWaitForSession(accessToken)
|
||||
const session = this.sessions.pop()
|
||||
/**
|
||||
* Checks if session is valid. Session is considered valid if time since it's creation is less than 'sessionInactiveTimeout' attribute.
|
||||
* @param session - session object.
|
||||
* @returns - boolean indicating if session is valid.
|
||||
*/
|
||||
private isSessionValid(session: Session): boolean {
|
||||
if (!session) return false
|
||||
|
||||
const secondsSinceSessionCreation =
|
||||
(new Date().getTime() - new Date(session!.creationTimeStamp).getTime()) /
|
||||
(new Date().getTime() - new Date(session.creationTimeStamp).getTime()) /
|
||||
1000
|
||||
|
||||
if (
|
||||
!session!.attributes ||
|
||||
secondsSinceSessionCreation >= session!.attributes.sessionInactiveTimeout
|
||||
) {
|
||||
await this.createSessions(accessToken)
|
||||
const freshSession = this.sessions.pop()
|
||||
|
||||
return freshSession
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
async clearSession(id: string, accessToken?: string) {
|
||||
/**
|
||||
* Removes session from pool of hot sessions.
|
||||
* @param session - session object.
|
||||
* @returns - void.
|
||||
*/
|
||||
private removeSessionFromPool(session: Session): void {
|
||||
this.sessions = this.sessions.filter((ses) => ses.id !== session.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters session pool to keep only valid sessions.
|
||||
* @param session - session object.
|
||||
* @returns - void.
|
||||
*/
|
||||
private removeExpiredSessions(): void {
|
||||
this.sessions = this.sessions.filter((session) =>
|
||||
this.isSessionValid(session)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws set of errors as a single error.
|
||||
* @param errors - array of errors or string.
|
||||
* @param prefix - an optional final error prefix.
|
||||
* @returns - never.
|
||||
*/
|
||||
private throwErrors(errors: (Error | string)[], prefix?: string): never {
|
||||
throw prefix
|
||||
? prefixMessage(new Error(errors.join('. ')), prefix)
|
||||
: new Error(
|
||||
errors
|
||||
.map((err) =>
|
||||
(err as Error).message ? (err as Error).message : err
|
||||
)
|
||||
.join('. ')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns session.
|
||||
* If there is a hot session available, it will be returned immediately and an asynchronous request to create new hot session will be submitted.
|
||||
* If there is no available session, 2 session creation requests will be submitted. The session is returned once it is created and ready.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves with a session.
|
||||
*/
|
||||
async getSession(accessToken?: string) {
|
||||
const errors: (Error | string)[] = []
|
||||
let isErrorThrown = false
|
||||
|
||||
const throwIfError = () => {
|
||||
if (errors.length && !isErrorThrown) {
|
||||
isErrorThrown = true
|
||||
|
||||
this.throwErrors(errors)
|
||||
}
|
||||
}
|
||||
|
||||
this.removeExpiredSessions()
|
||||
|
||||
if (this.sessions.length) {
|
||||
const session = this.sessions[0]
|
||||
|
||||
this.removeSessionFromPool(session)
|
||||
|
||||
this.createSessions(accessToken).catch((err) => {
|
||||
errors.push(err)
|
||||
})
|
||||
|
||||
this.createAndWaitForSession(accessToken).catch((err) => {
|
||||
errors.push(err)
|
||||
})
|
||||
|
||||
throwIfError()
|
||||
|
||||
return session
|
||||
} else {
|
||||
this.createSessions(accessToken).catch((err) => {
|
||||
errors.push(err)
|
||||
})
|
||||
|
||||
await this.createAndWaitForSession(accessToken).catch((err) => {
|
||||
errors.push(err)
|
||||
})
|
||||
|
||||
this.removeExpiredSessions()
|
||||
|
||||
const session = this.sessions.pop()!
|
||||
|
||||
this.removeSessionFromPool(session)
|
||||
|
||||
throwIfError()
|
||||
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns error message based on the response from SAS API.
|
||||
* @param err - an optional access token.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - an error message.
|
||||
*/
|
||||
private getErrorMessage(
|
||||
err: ApiErrorResponse,
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'DELETE'
|
||||
) {
|
||||
return (
|
||||
`${method} request to ${url} failed with status code ${
|
||||
err.response.status || 'unknown'
|
||||
}. ` + err.response.data.message || ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes session.
|
||||
* @param id - a session id.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves when session is deleted.
|
||||
*/
|
||||
async clearSession(id: string, accessToken?: string): Promise<void> {
|
||||
const url = `/compute/sessions/${id}`
|
||||
|
||||
return await this.requestClient
|
||||
.delete<Session>(`/compute/sessions/${id}`, accessToken)
|
||||
.delete<Session>(url, accessToken)
|
||||
.then(() => {
|
||||
this.sessions = this.sessions.filter((s) => s.id !== id)
|
||||
})
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while deleting session. ')
|
||||
.catch((err: ApiErrorResponse) => {
|
||||
throw prefixMessage(
|
||||
this.getErrorMessage(err, url, 'DELETE'),
|
||||
'Error while deleting session. '
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async createSessions(accessToken?: string) {
|
||||
/**
|
||||
* Creates sessions in amount equal to MAX_SESSION_COUNT.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves when required amount of sessions is created.
|
||||
*/
|
||||
private async createSessions(accessToken?: string): Promise<void> {
|
||||
const errors: (Error | string)[] = []
|
||||
|
||||
if (!this.sessions.length) {
|
||||
if (!this.currentContext) {
|
||||
await this.setCurrentContext(accessToken).catch((err) => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
await asyncForEach(new Array(MAX_SESSION_COUNT), async () => {
|
||||
const createdSession = await this.createAndWaitForSession(
|
||||
accessToken
|
||||
).catch((err) => {
|
||||
throw err
|
||||
await this.createAndWaitForSession(accessToken).catch((err) => {
|
||||
errors.push(err)
|
||||
})
|
||||
|
||||
this.sessions.push(createdSession)
|
||||
}).catch((err) => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
this.throwErrors(errors, 'Error while creating session. ')
|
||||
}
|
||||
}
|
||||
|
||||
private async createAndWaitForSession(accessToken?: string) {
|
||||
/**
|
||||
* Waits for the current context to be set.
|
||||
* @returns - a promise which resolves when current context is set.
|
||||
*/
|
||||
private async waitForCurrentContext(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (this.currentContext) {
|
||||
this.settingContext = false
|
||||
|
||||
clearInterval(timer)
|
||||
|
||||
resolve()
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and waits for session to be ready.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves with a session.
|
||||
*/
|
||||
private async createAndWaitForSession(
|
||||
accessToken?: string
|
||||
): Promise<Session> {
|
||||
if (!this.currentContext) {
|
||||
if (!this.settingContext) {
|
||||
await this.setCurrentContext(accessToken)
|
||||
} else {
|
||||
await this.waitForCurrentContext()
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${this.serverUrl}/compute/contexts/${
|
||||
this.currentContext!.id
|
||||
}/sessions`
|
||||
|
||||
const { result: createdSession, etag } = await this.requestClient
|
||||
.post<Session>(
|
||||
`${this.serverUrl}/compute/contexts/${
|
||||
this.currentContext!.id
|
||||
}/sessions`,
|
||||
{},
|
||||
accessToken
|
||||
)
|
||||
.catch((err) => {
|
||||
throw err
|
||||
.post<Session>(url, {}, accessToken)
|
||||
.catch((err: ApiErrorResponse) => {
|
||||
throw prefixMessage(
|
||||
this.getErrorMessage(err, url, 'POST'),
|
||||
`Error while creating session. `
|
||||
)
|
||||
})
|
||||
|
||||
await this.waitForSession(createdSession, etag, accessToken)
|
||||
@@ -107,14 +272,26 @@ export class SessionManager {
|
||||
return createdSession
|
||||
}
|
||||
|
||||
private async setCurrentContext(accessToken?: string) {
|
||||
/**
|
||||
* Sets current context.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves when current context is set.
|
||||
*/
|
||||
private async setCurrentContext(accessToken?: string): Promise<void> {
|
||||
if (!this.currentContext) {
|
||||
const url = `${this.serverUrl}/compute/contexts?limit=10000`
|
||||
|
||||
this.settingContext = true
|
||||
|
||||
const { result: contexts } = await this.requestClient
|
||||
.get<{
|
||||
items: Context[]
|
||||
}>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken)
|
||||
.catch((err) => {
|
||||
throw err
|
||||
}>(url, accessToken)
|
||||
.catch((err: ApiErrorResponse) => {
|
||||
throw prefixMessage(
|
||||
this.getErrorMessage(err, url, 'GET'),
|
||||
`Error while getting list of contexts. `
|
||||
)
|
||||
})
|
||||
|
||||
const contextsList =
|
||||
@@ -138,18 +315,13 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaders(accessToken?: string) {
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for session to be ready.
|
||||
* @param session - a session object.
|
||||
* @param etag - an etag that can be a string or null.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves with a session state.
|
||||
*/
|
||||
private async waitForSession(
|
||||
session: Session,
|
||||
etag: string | null,
|
||||
@@ -173,13 +345,11 @@ export class SessionManager {
|
||||
this.printedSessionState.printed = true
|
||||
}
|
||||
|
||||
const url = `${this.serverUrl}${stateLink.href}?wait=30`
|
||||
|
||||
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.')
|
||||
await this.getSessionState(url, etag!, accessToken).catch((err) => {
|
||||
throw prefixMessage(err, 'Error while waiting for session. ')
|
||||
})
|
||||
|
||||
sessionState = state.trim()
|
||||
@@ -216,7 +386,7 @@ export class SessionManager {
|
||||
|
||||
return sessionState
|
||||
} else {
|
||||
throw 'Error while getting session state link.'
|
||||
throw 'Error while getting session state link. '
|
||||
}
|
||||
} else {
|
||||
this.loggedErrors = []
|
||||
@@ -225,11 +395,21 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets session state.
|
||||
* @param url - a URL to get session state.
|
||||
* @param etag - an etag string.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves with a result string and response status.
|
||||
*/
|
||||
private async getSessionState(
|
||||
url: string,
|
||||
etag: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
): Promise<{
|
||||
result: string
|
||||
responseStatus: number
|
||||
}> {
|
||||
return await this.requestClient
|
||||
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
|
||||
.then((res) => ({
|
||||
@@ -237,20 +417,37 @@ export class SessionManager {
|
||||
responseStatus: res.status
|
||||
}))
|
||||
.catch((err) => {
|
||||
throw err
|
||||
throw prefixMessage(
|
||||
this.getErrorMessage(err, url, 'GET'),
|
||||
'Error while getting session state. '
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async getVariable(sessionId: string, variable: string, accessToken?: string) {
|
||||
/**
|
||||
* Gets variable.
|
||||
* @param sessionId - a session id.
|
||||
* @param variable - a variable string.
|
||||
* @param accessToken - an optional access token.
|
||||
* @returns - a promise which resolves with a result that confirms to SessionVariable interface, etag string and status code.
|
||||
*/
|
||||
async getVariable(
|
||||
sessionId: string,
|
||||
variable: string,
|
||||
accessToken?: string
|
||||
): Promise<{
|
||||
result: SessionVariable
|
||||
etag: string
|
||||
status: number
|
||||
}> {
|
||||
const url = `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`
|
||||
|
||||
return await this.requestClient
|
||||
.get<SessionVariable>(
|
||||
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
|
||||
accessToken
|
||||
)
|
||||
.get<SessionVariable>(url, accessToken)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(
|
||||
err,
|
||||
`Error while fetching session variable '${variable}'.`
|
||||
this.getErrorMessage(err, url, 'GET'),
|
||||
`Error while fetching session variable '${variable}'. `
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { refreshTokensForSasjs } from './refreshTokensForSasjs'
|
||||
|
||||
/**
|
||||
* Returns the auth configuration, refreshing the tokens if necessary.
|
||||
* This function can only be used by Node, if a server type is SASVIYA.
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param authConfig - an object containing a client ID, secret, access token and refresh token
|
||||
* @param serverType - server type for which refreshing the tokens, defaults to SASVIYA
|
||||
@@ -29,9 +30,12 @@ export async function getTokens(
|
||||
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.')
|
||||
|
||||
const tokens =
|
||||
serverType === ServerType.SasViya
|
||||
? await refreshTokensForViya(
|
||||
|
||||
@@ -2,9 +2,11 @@ import { SasAuthResponse } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
import { isNode } from '../utils'
|
||||
|
||||
/**
|
||||
* Exchanges the refresh token for an access token for the given client.
|
||||
* This function can only be used by Node.
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param clientId - the client ID to authenticate with.
|
||||
* @param clientSecret - the client secret to authenticate with.
|
||||
@@ -16,9 +18,12 @@ export async function refreshTokensForViya(
|
||||
clientSecret: string,
|
||||
refreshToken: string
|
||||
) {
|
||||
if (!isNode()) {
|
||||
throw new Error(`Method 'refreshTokensForViya' can only be used by Node.`)
|
||||
}
|
||||
|
||||
const url = '/SASLogon/oauth/token'
|
||||
let token
|
||||
token =
|
||||
const token =
|
||||
typeof Buffer === 'undefined'
|
||||
? btoa(clientId + ':' + clientSecret)
|
||||
: Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||
@@ -27,8 +32,7 @@ export async function refreshTokensForViya(
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
|
||||
const formData =
|
||||
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
|
||||
const formData = new NodeFormData()
|
||||
formData.append('grant_type', 'refresh_token')
|
||||
formData.append('refresh_token', refreshToken)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as NodeFormData from 'form-data'
|
||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { refreshTokensForViya } from '../refreshTokensForViya'
|
||||
import * as IsNodeModule from '../../utils/isNode'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
@@ -70,6 +71,18 @@ describe('refreshTokensForViya', () => {
|
||||
|
||||
expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
|
||||
})
|
||||
|
||||
it('should throw an error if environment is not Node', async () => {
|
||||
jest.spyOn(IsNodeModule, 'isNode').mockImplementation(() => false)
|
||||
|
||||
const expectedError = new Error(
|
||||
`Method 'refreshTokensForViya' can only be used by Node.`
|
||||
)
|
||||
|
||||
expect(
|
||||
refreshTokensForViya(requestClient, 'client', 'secret', 'token')
|
||||
).rejects.toEqual(expectedError)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
|
||||
@@ -101,7 +101,7 @@ export class FileUploader extends BaseJobExecutor {
|
||||
: res.result
|
||||
break
|
||||
}
|
||||
} else if (this.serverType !== ServerType.Sasjs) {
|
||||
} else {
|
||||
jsonResponse =
|
||||
typeof res.result === 'string'
|
||||
? getValidJson(res.result)
|
||||
|
||||
@@ -13,7 +13,11 @@ import { generateFileUploadForm } from '../file/generateFileUploadForm'
|
||||
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
|
||||
import { isRelativePath, appendExtraResponseAttributes } from '../utils'
|
||||
import {
|
||||
isRelativePath,
|
||||
appendExtraResponseAttributes,
|
||||
getValidJson
|
||||
} from '../utils'
|
||||
import { BaseJobExecutor } from './JobExecutor'
|
||||
|
||||
export class SasjsJobExecutor extends BaseJobExecutor {
|
||||
@@ -89,12 +93,18 @@ export class SasjsJobExecutor extends BaseJobExecutor {
|
||||
)
|
||||
}
|
||||
|
||||
const { result } = res
|
||||
|
||||
if (result && typeof result === 'string' && result.trim())
|
||||
res.result = getValidJson(result)
|
||||
|
||||
this.requestClient!.appendRequest(res, sasJob, config.debug)
|
||||
|
||||
const responseObject = appendExtraResponseAttributes(
|
||||
res,
|
||||
extraResponseAttributes
|
||||
)
|
||||
|
||||
resolve(responseObject)
|
||||
})
|
||||
.catch(async (e: Error) => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { RequestClient } from './RequestClient'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { SASJS_LOGS_SEPARATOR, getValidJson } from '../utils'
|
||||
import { SASJS_LOGS_SEPARATOR } from '../utils'
|
||||
|
||||
interface SasjsParsedResponse<T> {
|
||||
result: T
|
||||
log: string
|
||||
etag: string
|
||||
status: number
|
||||
printOutput?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific request client for SASJS.
|
||||
@@ -27,7 +35,7 @@ export class SasjsRequestClient extends RequestClient {
|
||||
protected parseResponse<T>(response: AxiosResponse<any>) {
|
||||
const etag = response?.headers ? response.headers['etag'] : ''
|
||||
let parsedResponse = {}
|
||||
let log
|
||||
let webout, log, printOutput
|
||||
|
||||
try {
|
||||
if (typeof response.data === 'string') {
|
||||
@@ -38,17 +46,26 @@ export class SasjsRequestClient extends RequestClient {
|
||||
} catch {
|
||||
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
|
||||
const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR)
|
||||
|
||||
webout = splittedResponse[0]
|
||||
if (webout !== undefined) parsedResponse = webout
|
||||
|
||||
log = splittedResponse[1]
|
||||
if (splittedResponse[0].trim())
|
||||
parsedResponse = getValidJson(splittedResponse[0])
|
||||
} else parsedResponse = response.data
|
||||
printOutput = splittedResponse[2]
|
||||
} else {
|
||||
parsedResponse = response.data
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const returnResult: SasjsParsedResponse<T> = {
|
||||
result: parsedResponse as T,
|
||||
log,
|
||||
etag,
|
||||
status: response.status
|
||||
}
|
||||
|
||||
if (printOutput) returnResult.printOutput = printOutput
|
||||
|
||||
return returnResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { RequestClient } from '../request/RequestClient'
|
||||
import * as dotenv from 'dotenv'
|
||||
import axios from 'axios'
|
||||
import { Logger, LogLevel } from '@sasjs/utils'
|
||||
import { Session } from '../types'
|
||||
import { Session, Context } from '../types'
|
||||
|
||||
jest.mock('axios')
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
describe('SessionManager', () => {
|
||||
dotenv.config()
|
||||
@@ -14,9 +15,23 @@ describe('SessionManager', () => {
|
||||
const sessionManager = new SessionManager(
|
||||
process.env.SERVER_URL as string,
|
||||
process.env.DEFAULT_COMPUTE_CONTEXT as string,
|
||||
new RequestClient('https://sample.server.com')
|
||||
requestClient
|
||||
)
|
||||
|
||||
const getMockSession = () => ({
|
||||
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
||||
state: '',
|
||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
||||
attributes: {
|
||||
sessionInactiveTimeout: 900
|
||||
},
|
||||
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getVariable', () => {
|
||||
it('should fetch session variable', async () => {
|
||||
const sampleResponse = {
|
||||
@@ -45,6 +60,30 @@ describe('SessionManager', () => {
|
||||
)
|
||||
).resolves.toEqual(expectedResponse)
|
||||
})
|
||||
|
||||
it('should throw an error if GET request failed', async () => {
|
||||
const responseStatus = 500
|
||||
const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}`
|
||||
const response = {
|
||||
status: responseStatus,
|
||||
data: {
|
||||
message: responseErrorMessage
|
||||
}
|
||||
}
|
||||
const testVariable = 'testVariable'
|
||||
|
||||
jest.spyOn(requestClient, 'get').mockImplementation(() =>
|
||||
Promise.reject({
|
||||
response
|
||||
})
|
||||
)
|
||||
|
||||
const expectedError = `Error while fetching session variable '${testVariable}'. GET request to ${process.env.SERVER_URL}/compute/sessions/testId/variables/${testVariable} failed with status code ${responseStatus}. ${responseErrorMessage}`
|
||||
|
||||
await expect(
|
||||
sessionManager.getVariable('testId', testVariable)
|
||||
).rejects.toEqual(expectedError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForSession', () => {
|
||||
@@ -115,11 +154,25 @@ describe('SessionManager', () => {
|
||||
})
|
||||
|
||||
it('should throw an error if could not get session state', async () => {
|
||||
mockedAxios.get.mockImplementation(() => Promise.reject('Mocked error'))
|
||||
const gettingSessionStatus = 500
|
||||
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
|
||||
|
||||
mockedAxios.get.mockImplementation(() =>
|
||||
Promise.reject({
|
||||
response: {
|
||||
status: gettingSessionStatus,
|
||||
data: {
|
||||
message: sessionStatusError
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${process.env.SERVER_URL}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
|
||||
|
||||
await expect(
|
||||
sessionManager['waitForSession'](session, null, 'access_token')
|
||||
).rejects.toContain('Error while getting session state.')
|
||||
).rejects.toEqual(expectedError)
|
||||
})
|
||||
|
||||
it('should return session state', async () => {
|
||||
@@ -135,4 +188,243 @@ describe('SessionManager', () => {
|
||||
).resolves.toEqual(customSession.state)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionValid', () => {
|
||||
const session: Session = getMockSession()
|
||||
|
||||
it('should return false if not a session provided', () => {
|
||||
expect(sessionManager['isSessionValid'](undefined as any)).toEqual(false)
|
||||
})
|
||||
|
||||
it('should return true if session is not expired', () => {
|
||||
expect(sessionManager['isSessionValid'](session)).toEqual(true)
|
||||
})
|
||||
|
||||
it('should return false if session is expired', () => {
|
||||
session.creationTimeStamp = `${new Date(
|
||||
new Date().getTime() -
|
||||
(session.attributes.sessionInactiveTimeout * 1000 + 1000)
|
||||
).toISOString()}`
|
||||
expect(sessionManager['isSessionValid'](session)).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSessionFromPool', () => {
|
||||
it('should remove session from the pool of sessions', () => {
|
||||
const session: Session = getMockSession()
|
||||
const sessions: Session[] = [getMockSession(), session]
|
||||
|
||||
sessionManager['sessions'] = sessions
|
||||
sessionManager['removeSessionFromPool'](session)
|
||||
|
||||
expect(sessionManager['sessions'].length).toEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return session if there is a valid session and create new session', async () => {
|
||||
jest
|
||||
.spyOn(sessionManager as any, 'createAndWaitForSession')
|
||||
.mockImplementation(async () => Promise.resolve(getMockSession()))
|
||||
|
||||
const session = getMockSession()
|
||||
sessionManager['sessions'] = [session]
|
||||
|
||||
await expect(sessionManager.getSession()).resolves.toEqual(session)
|
||||
expect(sessionManager['createAndWaitForSession']).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return a session and keep one session if there is no sessions available', async () => {
|
||||
jest
|
||||
.spyOn(sessionManager as any, 'createAndWaitForSession')
|
||||
.mockImplementation(async () => {
|
||||
const session = getMockSession()
|
||||
sessionManager['sessions'].push(session)
|
||||
|
||||
return Promise.resolve(session)
|
||||
})
|
||||
|
||||
const session = await sessionManager.getSession()
|
||||
|
||||
expect(Object.keys(session)).toEqual(Object.keys(getMockSession()))
|
||||
expect(sessionManager['createAndWaitForSession']).toHaveBeenCalledTimes(2)
|
||||
expect(sessionManager['sessions'].length).toEqual(1)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should throw an error if session creation request returned 500',
|
||||
async () => {
|
||||
const sessionCreationStatus = 500
|
||||
const sessionCreationError = `The process initialization for the Compute server with the ID 'ed40398a-ec8a-422b-867a-61493ee8a57f' timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}`
|
||||
|
||||
jest.spyOn(requestClient, 'post').mockImplementation(() =>
|
||||
Promise.reject({
|
||||
response: {
|
||||
status: sessionCreationStatus,
|
||||
data: {
|
||||
message: sessionCreationError
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const contextId = 'testContextId'
|
||||
const context: Context = {
|
||||
name: 'testContext',
|
||||
id: contextId,
|
||||
createdBy: 'createdBy',
|
||||
version: 1
|
||||
}
|
||||
|
||||
sessionManager['currentContext'] = context
|
||||
|
||||
const expectedError = new Error(
|
||||
`Error while creating session. POST request to ${process.env.SERVER_URL}/compute/contexts/${contextId}/sessions failed with status code ${sessionCreationStatus}. ${sessionCreationError}`
|
||||
)
|
||||
|
||||
await expect(sessionManager.getSession()).rejects.toEqual(expectedError)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('clearSession', () => {
|
||||
it('should clear session', async () => {
|
||||
jest
|
||||
.spyOn(requestClient, 'delete')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: '', etag: '', status: 200 })
|
||||
)
|
||||
|
||||
const sessionToBeCleared = getMockSession()
|
||||
const sessionToStay = getMockSession()
|
||||
|
||||
sessionManager['sessions'] = [sessionToBeCleared, sessionToStay]
|
||||
|
||||
await sessionManager.clearSession(sessionToBeCleared.id)
|
||||
|
||||
expect(sessionManager['sessions']).toEqual([sessionToStay])
|
||||
})
|
||||
|
||||
it('should throw error if DELETE request failed', async () => {
|
||||
const sessionCreationStatus = 500
|
||||
const sessionDeleteError = `The process timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}`
|
||||
|
||||
jest.spyOn(requestClient, 'delete').mockImplementation(() =>
|
||||
Promise.reject({
|
||||
response: {
|
||||
status: sessionCreationStatus,
|
||||
data: {
|
||||
message: sessionDeleteError
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const session = getMockSession()
|
||||
|
||||
sessionManager['sessions'] = [session]
|
||||
|
||||
const expectedError = `Error while deleting session. DELETE request to /compute/sessions/${session.id} failed with status code ${sessionCreationStatus}. ${sessionDeleteError}`
|
||||
|
||||
await expect(sessionManager.clearSession(session.id)).rejects.toEqual(
|
||||
expectedError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForCurrentContext', () => {
|
||||
it('should resolve when current context is ready', async () => {
|
||||
sessionManager['settingContext'] = true
|
||||
sessionManager['contextName'] = 'test context'
|
||||
|
||||
await expect(sessionManager['waitForCurrentContext']()).toResolve()
|
||||
expect(sessionManager['settingContext']).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentContext', () => {
|
||||
it('should set current context', async () => {
|
||||
const contextName = 'test context'
|
||||
const testContext: Context = {
|
||||
name: contextName,
|
||||
id: 'string',
|
||||
createdBy: 'string',
|
||||
version: 1
|
||||
}
|
||||
|
||||
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
result: {
|
||||
items: [testContext]
|
||||
},
|
||||
etag: '',
|
||||
status: 200
|
||||
})
|
||||
})
|
||||
|
||||
sessionManager['currentContext'] = null
|
||||
sessionManager['contextName'] = contextName
|
||||
sessionManager['settingContext'] = false
|
||||
|
||||
await expect(sessionManager['setCurrentContext']()).toResolve()
|
||||
expect(sessionManager['currentContext']).toEqual(testContext)
|
||||
})
|
||||
|
||||
it('should throw error if GET request failed', async () => {
|
||||
const responseStatus = 500
|
||||
const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}`
|
||||
const response = {
|
||||
status: responseStatus,
|
||||
data: {
|
||||
message: responseErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
jest.spyOn(requestClient, 'get').mockImplementation(() =>
|
||||
Promise.reject({
|
||||
response
|
||||
})
|
||||
)
|
||||
|
||||
const expectedError = `Error while getting list of contexts. GET request to ${process.env.SERVER_URL}/compute/contexts?limit=10000 failed with status code ${responseStatus}. ${responseErrorMessage}`
|
||||
|
||||
sessionManager['currentContext'] = null
|
||||
|
||||
await expect(sessionManager['setCurrentContext']()).rejects.toEqual(
|
||||
expectedError
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error if current context is not in the list of contexts', async () => {
|
||||
const contextName = 'test context'
|
||||
const testContext: Context = {
|
||||
name: `${contextName} does not exist`,
|
||||
id: 'string',
|
||||
createdBy: 'string',
|
||||
version: 1
|
||||
}
|
||||
|
||||
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
result: {
|
||||
items: [testContext]
|
||||
},
|
||||
etag: '',
|
||||
status: 200
|
||||
})
|
||||
})
|
||||
|
||||
sessionManager['currentContext'] = null
|
||||
sessionManager['contextName'] = contextName
|
||||
sessionManager['settingContext'] = false
|
||||
|
||||
const expectedError = new Error(
|
||||
`The context '${contextName}' was not found on the server ${process.env.SERVER_URL}.`
|
||||
)
|
||||
|
||||
await expect(sessionManager['setCurrentContext']()).rejects.toEqual(
|
||||
expectedError
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,8 +21,8 @@ export class ErrorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
export interface ErrorBody {
|
||||
message: string
|
||||
details: string
|
||||
details: any
|
||||
raw: any
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const getValidJson = (str: string | object): object => {
|
||||
return JSON.parse(str)
|
||||
} catch (e: any) {
|
||||
if (e instanceof JsonParseArrayError) throw e
|
||||
|
||||
throw new InvalidJsonError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ const nodeConfig = {
|
||||
entry: './node/index.ts',
|
||||
output: {
|
||||
...browserConfig.output,
|
||||
path: path.resolve(__dirname, 'build', 'node')
|
||||
path: path.resolve(__dirname, 'build', 'node'),
|
||||
filename: 'index.js'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user