mirror of
https://github.com/sasjs/adapter.git
synced 2026-01-03 18:50:05 +00:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde28046be | ||
|
|
eab61a80bf | ||
|
|
9149f932c3 | ||
|
|
fb30ff8876 | ||
|
|
afff422333 | ||
|
|
b49010cfe5 | ||
|
|
fd6fad9b07 | ||
|
|
8a10c229d6 | ||
|
|
66462fcc50 | ||
|
|
7e23b5db9d | ||
| 78f117812e | |||
|
|
55af8c3f50 | ||
| 1185c2f1bf | |||
|
|
2842636c4a | ||
| 8c7f614509 | |||
| 943f60ea11 | |||
| 3de343f135 | |||
| e11c97ec5d | |||
| 49fba07824 | |||
| b1c0e26c23 | |||
|
|
3ec73750b7 | ||
| e3c4cb6b90 | |||
| d35f1617b8 | |||
| 302752d79e | |||
| 4e1e3e8e77 | |||
| 954d3ff633 | |||
| fce0c7e522 | |||
| d0fbc7b8c7 | |||
| 6171199a7e | |||
| 4fb0b96f11 | |||
| 008a9b4ca5 | |||
| b3b2c1414c | |||
| 18be9e8806 | |||
| 7bdd826418 | |||
| 3713a226a4 | |||
| 77306fedee | |||
| be3ce56b85 | |||
| 851b8fce2a | |||
|
|
16dd175053 | ||
| 27698b3e8a | |||
| 0faa50685d | |||
|
|
0f20048fb4 | ||
| 249837dacf | |||
| a115c12f55 | |||
| 61c4d21467 | |||
| 3e9f38529f | |||
|
|
06f79307b9 | ||
|
|
5122d2a9c9 | ||
|
|
dc3eb3f0db | ||
|
|
b940bc7cc3 | ||
|
|
82fc55ac1c | ||
| fc1a22c8c5 | |||
| 57b9f86077 | |||
|
|
68f7b2eac2 | ||
|
|
2676873bb0 | ||
| add2f0a860 | |||
| 2072136577 | |||
| afae632fc6 | |||
|
|
317587a3c8 | ||
|
|
ffd6bc5a5c | ||
|
|
c2e64d9ba6 | ||
|
|
a90f699abd | ||
|
|
2cca192f88 | ||
|
|
053b07769a | ||
|
|
4c4511913c | ||
|
|
8c64c24f3c | ||
|
|
1f2f445002 | ||
|
|
6afa056a86 | ||
|
|
fe47ca1152 | ||
|
|
10da691f0f | ||
|
|
318f9694cb | ||
|
|
56e6131e5c | ||
| 5dfee30875 | |||
|
|
3a186bc55c | ||
|
|
0359fcb6be | ||
|
|
f2997169cb | ||
|
|
451f2dfaca | ||
|
|
38e11f1771 | ||
|
|
259b6b3ff2 | ||
|
|
5b2d9e675f | ||
|
|
8dd4ab8cec | ||
|
|
34135b889f | ||
|
|
62f4577b64 | ||
|
|
7a4feddd82 | ||
|
|
681abf5b3b | ||
|
|
46c6d3e7f4 | ||
|
|
5731b0f9b1 | ||
|
|
f18a523087 | ||
|
|
8cbd292f13 | ||
|
|
4851f25753 | ||
|
|
5756638dc2 | ||
|
|
e511cd613c | ||
|
|
2119c81ebb | ||
|
|
ea4b30d6ef | ||
|
|
f1e1b33571 | ||
|
|
ccb8599f00 | ||
|
|
5bcd17096b | ||
|
|
d744ee12a3 | ||
|
|
5f15226cd9 | ||
|
|
f31ea28b9c | ||
|
|
e315e4a619 | ||
|
|
76bf5b88e9 | ||
|
|
a97ac4eaa6 | ||
|
|
37cfea6ca7 | ||
|
|
f74c8aca57 | ||
|
|
77baaabfcd | ||
|
|
510ba771f0 | ||
|
|
6fce65f4c8 | ||
|
|
fe03faa59f | ||
|
|
6272eeda23 | ||
|
|
104d1b88b3 | ||
|
|
0d9ba36de8 | ||
|
|
4e7a845d99 | ||
|
|
716cc513ff | ||
|
|
22edcb0a8e | ||
|
|
aedf5c1734 | ||
|
|
784bd20ee0 | ||
|
|
61db1e0609 | ||
|
|
5c589a6af3 | ||
|
|
275cd6dbd3 | ||
|
|
d874e07889 | ||
|
|
1648cf28d5 | ||
| a4aaeba31c | |||
|
|
6bf68a315c | ||
|
|
c0f78d0c1e | ||
|
|
e0aebc169f | ||
|
|
9a50e5cb63 | ||
|
|
a51923dad7 | ||
|
|
9aee77f0e3 | ||
|
|
c32d037063 | ||
|
|
94f7492c31 | ||
|
|
d29e0a0f57 | ||
|
|
8d7cc11db5 | ||
|
|
28e9d1cc6b | ||
|
|
375cec48ca | ||
| 7d826685f7 | |||
| f42f6bca00 | |||
|
|
4440e5d1f9 | ||
|
|
f484a5a6a1 | ||
|
|
5c74186bab | ||
|
|
ea68c3dff3 | ||
|
|
153b285670 | ||
|
|
f9f4aa5aa6 | ||
|
|
bd02656b3c | ||
|
|
991519a13d | ||
|
|
615c9d012e | ||
|
|
bd872e0e75 | ||
|
|
a14a1663fc | ||
|
|
d166231c12 | ||
|
|
4cb150e951 | ||
|
|
fc8598473f | ||
|
|
367e0ae25a | ||
|
|
85dde61baf |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -14,7 +14,7 @@ What code changes have been made to achieve the intent.
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
- [ ] Unit tests coverage has been increased and a new threshold is set.
|
||||||
- [ ] All `sasjs-cli` unit tests are passing (`npm test`).
|
- [ ] All `sasjs-cli` unit tests are passing (`npm test`).
|
||||||
- (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)).
|
- (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (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
|
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya
|
||||||
|
|||||||
4
.github/reviewer-lottery.yml
vendored
4
.github/reviewer-lottery.yml
vendored
@@ -5,7 +5,3 @@ groups:
|
|||||||
- YuryShkoda
|
- YuryShkoda
|
||||||
- medjedovicm
|
- medjedovicm
|
||||||
- sabhas
|
- sabhas
|
||||||
- name: SASjs QA
|
|
||||||
reviewers: 1
|
|
||||||
usernames:
|
|
||||||
- VladislavParhomchik
|
|
||||||
|
|||||||
8
.github/vpn/config.ovpn
vendored
8
.github/vpn/config.ovpn
vendored
@@ -3,10 +3,12 @@ client
|
|||||||
tls-client
|
tls-client
|
||||||
dev tun
|
dev tun
|
||||||
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
|
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
|
||||||
proto tcp
|
proto udp
|
||||||
remote vpn.4gl.io 7494
|
remote vpn.4gl.io 7194
|
||||||
resolv-retry infinite
|
resolv-retry infinite
|
||||||
cipher AES-256-CBC
|
# this will fallback from udp6 to udp4 as well
|
||||||
|
connect-timeout 5
|
||||||
|
data-ciphers AES-256-CBC:AES-256-GCM
|
||||||
auth SHA256
|
auth SHA256
|
||||||
script-security 2
|
script-security 2
|
||||||
keepalive 10 120
|
keepalive 10 120
|
||||||
|
|||||||
2
.github/workflows/assign-reviewer.yml
vendored
2
.github/workflows/assign-reviewer.yml
vendored
@@ -10,4 +10,4 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: uesteibar/reviewer-lottery@v1
|
- uses: uesteibar/reviewer-lottery@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GH_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
58
.github/workflows/build-unit-tests.yml
vendored
Normal file
58
.github/workflows/build-unit-tests.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||||
|
|
||||||
|
name: SASjs Build and Unit Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [lts/hydrogen]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
# 2. Restore npm cache manually
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
- name: Check npm audit
|
||||||
|
run: npm audit --production --audit-level=low
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Rimraf
|
||||||
|
run: npm i rimraf
|
||||||
|
|
||||||
|
- name: Check code style
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build Package
|
||||||
|
run: npm run package:lib
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
|
||||||
|
- name: Generate coverage report
|
||||||
|
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
17
.github/workflows/generateDocs.yml
vendored
17
.github/workflows/generateDocs.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [lts/fermium]
|
node-version: [lts/hydrogen]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -21,7 +21,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: npm
|
|
||||||
|
# 2. Restore npm cache manually
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -37,8 +46,8 @@ jobs:
|
|||||||
- name: Push generated docs
|
- name: Push generated docs
|
||||||
uses: peaceiris/actions-gh-pages@v3
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GH_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
publish_branch: gh-pages
|
publish_branch: gh-pages
|
||||||
publish_dir: ./docs
|
publish_dir: ./docs
|
||||||
cname: adapter.sasjs.io
|
cname: adapter.sasjs.io
|
||||||
|
|
||||||
|
|||||||
15
.github/workflows/npmpublish.yml
vendored
15
.github/workflows/npmpublish.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [lts/fermium]
|
node-version: [lts/hydrogen]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
@@ -22,7 +22,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: npm
|
|
||||||
|
# 2. Restore npm cache manually
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -36,7 +45,7 @@ jobs:
|
|||||||
- name: Semantic Release
|
- name: Semantic Release
|
||||||
uses: cycjimmy/semantic-release-action@v3
|
uses: cycjimmy/semantic-release-action@v3
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: Send Matrix message
|
- name: Send Matrix message
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||||
|
|
||||||
name: SASjs Build
|
name: SASjs Build and Server Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [lts/fermium]
|
node-version: [lts/hydrogen]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
@@ -20,19 +20,22 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Check npm audit
|
# 2. Restore npm cache manually
|
||||||
run: npm audit --production --audit-level=low
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Check code style
|
- name: Install Rimraf
|
||||||
run: npm run lint
|
run: npm i rimraf
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build Package
|
- name: Build Package
|
||||||
run: npm run package:lib
|
run: npm run package:lib
|
||||||
@@ -52,6 +55,10 @@ jobs:
|
|||||||
USER_KEY: ${{ secrets.USER_KEY }}
|
USER_KEY: ${{ secrets.USER_KEY }}
|
||||||
TLS_KEY: ${{ secrets.TLS_KEY }}
|
TLS_KEY: ${{ secrets.TLS_KEY }}
|
||||||
|
|
||||||
|
- name: Chmod VPN files
|
||||||
|
run: |
|
||||||
|
chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key
|
||||||
|
|
||||||
- name: Install Open VPN
|
- name: Install Open VPN
|
||||||
run: |
|
run: |
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
@@ -67,6 +74,9 @@ jobs:
|
|||||||
- name: install pm2
|
- name: install pm2
|
||||||
run: npm i -g pm2
|
run: npm i -g pm2
|
||||||
|
|
||||||
|
- name: Fetch SASJS server
|
||||||
|
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
|
||||||
|
|
||||||
- name: Deploy sasjs-tests
|
- name: Deploy sasjs-tests
|
||||||
run: |
|
run: |
|
||||||
npm install -g replace-in-files-cli
|
npm install -g replace-in-files-cli
|
||||||
@@ -75,20 +85,23 @@ jobs:
|
|||||||
npm i
|
npm i
|
||||||
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
|
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
|
||||||
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
|
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
|
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
|
||||||
|
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
|
||||||
|
cat ./public/config.json
|
||||||
|
|
||||||
npm run update:adapter
|
npm run update:adapter
|
||||||
pm2 start --name sasjs-test npm -- start
|
pm2 start --name sasjs-test npm -- start
|
||||||
|
|
||||||
|
- name: Sleep for 10 seconds
|
||||||
|
run: sleep 10s
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Run cypress on sasjs
|
- name: Run cypress on sasjs
|
||||||
run: |
|
run: |
|
||||||
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./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='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
|
||||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./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}}
|
cat ./cypress.json
|
||||||
|
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
|
||||||
|
|
||||||
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
|
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
||||||
- name: Generate coverage report
|
|
||||||
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": ["SASVIYA"]
|
||||||
|
}
|
||||||
@@ -151,7 +151,11 @@ The `request()` method also has optional parameters such as a config object and
|
|||||||
|
|
||||||
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
|
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### Verbose Mode
|
||||||
|
|
||||||
|
Set `verbose` to `true` to enable verbose mode that logs a summary of every HTTP response. Verbose mode can be disabled by calling `disableVerboseMode` method or enabled by `enableVerboseMode` method. Verbose mode also supports `bleached` mode that disables extra colors in req/res summary. To enable `bleached` verbose mode, pass `verbose` equal to `bleached` while instantiating an instance of `RequestClient` or to `setVerboseMode` method. Verbose mode can also be enabled/disabled by `startComputeJob` method.
|
||||||
|
|
||||||
### Session Manager
|
### Session Manager
|
||||||
|
|
||||||
@@ -273,6 +277,7 @@ Configuration on the client side involves passing an object on startup, which ca
|
|||||||
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
|
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
|
||||||
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
|
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
|
||||||
* `debug` - if `true` then SAS Logs and extra debug information is returned.
|
* `debug` - if `true` then SAS Logs and extra debug information is returned.
|
||||||
|
* `verbose` - optional, if `true` then a summary of every HTTP response is logged.
|
||||||
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
|
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
|
||||||
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
|
* `useComputeApi` - 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`.
|
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
|
||||||
|
|||||||
@@ -4,93 +4,56 @@ const password = Cypress.env('password')
|
|||||||
const testingFinishTimeout = Cypress.env('testingFinishTimeout')
|
const testingFinishTimeout = Cypress.env('testingFinishTimeout')
|
||||||
|
|
||||||
context('sasjs-tests', function () {
|
context('sasjs-tests', function () {
|
||||||
this.beforeAll(() => {
|
before(() => {
|
||||||
cy.visit(sasjsTestsUrl)
|
cy.visit(sasjsTestsUrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.reload()
|
cy.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have all tests successfull', (done) => {
|
function loginIfNeeded() {
|
||||||
cy.get('body').then(($body) => {
|
cy.get('body').then(($body) => {
|
||||||
cy.wait(1000).then(() => {
|
if ($body.find('input[placeholder="User Name"]').length > 0) {
|
||||||
const startButton = $body.find(
|
cy.get('input[placeholder="User Name"]')
|
||||||
'.ui.massive.icon.primary.left.labeled.button'
|
.should('be.visible')
|
||||||
)[0]
|
.type(username)
|
||||||
|
cy.get('input[placeholder="Password"]')
|
||||||
if (
|
.should('be.visible')
|
||||||
!startButton ||
|
.type(password)
|
||||||
(startButton && !Cypress.dom.isVisible(startButton))
|
cy.get('.submit-button').should('be.visible').click()
|
||||||
) {
|
cy.get('input[placeholder="User Name"]').should('not.exist') // Wait for login to finish
|
||||||
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 successful', () => {
|
||||||
|
loginIfNeeded()
|
||||||
|
|
||||||
|
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||||
|
.should('be.visible')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.ui.massive.loading.primary.button', {
|
||||||
|
timeout: testingFinishTimeout
|
||||||
|
}).should('not.exist')
|
||||||
|
|
||||||
|
cy.get('span.icon.failed').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have all tests successfull with debug on', (done) => {
|
it('Should have all tests successful with debug on', () => {
|
||||||
cy.get('body').then(($body) => {
|
loginIfNeeded()
|
||||||
cy.wait(1000).then(() => {
|
|
||||||
const startButton = $body.find(
|
|
||||||
'.ui.massive.icon.primary.left.labeled.button'
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
if (
|
cy.get('.ui.fitted.toggle.checkbox label').should('be.visible').click()
|
||||||
!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')
|
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||||
.click()
|
.should('be.visible')
|
||||||
.then(() => {
|
.click()
|
||||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
|
||||||
.should('not.exist')
|
cy.get('.ui.massive.loading.primary.button', {
|
||||||
.then(() => {
|
timeout: testingFinishTimeout
|
||||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
}).should('not.exist')
|
||||||
.click()
|
|
||||||
.then(() => {
|
cy.get('span.icon.failed').should('not.exist')
|
||||||
cy.get('.ui.massive.loading.primary.button', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('span.icon.failed')
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
cypress/videos/sasjs.tests.ts.mp4
Normal file
BIN
cypress/videos/sasjs.tests.ts.mp4
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<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>
|
<script>
|
||||||
var sasJs = new SASjs.default({
|
var sasJs = new SASjs.default({
|
||||||
appLoc: "/Public/app/readme"
|
appLoc: "/Public/app/readme"
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ module.exports = {
|
|||||||
// ],
|
// ],
|
||||||
|
|
||||||
// An object that configures minimum threshold enforcement for coverage results
|
// An object that configures minimum threshold enforcement for coverage results
|
||||||
// coverageThreshold: undefined,
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 64.03,
|
||||||
|
branches: 45.11,
|
||||||
|
functions: 54.18,
|
||||||
|
lines: 64.53
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// A path to a custom dependency extractor
|
// A path to a custom dependency extractor
|
||||||
// dependencyExtractor: undefined,
|
// dependencyExtractor: undefined,
|
||||||
@@ -135,6 +142,8 @@ module.exports = {
|
|||||||
// Options that will be passed to the testEnvironment
|
// Options that will be passed to the testEnvironment
|
||||||
// testEnvironmentOptions: {},
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
|
testEnvironment: 'node',
|
||||||
|
|
||||||
// Adds a location field to test results
|
// Adds a location field to test results
|
||||||
// testLocationInResults: false,
|
// testLocationInResults: false,
|
||||||
|
|
||||||
|
|||||||
23306
package-lock.json
generated
23306
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -6,7 +6,7 @@
|
|||||||
"nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m",
|
"nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m",
|
||||||
"preinstall": "npm run nodeVersionMessage",
|
"preinstall": "npm run nodeVersionMessage",
|
||||||
"prebuild": "npm run nodeVersionMessage",
|
"prebuild": "npm run nodeVersionMessage",
|
||||||
"build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && rimraf build/src && rimraf node",
|
"build": "npx rimraf build && npx rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && npx rimraf build/src && npx rimraf node",
|
||||||
"package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack",
|
"package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack",
|
||||||
"publish:lib": "npm run build && cd build && npm publish",
|
"publish:lib": "npm run build && cd build && npm publish",
|
||||||
"lint:fix": "npx prettier --loglevel silent --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"cypress/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
"lint:fix": "npx prettier --loglevel silent --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"cypress/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
||||||
@@ -45,43 +45,45 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/webpack-preprocessor": "5.9.1",
|
"@cypress/webpack-preprocessor": "5.9.1",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.13",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/mime": "2.0.3",
|
"@types/mime": "2.0.3",
|
||||||
"@types/pem": "1.9.6",
|
"@types/pem": "1.9.6",
|
||||||
"@types/tough-cookie": "4.0.1",
|
"@types/tough-cookie": "4.0.2",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"cp": "0.2.0",
|
"cp": "0.2.0",
|
||||||
"cypress": "7.7.0",
|
"cypress": "7.7.0",
|
||||||
"dotenv": "16.0.0",
|
"dotenv": "16.0.0",
|
||||||
"express": "4.17.3",
|
"express": "4.17.3",
|
||||||
"jest": "27.4.7",
|
"jest": "29.7.0",
|
||||||
"jest-extended": "2.0.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-extended": "4.0.2",
|
||||||
"node-polyfill-webpack-plugin": "1.1.4",
|
"node-polyfill-webpack-plugin": "1.1.4",
|
||||||
"path": "0.12.7",
|
"path": "0.12.7",
|
||||||
"pem": "1.14.5",
|
"pem": "1.14.5",
|
||||||
"prettier": "2.8.7",
|
"prettier": "2.8.7",
|
||||||
"process": "0.11.10",
|
"process": "0.11.10",
|
||||||
"rimraf": "3.0.2",
|
|
||||||
"semantic-release": "19.0.3",
|
"semantic-release": "19.0.3",
|
||||||
"terser-webpack-plugin": "5.3.6",
|
"terser-webpack-plugin": "5.3.6",
|
||||||
"ts-jest": "27.1.3",
|
"ts-jest": "29.2.6",
|
||||||
"ts-loader": "9.4.0",
|
"ts-loader": "9.4.0",
|
||||||
"tslint": "6.1.3",
|
"tslint": "6.1.3",
|
||||||
"tslint-config-prettier": "1.18.0",
|
"tslint-config-prettier": "1.18.0",
|
||||||
"typedoc": "0.23.24",
|
"typedoc": "0.23.24",
|
||||||
"typedoc-plugin-rename-defaults": "0.6.4",
|
"typedoc-plugin-rename-defaults": "0.6.4",
|
||||||
"typescript": "4.8.3",
|
"typescript": "4.9.5",
|
||||||
"webpack": "5.76.2",
|
"webpack": "5.76.2",
|
||||||
"webpack-cli": "4.9.2"
|
"webpack-cli": "4.9.2"
|
||||||
},
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/utils": "2.52.0",
|
"@sasjs/utils": "3.5.2",
|
||||||
"axios": "0.27.2",
|
"axios": "1.12.2",
|
||||||
"axios-cookiejar-support": "1.0.1",
|
"axios-cookiejar-support": "5.0.5",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.4",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
"tough-cookie": "4.0.0"
|
"tough-cookie": "4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
SKIP_PREFLIGHT_CHECK=true
|
SKIP_PREFLIGHT_CHECK=true
|
||||||
|
# Removes index.html inline scripts
|
||||||
|
INLINE_RUNTIME_CHUNK=false
|
||||||
|
|||||||
15
sasjs-tests/craco.config.js
Normal file
15
sasjs-tests/craco.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// craco.config.js
|
||||||
|
// We use craco instead of react-scripts so we can override webpack config, to include source maps
|
||||||
|
// so we can debug @sasjs/adapter easier when tests fail
|
||||||
|
module.exports = {
|
||||||
|
webpack: {
|
||||||
|
configure: (webpackConfig, { env }) => {
|
||||||
|
// Disable optimizations in both development and production
|
||||||
|
webpackConfig.optimization.minimize = false;
|
||||||
|
webpackConfig.optimization.minimizer = [];
|
||||||
|
webpackConfig.optimization.concatenateModules = false;
|
||||||
|
webpackConfig.optimization.splitChunks = { cacheGroups: { default: false } };
|
||||||
|
return webpackConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
39059
sasjs-tests/package-lock.json
generated
39059
sasjs-tests/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,21 +8,21 @@
|
|||||||
"@sasjs/test-framework": "1.5.7",
|
"@sasjs/test-framework": "1.5.7",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/node": "^14.14.41",
|
"@types/node": "^14.14.41",
|
||||||
"@types/react": "^17.0.1",
|
"@types/react": "^16.0.1",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^16.0.0",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"react": "^17.0.1",
|
"react": "^16.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^16.0.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "4.0.3",
|
||||||
"typescript": "^4.1.3"
|
"typescript": "^4.1.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "NODE_OPTIONS=--openssl-legacy-provider craco build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz --legacy-peer-deps",
|
"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": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
|
||||||
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
|
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
|
||||||
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
|
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"node-sass": "7.0.3"
|
"@craco/craco": "6.4.3",
|
||||||
|
"node-sass": "9.0.0",
|
||||||
|
"source-map-loader": "0.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then
|
|||||||
echo "Cypress sasjs testing passed!"
|
echo "Cypress sasjs testing passed!"
|
||||||
else
|
else
|
||||||
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
|
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
|
||||||
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io:4gl.io/send/m.room.message?access_token=$1
|
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=$1
|
||||||
echo "Cypress sasjs testing failed!"
|
echo "Cypress sasjs testing failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -24,6 +24,26 @@
|
|||||||
"streamServiceName": "adapter-tests",
|
"streamServiceName": "adapter-tests",
|
||||||
"assetPaths": []
|
"assetPaths": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "viya",
|
||||||
|
"serverUrl": "",
|
||||||
|
"serverType": "SASVIYA",
|
||||||
|
"httpsAgentOptions": {
|
||||||
|
"allowInsecureRequests": false
|
||||||
|
},
|
||||||
|
"appLoc": "/Public/app/adapter-tests",
|
||||||
|
"deployConfig": {
|
||||||
|
"deployServicePack": true,
|
||||||
|
"deployScripts": []
|
||||||
|
},
|
||||||
|
"streamConfig": {
|
||||||
|
"streamWeb": true,
|
||||||
|
"streamWebFolder": "webv",
|
||||||
|
"webSourcePath": "build",
|
||||||
|
"streamServiceName": "adapter-tests",
|
||||||
|
"assetPaths": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const App = (): ReactElement<{}> => {
|
|||||||
basicTests(adapter, config.userName, config.password),
|
basicTests(adapter, config.userName, config.password),
|
||||||
sendArrTests(adapter, appLoc),
|
sendArrTests(adapter, appLoc),
|
||||||
sendObjTests(adapter),
|
sendObjTests(adapter),
|
||||||
specialCaseTests(adapter),
|
// specialCaseTests(adapter),
|
||||||
sasjsRequestTests(adapter),
|
sasjsRequestTests(adapter),
|
||||||
fileUploadTests(adapter)
|
fileUploadTests(adapter)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ export const basicTests = (
|
|||||||
return response.table1[0][0] === stringData.table1[0].col1
|
return response.table1[0][0] === stringData.table1[0].col1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Web request',
|
||||||
|
description: 'Should run the request with old web approach',
|
||||||
|
test: async () => {
|
||||||
|
const config: Partial<SASjsConfig> = {
|
||||||
|
useComputeApi: false
|
||||||
|
}
|
||||||
|
|
||||||
|
return await adapter.request('common/sendArr', stringData, config)
|
||||||
|
},
|
||||||
|
assertion: (response: any) => {
|
||||||
|
return response.table1[0][0] === stringData.table1[0].col1
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Request with debug on',
|
title: 'Request with debug on',
|
||||||
description:
|
description:
|
||||||
@@ -159,20 +173,6 @@ export const basicTests = (
|
|||||||
sasjsConfig.debug === false
|
sasjsConfig.debug === false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Web request',
|
|
||||||
description: 'Should run the request with old web approach',
|
|
||||||
test: async () => {
|
|
||||||
const config: Partial<SASjsConfig> = {
|
|
||||||
useComputeApi: false
|
|
||||||
}
|
|
||||||
|
|
||||||
return await adapter.request('common/sendArr', stringData, config)
|
|
||||||
},
|
|
||||||
assertion: (response: any) => {
|
|
||||||
return response.table1[0][0] === stringData.table1[0].col1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,30 +20,30 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
|
|||||||
return requests[0].SASWORK === null
|
return requests[0].SASWORK === null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Make error and capture log',
|
|
||||||
description:
|
|
||||||
'Should make an error and capture log, in the same time it is testing if debug override is working',
|
|
||||||
test: async () => {
|
|
||||||
return adapter
|
|
||||||
.request('common/makeErr', data, { debug: true })
|
|
||||||
.catch(() => {
|
|
||||||
const sasRequests = adapter.getSasRequests()
|
|
||||||
const makeErrRequest: any =
|
|
||||||
sasRequests.find((req) => req.serviceLink.includes('makeErr')) ||
|
|
||||||
null
|
|
||||||
|
|
||||||
if (!makeErrRequest) return false
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
makeErrRequest.logFile && makeErrRequest.logFile.length > 0
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
assertion: (response) => {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// title: 'Make error and capture log',
|
||||||
|
// description:
|
||||||
|
// 'Should make an error and capture log, in the same time it is testing if debug override is working',
|
||||||
|
// test: async () => {
|
||||||
|
// return adapter
|
||||||
|
// .request('common/makeErr', data, { debug: true })
|
||||||
|
// .catch(() => {
|
||||||
|
// const sasRequests = adapter.getSasRequests()
|
||||||
|
// const makeErrRequest: any =
|
||||||
|
// sasRequests.find((req) => req.serviceLink.includes('makeErr')) ||
|
||||||
|
// null
|
||||||
|
|
||||||
|
// if (!makeErrRequest) return false
|
||||||
|
|
||||||
|
// return !!(
|
||||||
|
// makeErrRequest.logFile && makeErrRequest.logFile.length > 0
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
// },
|
||||||
|
// assertion: (response) => {
|
||||||
|
// return response
|
||||||
|
// }
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,6 +134,20 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
|
|||||||
return adapter.request('common/sendArr', moreSpecialCharData)
|
return adapter.request('common/sendArr', moreSpecialCharData)
|
||||||
},
|
},
|
||||||
assertion: (res: any) => {
|
assertion: (res: any) => {
|
||||||
|
// If sas session is `latin9` or `wlatin1` we can't process the special characters,
|
||||||
|
// But it can happen that response is broken JSON, so we first need to check if
|
||||||
|
// it's object and then check accordingly
|
||||||
|
|
||||||
|
if (typeof res === 'object') {
|
||||||
|
// Valid JSON response
|
||||||
|
if (res.SYSENCODING === 'latin9' || res.SYSENCODING === 'wlatin1')
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
// Since we got string response (broken JSON), we need to check with regex
|
||||||
|
const regex = /"SYSENCODING"\s*:\s*"(?:wlatin1|latin9)"/
|
||||||
|
if (regex.test(res)) return true
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
|
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
|
||||||
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&
|
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
|
"sourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { generateTimestamp } from '@sasjs/utils/time'
|
import { generateTimestamp } from '@sasjs/utils/time'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { Sas9RequestClient } from './request/Sas9RequestClient'
|
import { Sas9RequestClient } from './request/Sas9RequestClient'
|
||||||
import { isUrl } from './utils'
|
import { isUrl } from './utils'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isRelativePath, isUri, isUrl } from './utils'
|
import { isRelativePath, isUri, isUrl } from './utils'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import {
|
import {
|
||||||
Job,
|
Job,
|
||||||
Session,
|
Session,
|
||||||
@@ -25,9 +25,16 @@ import { prefixMessage } from '@sasjs/utils/error'
|
|||||||
import { pollJobState } from './api/viya/pollJobState'
|
import { pollJobState } from './api/viya/pollJobState'
|
||||||
import { getTokens } from './auth/getTokens'
|
import { getTokens } from './auth/getTokens'
|
||||||
import { uploadTables } from './api/viya/uploadTables'
|
import { uploadTables } from './api/viya/uploadTables'
|
||||||
import { executeScript } from './api/viya/executeScript'
|
import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
|
||||||
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
|
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
|
||||||
import { refreshTokensForViya } from './auth/refreshTokensForViya'
|
import { refreshTokensForViya } from './auth/refreshTokensForViya'
|
||||||
|
import { FileResource } from './types/FileResource'
|
||||||
|
|
||||||
|
interface JobExecutionResult {
|
||||||
|
result?: { result: object }
|
||||||
|
log?: string
|
||||||
|
error?: object
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A client for interfacing with the SAS Viya REST API.
|
* A client for interfacing with the SAS Viya REST API.
|
||||||
@@ -270,7 +277,7 @@ export class SASViyaApiClient {
|
|||||||
* @param debug - when set to true, the log will be returned.
|
* @param debug - when set to true, the log will be returned.
|
||||||
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
|
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
|
||||||
* @param waitForResult - when set to true, function will return the session
|
* @param waitForResult - when set to true, function will return the session
|
||||||
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
|
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
|
||||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||||
* @param variables - an object that represents macro variables.
|
* @param variables - an object that represents macro variables.
|
||||||
*/
|
*/
|
||||||
@@ -287,7 +294,7 @@ export class SASViyaApiClient {
|
|||||||
printPid = false,
|
printPid = false,
|
||||||
variables?: MacroVar
|
variables?: MacroVar
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return executeScript(
|
return executeOnComputeApi(
|
||||||
this.requestClient,
|
this.requestClient,
|
||||||
this.sessionManager,
|
this.sessionManager,
|
||||||
this.rootFolderName,
|
this.rootFolderName,
|
||||||
@@ -305,6 +312,84 @@ export class SASViyaApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async getFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
const fileUri = await this.getFileUri(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
accessToken
|
||||||
|
).catch((err) => {
|
||||||
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return await this.requestClient
|
||||||
|
.get<string>(`${this.serverUrl}${fileUri}/content`, accessToken)
|
||||||
|
.then((res) => res.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param content - the new content to be written to the file
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async updateFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
content: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
const fileUri = await this.getFileUri(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
accessToken
|
||||||
|
).catch((err) => {
|
||||||
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch the file resource details to get the Etag and content type
|
||||||
|
const { result: originalFileResource, etag } =
|
||||||
|
await this.requestClient.get<FileResource>(
|
||||||
|
`${this.serverUrl}${fileUri}`,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!originalFileResource || !etag)
|
||||||
|
throw new Error(
|
||||||
|
`File ${fileName} does not have an ETag, or request failed.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return await this.requestClient
|
||||||
|
.put<FileResource>(
|
||||||
|
`${this.serverUrl}${fileUri}/content`,
|
||||||
|
content,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
'If-Match': etag,
|
||||||
|
'Content-Type': originalFileResource.contentType
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.result)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a folder. Path to the folder is required.
|
* Fetches a folder. Path to the folder is required.
|
||||||
* @param folderPath - the absolute path to the folder.
|
* @param folderPath - the absolute path to the folder.
|
||||||
@@ -621,7 +706,7 @@ export class SASViyaApiClient {
|
|||||||
* @param accessToken - an optional access token for an authorized user.
|
* @param accessToken - an optional access token for an authorized user.
|
||||||
* @param waitForResult - a boolean indicating if the function should wait for a result.
|
* @param waitForResult - a boolean indicating if the function should wait for a result.
|
||||||
* @param expectWebout - a boolean indicating whether to expect a _webout response.
|
* @param expectWebout - a boolean indicating whether to expect a _webout response.
|
||||||
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
|
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
|
||||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||||
* @param variables - an object that represents macro variables.
|
* @param variables - an object that represents macro variables.
|
||||||
*/
|
*/
|
||||||
@@ -732,11 +817,13 @@ export class SASViyaApiClient {
|
|||||||
debug: boolean,
|
debug: boolean,
|
||||||
data?: any,
|
data?: any,
|
||||||
authConfig?: AuthConfig
|
authConfig?: AuthConfig
|
||||||
) {
|
): Promise<JobExecutionResult> {
|
||||||
let access_token = (authConfig || {}).access_token
|
let access_token = (authConfig || {}).access_token
|
||||||
|
|
||||||
if (authConfig) {
|
if (authConfig) {
|
||||||
;({ access_token } = await getTokens(this.requestClient, authConfig))
|
;({ access_token } = await getTokens(this.requestClient, authConfig))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRelativePath(sasJob) && !this.rootFolderName) {
|
if (isRelativePath(sasJob) && !this.rootFolderName) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Relative paths cannot be used without specifying a root folder name.'
|
'Relative paths cannot be used without specifying a root folder name.'
|
||||||
@@ -749,6 +836,7 @@ export class SASViyaApiClient {
|
|||||||
const fullFolderPath = isRelativePath(sasJob)
|
const fullFolderPath = isRelativePath(sasJob)
|
||||||
? `${this.rootFolderName}/${folderPath}`
|
? `${this.rootFolderName}/${folderPath}`
|
||||||
: folderPath
|
: folderPath
|
||||||
|
|
||||||
await this.populateFolderMap(fullFolderPath, access_token)
|
await this.populateFolderMap(fullFolderPath, access_token)
|
||||||
|
|
||||||
const jobFolder = this.folderMap.get(fullFolderPath)
|
const jobFolder = this.folderMap.get(fullFolderPath)
|
||||||
@@ -765,9 +853,8 @@ export class SASViyaApiClient {
|
|||||||
files = await this.uploadTables(data, access_token)
|
files = await this.uploadTables(data, access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!jobToExecute) {
|
if (!jobToExecute) throw new Error(`Job was not found.`)
|
||||||
throw new Error(`Job was not found.`)
|
|
||||||
}
|
|
||||||
const jobDefinitionLink = jobToExecute?.links.find(
|
const jobDefinitionLink = jobToExecute?.links.find(
|
||||||
(l) => l.rel === 'getResource'
|
(l) => l.rel === 'getResource'
|
||||||
)?.href
|
)?.href
|
||||||
@@ -783,14 +870,14 @@ export class SASViyaApiClient {
|
|||||||
_webin_file_count: files.length,
|
_webin_file_count: files.length,
|
||||||
_OMITJSONLISTING: true,
|
_OMITJSONLISTING: true,
|
||||||
_OMITJSONLOG: true,
|
_OMITJSONLOG: true,
|
||||||
_OMITSESSIONRESULTS: true,
|
_omitSessionResults: false,
|
||||||
_OMITTEXTLISTING: true,
|
_OMITTEXTLISTING: true,
|
||||||
_OMITTEXTLOG: true
|
_OMITTEXTLOG: true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
jobArguments['_OMITTEXTLOG'] = 'false'
|
jobArguments['_OMITTEXTLOG'] = 'false'
|
||||||
jobArguments['_OMITSESSIONRESULTS'] = 'false'
|
jobArguments['_omitSessionResults'] = 'false'
|
||||||
jobArguments['_DEBUG'] = 131
|
jobArguments['_DEBUG'] = 131
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,16 +894,19 @@ export class SASViyaApiClient {
|
|||||||
jobDefinition,
|
jobDefinition,
|
||||||
arguments: jobArguments
|
arguments: jobArguments
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result: postedJob } = await this.requestClient.post<Job>(
|
const { result: postedJob } = await this.requestClient.post<Job>(
|
||||||
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
|
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
|
||||||
postJobRequestBody,
|
postJobRequestBody,
|
||||||
access_token
|
access_token
|
||||||
)
|
)
|
||||||
|
|
||||||
const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
|
const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
|
||||||
(err) => {
|
(err) => {
|
||||||
throw prefixMessage(err, 'Error while polling job status. ')
|
throw prefixMessage(err, 'Error while polling job status. ')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const { result: currentJob } = await this.requestClient.get<Job>(
|
const { result: currentJob } = await this.requestClient.get<Job>(
|
||||||
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
|
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
|
||||||
access_token
|
access_token
|
||||||
@@ -827,6 +917,7 @@ export class SASViyaApiClient {
|
|||||||
|
|
||||||
const resultLink = currentJob.results['_webout.json']
|
const resultLink = currentJob.results['_webout.json']
|
||||||
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
||||||
|
|
||||||
if (resultLink) {
|
if (resultLink) {
|
||||||
jobResult = await this.requestClient.get<any>(
|
jobResult = await this.requestClient.get<any>(
|
||||||
`${this.serverUrl}${resultLink}/content`,
|
`${this.serverUrl}${resultLink}/content`,
|
||||||
@@ -834,11 +925,13 @@ export class SASViyaApiClient {
|
|||||||
'text/plain'
|
'text/plain'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug && logLink) {
|
if (debug && logLink) {
|
||||||
log = await this.requestClient
|
log = await this.requestClient
|
||||||
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
|
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
|
||||||
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
|
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jobStatus === 'failed') {
|
if (jobStatus === 'failed') {
|
||||||
throw new JobExecutionError(
|
throw new JobExecutionError(
|
||||||
currentJob.error?.errorCode,
|
currentJob.error?.errorCode,
|
||||||
@@ -846,7 +939,16 @@ export class SASViyaApiClient {
|
|||||||
log
|
log
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return { result: jobResult?.result, log }
|
|
||||||
|
const executionResult: JobExecutionResult = {
|
||||||
|
result: jobResult?.result,
|
||||||
|
log
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = currentJob
|
||||||
|
if (error) executionResult.error = error
|
||||||
|
|
||||||
|
return executionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
private async populateFolderMap(folderPath: string, accessToken?: string) {
|
private async populateFolderMap(folderPath: string, accessToken?: string) {
|
||||||
@@ -918,6 +1020,7 @@ export class SASViyaApiClient {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!folder) return undefined
|
if (!folder) return undefined
|
||||||
|
|
||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,6 +1032,30 @@ export class SASViyaApiClient {
|
|||||||
return `/folders/folders/${folderDetails.id}`
|
return `/folders/folders/${folderDetails.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFileUri(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
accessToken?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const folderMembers = await this.listFolder(folderPath, accessToken, 1000, {
|
||||||
|
returnDetails: true
|
||||||
|
}).catch((err) => {
|
||||||
|
throw prefixMessage(err, `Error while listing folder: ${folderPath}. `)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!folderMembers || !folderMembers.length)
|
||||||
|
throw new Error(`No members found in folder: ${folderPath}`)
|
||||||
|
|
||||||
|
const fileUri = folderMembers.find(
|
||||||
|
(member) => member.name === fileName
|
||||||
|
)?.uri
|
||||||
|
|
||||||
|
if (!fileUri)
|
||||||
|
throw new Error(`File ${fileName} not found in folder: ${folderPath}`)
|
||||||
|
|
||||||
|
return fileUri
|
||||||
|
}
|
||||||
|
|
||||||
private async getRecycleBinUri(accessToken?: string) {
|
private async getRecycleBinUri(accessToken?: string) {
|
||||||
const url = '/folders/folders/@myRecycleBin'
|
const url = '/folders/folders/@myRecycleBin'
|
||||||
|
|
||||||
@@ -976,14 +1103,19 @@ export class SASViyaApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists children folders for given Viya folder.
|
* Lists children folders/files for given Viya folder.
|
||||||
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
||||||
* @param accessToken - an access token for authorizing the request.
|
* @param accessToken - an access token for authorizing the request.
|
||||||
|
* @param {Object} [options] - Additional options.
|
||||||
|
* @param {boolean} [options.returnDetails=false] - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
|
||||||
*/
|
*/
|
||||||
public async listFolder(
|
public async listFolder(
|
||||||
sourceFolder: string,
|
sourceFolder: string,
|
||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
limit: number = 20
|
limit: number = 20,
|
||||||
|
options?: {
|
||||||
|
returnDetails?: boolean
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// checks if 'sourceFolder' is already a URI
|
// checks if 'sourceFolder' is already a URI
|
||||||
const sourceFolderUri = isUri(sourceFolder)
|
const sourceFolderUri = isUri(sourceFolder)
|
||||||
@@ -995,11 +1127,20 @@ export class SASViyaApiClient {
|
|||||||
accessToken
|
accessToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let membersToReturn = []
|
||||||
|
|
||||||
if (members && members.items) {
|
if (members && members.items) {
|
||||||
return members.items.map((item: any) => item.name)
|
// If returnDetails is true, return full member details
|
||||||
} else {
|
if (options?.returnDetails) {
|
||||||
return []
|
membersToReturn = members.items
|
||||||
|
} else {
|
||||||
|
// If returnDetails is false, return only member names
|
||||||
|
membersToReturn = members.items.map((item: any) => item.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return members without Etag
|
||||||
|
return membersToReturn
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
134
src/SASjs.ts
134
src/SASjs.ts
@@ -4,7 +4,13 @@ import {
|
|||||||
UploadFile,
|
UploadFile,
|
||||||
EditContextInput,
|
EditContextInput,
|
||||||
PollOptions,
|
PollOptions,
|
||||||
LoginMechanism
|
LoginMechanism,
|
||||||
|
VerboseMode,
|
||||||
|
ErrorResponse,
|
||||||
|
LoginOptions,
|
||||||
|
LoginResult,
|
||||||
|
ExecutionQuery,
|
||||||
|
Tables
|
||||||
} from './types'
|
} from './types'
|
||||||
import { SASViyaApiClient } from './SASViyaApiClient'
|
import { SASViyaApiClient } from './SASViyaApiClient'
|
||||||
import { SAS9ApiClient } from './SAS9ApiClient'
|
import { SAS9ApiClient } from './SAS9ApiClient'
|
||||||
@@ -29,8 +35,7 @@ import {
|
|||||||
Sas9JobExecutor,
|
Sas9JobExecutor,
|
||||||
FileUploader
|
FileUploader
|
||||||
} from './job-execution'
|
} from './job-execution'
|
||||||
import { ErrorResponse } from './types/errors'
|
import { AxiosResponse, AxiosError } from 'axios'
|
||||||
import { LoginOptions, LoginResult } from './types/Login'
|
|
||||||
|
|
||||||
interface ExecuteScriptParams {
|
interface ExecuteScriptParams {
|
||||||
linesOfCode: string[]
|
linesOfCode: string[]
|
||||||
@@ -157,6 +162,23 @@ export default class SASjs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes job on SASJS server.
|
||||||
|
* @param query - an object containing job path and debug level.
|
||||||
|
* @param appLoc - an application path.
|
||||||
|
* @param authConfig - an object for authentication.
|
||||||
|
* @returns a promise that resolves into job execution result and log.
|
||||||
|
*/
|
||||||
|
public async executeJob(
|
||||||
|
query: ExecutionQuery,
|
||||||
|
appLoc: string,
|
||||||
|
authConfig?: AuthConfig
|
||||||
|
) {
|
||||||
|
this.isMethodSupported('executeScript', [ServerType.Sasjs])
|
||||||
|
|
||||||
|
return await this.sasJSApiClient?.executeJob(query, appLoc, authConfig)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets compute contexts.
|
* Gets compute contexts.
|
||||||
* @param accessToken - an access token for an authorised user.
|
* @param accessToken - an access token for an authorised user.
|
||||||
@@ -390,6 +412,51 @@ export default class SASjs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async getFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
this.isMethodSupported('getFileContent', [ServerType.SasViya])
|
||||||
|
|
||||||
|
return await this.sasViyaApiClient!.getFileContent(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param content - the new content to be written to the file
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async updateFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
content: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
this.isMethodSupported('updateFileContent', [ServerType.SasViya])
|
||||||
|
|
||||||
|
return await this.sasViyaApiClient!.updateFileContent(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
content,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a folder from the SAS file system.
|
* Fetches a folder from the SAS file system.
|
||||||
* @param folderPath - path of the folder to be fetched.
|
* @param folderPath - path of the folder to be fetched.
|
||||||
@@ -415,18 +482,23 @@ export default class SASjs {
|
|||||||
* Lists children folders for given Viya folder.
|
* Lists children folders for given Viya folder.
|
||||||
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
||||||
* @param accessToken - an access token for authorizing the request.
|
* @param accessToken - an access token for authorizing the request.
|
||||||
|
* @param returnDetails - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
|
||||||
*/
|
*/
|
||||||
public async listFolder(
|
public async listFolder(
|
||||||
sourceFolder: string,
|
sourceFolder: string,
|
||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
limit?: number
|
limit?: number,
|
||||||
|
returnDetails = false
|
||||||
) {
|
) {
|
||||||
this.isMethodSupported('listFolder', [ServerType.SasViya])
|
this.isMethodSupported('listFolder', [ServerType.SasViya])
|
||||||
|
|
||||||
return await this.sasViyaApiClient?.listFolder(
|
return await this.sasViyaApiClient?.listFolder(
|
||||||
sourceFolder,
|
sourceFolder,
|
||||||
accessToken,
|
accessToken,
|
||||||
limit
|
limit,
|
||||||
|
{
|
||||||
|
returnDetails
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,9 +923,10 @@ export default class SASjs {
|
|||||||
* @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
|
* @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
|
||||||
* The access token is not required when the user is authenticated via the browser.
|
* The access token is not required when the user is authenticated via the browser.
|
||||||
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
|
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
|
||||||
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
|
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
|
||||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||||
* @param variables - an object that represents macro variables.
|
* @param variables - an object that represents macro variables.
|
||||||
|
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
|
||||||
*/
|
*/
|
||||||
public async startComputeJob(
|
public async startComputeJob(
|
||||||
sasJob: string,
|
sasJob: string,
|
||||||
@@ -863,7 +936,8 @@ export default class SASjs {
|
|||||||
waitForResult?: boolean,
|
waitForResult?: boolean,
|
||||||
pollOptions?: PollOptions,
|
pollOptions?: PollOptions,
|
||||||
printPid = false,
|
printPid = false,
|
||||||
variables?: MacroVar
|
variables?: MacroVar,
|
||||||
|
verboseMode?: VerboseMode
|
||||||
) {
|
) {
|
||||||
config = {
|
config = {
|
||||||
...this.sasjsConfig,
|
...this.sasjsConfig,
|
||||||
@@ -877,6 +951,11 @@ export default class SASjs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verboseMode) {
|
||||||
|
this.requestClient?.setVerboseMode(verboseMode)
|
||||||
|
this.requestClient?.enableVerboseMode()
|
||||||
|
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
|
||||||
|
|
||||||
return this.sasViyaApiClient?.executeComputeJob(
|
return this.sasViyaApiClient?.executeComputeJob(
|
||||||
sasJob,
|
sasJob,
|
||||||
config.contextName,
|
config.contextName,
|
||||||
@@ -970,7 +1049,8 @@ export default class SASjs {
|
|||||||
this.requestClient = new RequestClientClass(
|
this.requestClient = new RequestClientClass(
|
||||||
this.sasjsConfig.serverUrl,
|
this.sasjsConfig.serverUrl,
|
||||||
this.sasjsConfig.httpsAgentOptions,
|
this.sasjsConfig.httpsAgentOptions,
|
||||||
this.sasjsConfig.requestHistoryLimit
|
this.sasjsConfig.requestHistoryLimit,
|
||||||
|
this.sasjsConfig.verbose
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.requestClient.setConfig(
|
this.requestClient.setConfig(
|
||||||
@@ -1134,4 +1214,42 @@ export default class SASjs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables verbose mode that will log a summary of every HTTP response.
|
||||||
|
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
|
||||||
|
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
|
||||||
|
*/
|
||||||
|
public enableVerboseMode(
|
||||||
|
successCallBack?: (response: AxiosResponse) => AxiosResponse,
|
||||||
|
errorCallBack?: (response: AxiosError) => AxiosError
|
||||||
|
) {
|
||||||
|
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off verbose mode to log every HTTP response.
|
||||||
|
*/
|
||||||
|
public disableVerboseMode() {
|
||||||
|
this.requestClient?.disableVerboseMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets verbose mode.
|
||||||
|
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
|
||||||
|
*/
|
||||||
|
public setVerboseMode = (verboseMode: VerboseMode) => {
|
||||||
|
this.requestClient?.setVerboseMode(verboseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tables class containing one or more tables to be sent to
|
||||||
|
* SAS.
|
||||||
|
* @param table - initial table data
|
||||||
|
* @param macroName - macro name
|
||||||
|
* @returns Tables class
|
||||||
|
*/
|
||||||
|
Tables(table: Record<string, any>, macroName: string) {
|
||||||
|
return new Tables(table, macroName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
|
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { ExecutionQuery } from './types'
|
import { ExecutionQuery } from './types'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Session, Context, SessionVariable } from './types'
|
import { Session, Context, SessionVariable, SessionState } from './types'
|
||||||
import { NoSessionStateError } from './types/errors'
|
import { NoSessionStateError } from './types/errors'
|
||||||
import { asyncForEach, isUrl } from './utils'
|
import { asyncForEach, isUrl } from './utils'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
@@ -12,6 +12,7 @@ interface ApiErrorResponse {
|
|||||||
|
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private loggedErrors: NoSessionStateError[] = []
|
private loggedErrors: NoSessionStateError[] = []
|
||||||
|
private sessionStateLinkError = 'Error while getting session state link. '
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private serverUrl: string,
|
private serverUrl: string,
|
||||||
@@ -28,7 +29,7 @@ export class SessionManager {
|
|||||||
private _debug: boolean = false
|
private _debug: boolean = false
|
||||||
private printedSessionState = {
|
private printedSessionState = {
|
||||||
printed: false,
|
printed: false,
|
||||||
state: ''
|
state: SessionState.NoState
|
||||||
}
|
}
|
||||||
|
|
||||||
public get debug() {
|
public get debug() {
|
||||||
@@ -265,6 +266,18 @@ export class SessionManager {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add response etag to Session object.
|
||||||
|
createdSession.etag = etag
|
||||||
|
|
||||||
|
// Get session state link.
|
||||||
|
const stateLink = createdSession.links.find((link) => link.rel === 'state')
|
||||||
|
|
||||||
|
// Throw error if session state link is not present.
|
||||||
|
if (!stateLink) throw this.sessionStateLinkError
|
||||||
|
|
||||||
|
// Add session state link to Session object.
|
||||||
|
createdSession.stateUrl = stateLink.href
|
||||||
|
|
||||||
await this.waitForSession(createdSession, etag, accessToken)
|
await this.waitForSession(createdSession, etag, accessToken)
|
||||||
|
|
||||||
this.sessions.push(createdSession)
|
this.sessions.push(createdSession)
|
||||||
@@ -327,32 +340,30 @@ export class SessionManager {
|
|||||||
etag: string | null,
|
etag: string | null,
|
||||||
accessToken?: string
|
accessToken?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
let { state: sessionState } = session
|
||||||
|
const { stateUrl } = session
|
||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
|
|
||||||
let sessionState = session.state
|
|
||||||
|
|
||||||
const stateLink = session.links.find((l: any) => l.rel === 'state')
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sessionState === 'pending' ||
|
sessionState === SessionState.Pending ||
|
||||||
sessionState === 'running' ||
|
sessionState === SessionState.Running ||
|
||||||
sessionState === ''
|
sessionState === SessionState.NoState
|
||||||
) {
|
) {
|
||||||
if (stateLink) {
|
if (stateUrl) {
|
||||||
if (this.debug && !this.printedSessionState.printed) {
|
if (this.debug && !this.printedSessionState.printed) {
|
||||||
logger.info(`Polling: ${this.serverUrl + stateLink.href}`)
|
logger.info(`Polling: ${this.serverUrl + stateUrl}`)
|
||||||
|
|
||||||
this.printedSessionState.printed = true
|
this.printedSessionState.printed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${this.serverUrl}${stateLink.href}?wait=30`
|
const url = `${this.serverUrl}${stateUrl}?wait=30`
|
||||||
|
|
||||||
const { result: state, responseStatus: responseStatus } =
|
const { result: state, responseStatus: responseStatus } =
|
||||||
await this.getSessionState(url, etag!, accessToken).catch((err) => {
|
await this.getSessionState(url, etag!, accessToken).catch((err) => {
|
||||||
throw prefixMessage(err, 'Error while waiting for session. ')
|
throw prefixMessage(err, 'Error while waiting for session. ')
|
||||||
})
|
})
|
||||||
|
|
||||||
sessionState = state.trim()
|
sessionState = state.trim() as SessionState
|
||||||
|
|
||||||
if (this.debug && this.printedSessionState.state !== sessionState) {
|
if (this.debug && this.printedSessionState.state !== sessionState) {
|
||||||
logger.info(`Current session state is '${sessionState}'`)
|
logger.info(`Current session state is '${sessionState}'`)
|
||||||
@@ -364,7 +375,7 @@ export class SessionManager {
|
|||||||
if (!sessionState) {
|
if (!sessionState) {
|
||||||
const stateError = new NoSessionStateError(
|
const stateError = new NoSessionStateError(
|
||||||
responseStatus,
|
responseStatus,
|
||||||
this.serverUrl + stateLink.href,
|
this.serverUrl + stateUrl,
|
||||||
session.links.find((l: any) => l.rel === 'log')?.href as string
|
session.links.find((l: any) => l.rel === 'log')?.href as string
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -386,7 +397,7 @@ export class SessionManager {
|
|||||||
|
|
||||||
return sessionState
|
return sessionState
|
||||||
} else {
|
} else {
|
||||||
throw 'Error while getting session state link. '
|
throw this.sessionStateLinkError
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.loggedErrors = []
|
this.loggedErrors = []
|
||||||
@@ -413,7 +424,7 @@ export class SessionManager {
|
|||||||
return await this.requestClient
|
return await this.requestClient
|
||||||
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
|
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
|
||||||
.then((res) => ({
|
.then((res) => ({
|
||||||
result: res.result as string,
|
result: res.result as SessionState,
|
||||||
responseStatus: res.status
|
responseStatus: res.status
|
||||||
}))
|
}))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ import { RequestClient } from '../../request/RequestClient'
|
|||||||
import { SessionManager } from '../../SessionManager'
|
import { SessionManager } from '../../SessionManager'
|
||||||
import { isRelativePath, fetchLogByChunks } from '../../utils'
|
import { isRelativePath, fetchLogByChunks } from '../../utils'
|
||||||
import { formatDataForRequest } from '../../utils/formatDataForRequest'
|
import { formatDataForRequest } from '../../utils/formatDataForRequest'
|
||||||
import { pollJobState } from './pollJobState'
|
import { pollJobState, JobState } from './pollJobState'
|
||||||
import { uploadTables } from './uploadTables'
|
import { uploadTables } from './uploadTables'
|
||||||
|
|
||||||
|
interface JobRequestBody {
|
||||||
|
[key: string]: number | string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes code on the current SAS Viya server.
|
* Executes SAS program on the current SAS Viya server using Compute API.
|
||||||
* @param jobPath - the path to the file being submitted for execution.
|
* @param jobPath - the path to the file being submitted for execution.
|
||||||
* @param linesOfCode - an array of code lines to execute.
|
* @param linesOfCode - an array of code lines to execute.
|
||||||
* @param contextName - the context to execute the code in.
|
* @param contextName - the context to execute the code in.
|
||||||
@@ -25,11 +29,11 @@ import { uploadTables } from './uploadTables'
|
|||||||
* @param debug - when set to true, the log will be returned.
|
* @param debug - when set to true, the log will be returned.
|
||||||
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
|
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
|
||||||
* @param waitForResult - when set to true, function will return the session
|
* @param waitForResult - when set to true, function will return the session
|
||||||
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
|
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
|
||||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||||
* @param variables - an object that represents macro variables.
|
* @param variables - an object that represents macro variables.
|
||||||
*/
|
*/
|
||||||
export async function executeScript(
|
export async function executeOnComputeApi(
|
||||||
requestClient: RequestClient,
|
requestClient: RequestClient,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
rootFolderName: string,
|
rootFolderName: string,
|
||||||
@@ -46,6 +50,7 @@ export async function executeScript(
|
|||||||
variables?: MacroVar
|
variables?: MacroVar
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
let access_token = (authConfig || {}).access_token
|
let access_token = (authConfig || {}).access_token
|
||||||
|
|
||||||
if (authConfig) {
|
if (authConfig) {
|
||||||
;({ access_token } = await getTokens(requestClient, authConfig))
|
;({ access_token } = await getTokens(requestClient, authConfig))
|
||||||
}
|
}
|
||||||
@@ -78,27 +83,13 @@ export async function executeScript(
|
|||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Triggered '${relativeJobPath}' with PID ${
|
`Triggering '${relativeJobPath}' with PID ${
|
||||||
jobIdVariable.value
|
jobIdVariable.value
|
||||||
} at ${timestampToYYYYMMDDHHMMSS()}`
|
} 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
|
let fileName
|
||||||
|
|
||||||
if (isRelativePath(jobPath)) {
|
if (isRelativePath(jobPath)) {
|
||||||
@@ -107,6 +98,7 @@ export async function executeScript(
|
|||||||
}`
|
}`
|
||||||
} else {
|
} else {
|
||||||
const jobPathParts = jobPath.split('/')
|
const jobPathParts = jobPath.split('/')
|
||||||
|
|
||||||
fileName = jobPathParts.pop()
|
fileName = jobPathParts.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +110,6 @@ export async function executeScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (variables) jobVariables = { ...jobVariables, ...variables }
|
if (variables) jobVariables = { ...jobVariables, ...variables }
|
||||||
|
|
||||||
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
|
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
|
||||||
|
|
||||||
let files: any[] = []
|
let files: any[] = []
|
||||||
@@ -145,12 +136,12 @@ export async function executeScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute job in session
|
// Execute job in session
|
||||||
const jobRequestBody = {
|
const jobRequestBody: JobRequestBody = {
|
||||||
name: fileName,
|
name: fileName || 'Default Job Name',
|
||||||
description: 'Powered by SASjs',
|
description: 'Powered by SASjs',
|
||||||
code: linesOfCode,
|
code: linesOfCode,
|
||||||
variables: jobVariables,
|
variables: jobVariables,
|
||||||
arguments: jobArguments
|
version: 2
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result: postedJob, etag } = await requestClient
|
const { result: postedJob, etag } = await requestClient
|
||||||
@@ -179,16 +170,21 @@ export async function executeScript(
|
|||||||
postedJob,
|
postedJob,
|
||||||
debug,
|
debug,
|
||||||
authConfig,
|
authConfig,
|
||||||
pollOptions
|
pollOptions,
|
||||||
|
{
|
||||||
|
session,
|
||||||
|
sessionManager
|
||||||
|
}
|
||||||
).catch(async (err) => {
|
).catch(async (err) => {
|
||||||
const error = err?.response?.data
|
const error = err?.response?.data
|
||||||
const result = /err=[0-9]*,/.exec(error)
|
const result = /err=[0-9]*,/.exec(error)
|
||||||
|
|
||||||
const errorCode = '5113'
|
const errorCode = '5113'
|
||||||
|
|
||||||
if (result?.[0]?.slice(4, -1) === errorCode) {
|
if (result?.[0]?.slice(4, -1) === errorCode) {
|
||||||
|
const logCount = 1000000
|
||||||
const sessionLogUrl =
|
const sessionLogUrl =
|
||||||
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
||||||
const logCount = 1000000
|
|
||||||
err.log = await fetchLogByChunks(
|
err.log = await fetchLogByChunks(
|
||||||
requestClient,
|
requestClient,
|
||||||
access_token!,
|
access_token!,
|
||||||
@@ -196,6 +192,7 @@ export async function executeScript(
|
|||||||
logCount
|
logCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw prefixMessage(err, 'Error while polling job status. ')
|
throw prefixMessage(err, 'Error while polling job status. ')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,12 +211,12 @@ export async function executeScript(
|
|||||||
|
|
||||||
let jobResult
|
let jobResult
|
||||||
let log = ''
|
let log = ''
|
||||||
|
|
||||||
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
||||||
|
|
||||||
if (debug && logLink) {
|
if (debug && logLink) {
|
||||||
const logUrl = `${logLink.href}/content`
|
const logUrl = `${logLink.href}/content`
|
||||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||||
|
|
||||||
log = await fetchLogByChunks(
|
log = await fetchLogByChunks(
|
||||||
requestClient,
|
requestClient,
|
||||||
access_token!,
|
access_token!,
|
||||||
@@ -228,13 +225,11 @@ export async function executeScript(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jobStatus === 'failed' || jobStatus === 'error') {
|
if (jobStatus === JobState.Failed || jobStatus === JobState.Error) {
|
||||||
throw new ComputeJobExecutionError(currentJob, log)
|
throw new ComputeJobExecutionError(currentJob, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!expectWebout) {
|
if (!expectWebout) return { job: currentJob, log }
|
||||||
return { job: currentJob, log }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
||||||
|
|
||||||
@@ -245,6 +240,7 @@ export async function executeScript(
|
|||||||
if (logLink) {
|
if (logLink) {
|
||||||
const logUrl = `${logLink.href}/content`
|
const logUrl = `${logLink.href}/content`
|
||||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||||
|
|
||||||
log = await fetchLogByChunks(
|
log = await fetchLogByChunks(
|
||||||
requestClient,
|
requestClient,
|
||||||
access_token!,
|
access_token!,
|
||||||
@@ -279,7 +275,7 @@ export async function executeScript(
|
|||||||
const error = e as HttpError
|
const error = e as HttpError
|
||||||
|
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
return executeScript(
|
return executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
rootFolderName,
|
rootFolderName,
|
||||||
@@ -1,29 +1,90 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils/types'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import { Job, PollOptions } from '../..'
|
import { Job, PollOptions, PollStrategy } from '../..'
|
||||||
import { getTokens } from '../../auth/getTokens'
|
import { getTokens } from '../../auth/getTokens'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { JobStatePollError } from '../../types/errors'
|
import { JobStatePollError } from '../../types/errors'
|
||||||
import { Link, WriteStream } from '../../types'
|
import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
|
||||||
import { delay, isNode } from '../../utils'
|
import { delay, isNode } from '../../utils'
|
||||||
|
|
||||||
|
export enum JobState {
|
||||||
|
Completed = 'completed',
|
||||||
|
Running = 'running',
|
||||||
|
Pending = 'pending',
|
||||||
|
Unavailable = 'unavailable',
|
||||||
|
NoState = '',
|
||||||
|
Failed = 'failed',
|
||||||
|
Error = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls job status using default or provided poll options.
|
||||||
|
* @param requestClient - the pre-configured HTTP request client.
|
||||||
|
* @param postedJob - the relative or absolute path to the job.
|
||||||
|
* @param debug - sets the _debug flag in the job arguments.
|
||||||
|
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
|
||||||
|
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath. It will override the first default poll options in poll strategy if provided.
|
||||||
|
* Example pollOptions:
|
||||||
|
* {
|
||||||
|
* maxPollCount: 200,
|
||||||
|
* pollInterval: 300,
|
||||||
|
* streamLog: true, // optional, equals to false by default.
|
||||||
|
* pollStrategy?: // optional array of poll options that should be applied after 'maxPollCount' of the provided poll options is reached. If not provided the default (see example below) poll strategy will be used.
|
||||||
|
* }
|
||||||
|
* Example pollStrategy (values used from default poll strategy):
|
||||||
|
* [
|
||||||
|
* { maxPollCount: 200, pollInterval: 300 }, // approximately ~2 mins (including time to get response (~300ms))
|
||||||
|
* { maxPollCount: 300, pollInterval: 3000 }, // approximately ~5.5 mins (including time to get response (~300ms))
|
||||||
|
* { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms))
|
||||||
|
* { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms))
|
||||||
|
* ]
|
||||||
|
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state.
|
||||||
|
* @returns - a promise which resolves with a job state
|
||||||
|
*/
|
||||||
export async function pollJobState(
|
export async function pollJobState(
|
||||||
requestClient: RequestClient,
|
requestClient: RequestClient,
|
||||||
postedJob: Job,
|
postedJob: Job,
|
||||||
debug: boolean,
|
debug: boolean,
|
||||||
authConfig?: AuthConfig,
|
authConfig?: AuthConfig,
|
||||||
pollOptions?: PollOptions
|
pollOptions?: PollOptions,
|
||||||
) {
|
jobSessionManager?: JobSessionManager
|
||||||
|
): Promise<JobState> {
|
||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
|
|
||||||
let pollInterval = 300
|
const streamLog = pollOptions?.streamLog || false
|
||||||
let maxPollCount = 1000
|
|
||||||
|
|
||||||
const defaultPollOptions: PollOptions = {
|
const defaultPollStrategy: PollStrategy = [
|
||||||
maxPollCount,
|
{ maxPollCount: 200, pollInterval: 300 },
|
||||||
pollInterval,
|
{ maxPollCount: 300, pollInterval: 3000 },
|
||||||
streamLog: false
|
{ maxPollCount: 500, pollInterval: 30000 },
|
||||||
|
{ maxPollCount: 3400, pollInterval: 60000 }
|
||||||
|
]
|
||||||
|
|
||||||
|
let pollStrategy: PollStrategy
|
||||||
|
|
||||||
|
if (pollOptions !== undefined) {
|
||||||
|
pollStrategy = [pollOptions]
|
||||||
|
|
||||||
|
let { pollStrategy: providedPollStrategy } = pollOptions
|
||||||
|
|
||||||
|
if (providedPollStrategy !== undefined) {
|
||||||
|
validatePollStrategies(providedPollStrategy)
|
||||||
|
|
||||||
|
// INFO: sort by 'maxPollCount'
|
||||||
|
providedPollStrategy = providedPollStrategy.sort(
|
||||||
|
(strategyA: PollOptions, strategyB: PollOptions) =>
|
||||||
|
strategyA.maxPollCount - strategyB.maxPollCount
|
||||||
|
)
|
||||||
|
|
||||||
|
pollStrategy = [...pollStrategy, ...providedPollStrategy]
|
||||||
|
} else {
|
||||||
|
pollStrategy = [...pollStrategy, ...defaultPollStrategy]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pollStrategy = defaultPollStrategy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let defaultPollOptions: PollOptions = pollStrategy.splice(0, 1)[0]
|
||||||
|
|
||||||
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
|
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
|
||||||
|
|
||||||
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
|
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
|
||||||
@@ -31,10 +92,10 @@ export async function pollJobState(
|
|||||||
throw new Error(`Job state link was not found.`)
|
throw new Error(`Job state link was not found.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentState = await getJobState(
|
let currentState: JobState = await getJobState(
|
||||||
requestClient,
|
requestClient,
|
||||||
postedJob,
|
postedJob,
|
||||||
'',
|
JobState.NoState,
|
||||||
debug,
|
debug,
|
||||||
authConfig
|
authConfig
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
@@ -42,73 +103,73 @@ export async function pollJobState(
|
|||||||
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
|
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
|
||||||
err
|
err
|
||||||
)
|
)
|
||||||
return 'unavailable'
|
|
||||||
|
return JobState.Unavailable
|
||||||
})
|
})
|
||||||
|
|
||||||
let pollCount = 0
|
let pollCount = 0
|
||||||
|
|
||||||
if (currentState === 'completed') {
|
if (currentState === JobState.Completed) {
|
||||||
return Promise.resolve(currentState)
|
return Promise.resolve(currentState)
|
||||||
}
|
}
|
||||||
|
|
||||||
let logFileStream
|
let logFileStream
|
||||||
if (pollOptions.streamLog && isNode()) {
|
if (streamLog && isNode()) {
|
||||||
const { getFileStream } = require('./getFileStream')
|
const { getFileStream } = require('./getFileStream')
|
||||||
|
|
||||||
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
|
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll up to the first 100 times with the specified poll interval
|
|
||||||
let result = await doPoll(
|
let result = await doPoll(
|
||||||
requestClient,
|
requestClient,
|
||||||
postedJob,
|
postedJob,
|
||||||
currentState,
|
currentState,
|
||||||
debug,
|
debug,
|
||||||
pollCount,
|
pollCount,
|
||||||
|
pollOptions,
|
||||||
authConfig,
|
authConfig,
|
||||||
{
|
streamLog,
|
||||||
...pollOptions,
|
logFileStream,
|
||||||
maxPollCount:
|
jobSessionManager
|
||||||
pollOptions.maxPollCount <= 100 ? pollOptions.maxPollCount : 100
|
|
||||||
},
|
|
||||||
logFileStream
|
|
||||||
)
|
)
|
||||||
|
|
||||||
currentState = result.state
|
currentState = result.state
|
||||||
pollCount = result.pollCount
|
pollCount = result.pollCount
|
||||||
|
|
||||||
if (!needsRetry(currentState) || pollCount >= pollOptions.maxPollCount) {
|
if (
|
||||||
|
!needsRetry(currentState) ||
|
||||||
|
(pollCount >= pollOptions.maxPollCount && !pollStrategy.length)
|
||||||
|
) {
|
||||||
return currentState
|
return currentState
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get to this point, this is a long-running job that needs longer polling.
|
// INFO: 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
|
// We will resume polling with a bigger interval according to the next polling strategy
|
||||||
let longJobPollOptions: PollOptions = {
|
while (pollStrategy.length && needsRetry(currentState)) {
|
||||||
maxPollCount: 24 * 60,
|
defaultPollOptions = pollStrategy.splice(0, 1)[0]
|
||||||
pollInterval: 60000,
|
|
||||||
streamLog: false
|
if (pollOptions) {
|
||||||
}
|
defaultPollOptions.logFolderPath = pollOptions.logFolderPath
|
||||||
if (pollOptions) {
|
}
|
||||||
longJobPollOptions.streamLog = pollOptions.streamLog
|
|
||||||
longJobPollOptions.logFolderPath = pollOptions.logFolderPath
|
result = await doPoll(
|
||||||
|
requestClient,
|
||||||
|
postedJob,
|
||||||
|
currentState,
|
||||||
|
debug,
|
||||||
|
pollCount,
|
||||||
|
defaultPollOptions,
|
||||||
|
authConfig,
|
||||||
|
streamLog,
|
||||||
|
logFileStream,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
|
||||||
|
currentState = result.state
|
||||||
|
pollCount = result.pollCount
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await doPoll(
|
if (logFileStream) logFileStream.end()
|
||||||
requestClient,
|
|
||||||
postedJob,
|
|
||||||
currentState,
|
|
||||||
debug,
|
|
||||||
pollCount,
|
|
||||||
authConfig,
|
|
||||||
longJobPollOptions,
|
|
||||||
logFileStream
|
|
||||||
)
|
|
||||||
|
|
||||||
currentState = result.state
|
|
||||||
pollCount = result.pollCount
|
|
||||||
|
|
||||||
if (logFileStream) {
|
|
||||||
logFileStream.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentState
|
return currentState
|
||||||
}
|
}
|
||||||
@@ -119,17 +180,13 @@ const getJobState = async (
|
|||||||
currentState: string,
|
currentState: string,
|
||||||
debug: boolean,
|
debug: boolean,
|
||||||
authConfig?: AuthConfig
|
authConfig?: AuthConfig
|
||||||
) => {
|
): Promise<JobState> => {
|
||||||
const stateLink = job.links.find((l: any) => l.rel === 'state')
|
const stateLink = job.links.find((l: any) => l.rel === 'state')!
|
||||||
if (!stateLink) {
|
|
||||||
throw new Error(`Job state link was not found.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsRetry(currentState)) {
|
if (needsRetry(currentState)) {
|
||||||
let tokens
|
let tokens
|
||||||
if (authConfig) {
|
|
||||||
tokens = await getTokens(requestClient, authConfig)
|
if (authConfig) tokens = await getTokens(requestClient, authConfig)
|
||||||
}
|
|
||||||
|
|
||||||
const { result: jobState } = await requestClient
|
const { result: jobState } = await requestClient
|
||||||
.get<string>(
|
.get<string>(
|
||||||
@@ -143,49 +200,88 @@ const getJobState = async (
|
|||||||
throw new JobStatePollError(job.id, err)
|
throw new JobStatePollError(job.id, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
return jobState.trim()
|
return jobState.trim() as JobState
|
||||||
} else {
|
} else {
|
||||||
return currentState
|
return currentState as JobState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsRetry = (state: string) =>
|
const needsRetry = (state: string) =>
|
||||||
state === 'running' ||
|
state === JobState.Running ||
|
||||||
state === '' ||
|
state === JobState.NoState ||
|
||||||
state === 'pending' ||
|
state === JobState.Pending ||
|
||||||
state === 'unavailable'
|
state === JobState.Unavailable
|
||||||
|
|
||||||
const doPoll = async (
|
/**
|
||||||
|
* Polls job state.
|
||||||
|
* @param requestClient - the pre-configured HTTP request client.
|
||||||
|
* @param postedJob - the relative or absolute path to the job.
|
||||||
|
* @param currentState - current job state.
|
||||||
|
* @param debug - sets the _debug flag in the job arguments.
|
||||||
|
* @param pollCount - current poll count.
|
||||||
|
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath.
|
||||||
|
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
|
||||||
|
* @param streamLog - indicates if job log should be streamed.
|
||||||
|
* @param logStream - job log stream.
|
||||||
|
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state. Session state is considered healthy if it is equal to 'running' or 'idle'.
|
||||||
|
* @returns - a promise which resolves with a job state
|
||||||
|
*/
|
||||||
|
export const doPoll = async (
|
||||||
requestClient: RequestClient,
|
requestClient: RequestClient,
|
||||||
postedJob: Job,
|
postedJob: Job,
|
||||||
currentState: string,
|
currentState: JobState,
|
||||||
debug: boolean,
|
debug: boolean,
|
||||||
pollCount: number,
|
pollCount: number,
|
||||||
|
pollOptions: PollOptions,
|
||||||
authConfig?: AuthConfig,
|
authConfig?: AuthConfig,
|
||||||
pollOptions?: PollOptions,
|
streamLog?: boolean,
|
||||||
logStream?: WriteStream
|
logStream?: WriteStream,
|
||||||
): Promise<{ state: string; pollCount: number }> => {
|
jobSessionManager?: JobSessionManager
|
||||||
let pollInterval = 300
|
): Promise<{ state: JobState; pollCount: number }> => {
|
||||||
let maxPollCount = 1000
|
const { maxPollCount, pollInterval } = pollOptions
|
||||||
|
const logger = process.logger || console
|
||||||
|
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')!
|
||||||
let maxErrorCount = 5
|
let maxErrorCount = 5
|
||||||
let errorCount = 0
|
let errorCount = 0
|
||||||
let state = currentState
|
let state = currentState
|
||||||
let printedState = ''
|
let printedState = JobState.NoState
|
||||||
let startLogLine = 0
|
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) {
|
while (needsRetry(state) && pollCount <= maxPollCount) {
|
||||||
|
// Check parent session state on every 10th job state poll.
|
||||||
|
if (jobSessionManager && pollCount && pollCount % 10 === 0 && authConfig) {
|
||||||
|
const { session, sessionManager } = jobSessionManager
|
||||||
|
const { stateUrl, etag, id: sessionId } = session
|
||||||
|
const { access_token } = authConfig
|
||||||
|
const { id: jobId } = postedJob
|
||||||
|
|
||||||
|
// Get session state.
|
||||||
|
const { result: sessionState, responseStatus } = await sessionManager[
|
||||||
|
'getSessionState'
|
||||||
|
](stateUrl, etag, access_token).catch((err) => {
|
||||||
|
// Handle error while getting session state.
|
||||||
|
throw new JobStatePollError(jobId, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Checks if session state is equal to 'running' or 'idle'.
|
||||||
|
const isSessionStatesHealthy = (state: string) =>
|
||||||
|
[SessionState.Running, SessionState.Idle].includes(
|
||||||
|
state as SessionState
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear parent session and throw an error if session state is not
|
||||||
|
// 'running', 'idle' or response status is not 200.
|
||||||
|
if (!isSessionStatesHealthy(sessionState) || responseStatus !== 200) {
|
||||||
|
sessionManager.clearSession(sessionId, access_token)
|
||||||
|
|
||||||
|
const sessionError = isSessionStatesHealthy(sessionState)
|
||||||
|
? `Session response status is not 200. Session response status is ${responseStatus}.`
|
||||||
|
: `Session state of the job is not 'running' or 'idle'. Session state is '${sessionState}'`
|
||||||
|
|
||||||
|
throw new JobStatePollError(jobId, new Error(sessionError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state = await getJobState(
|
state = await getJobState(
|
||||||
requestClient,
|
requestClient,
|
||||||
postedJob,
|
postedJob,
|
||||||
@@ -194,21 +290,24 @@ const doPoll = async (
|
|||||||
authConfig
|
authConfig
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
errorCount++
|
errorCount++
|
||||||
|
|
||||||
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
|
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
|
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
|
||||||
err
|
err
|
||||||
)
|
)
|
||||||
return 'unavailable'
|
|
||||||
|
return JobState.Unavailable
|
||||||
})
|
})
|
||||||
|
|
||||||
pollCount++
|
pollCount++
|
||||||
|
|
||||||
const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href
|
const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href
|
||||||
|
|
||||||
if (pollOptions?.streamLog) {
|
if (streamLog) {
|
||||||
const { result: job } = await requestClient.get<Job>(
|
const { result: job } = await requestClient.get<Job>(
|
||||||
jobHref,
|
jobHref,
|
||||||
authConfig?.access_token
|
authConfig?.access_token
|
||||||
@@ -238,12 +337,45 @@ const doPoll = async (
|
|||||||
printedState = state
|
printedState = state
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state != 'unavailable' && errorCount > 0) {
|
if (state !== JobState.Unavailable && errorCount > 0) {
|
||||||
errorCount = 0
|
errorCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
await delay(pollInterval)
|
if (state !== JobState.Completed) {
|
||||||
|
await delay(pollInterval)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state, pollCount }
|
return { state, pollCount }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validatePollStrategies = (strategy: PollStrategy) => {
|
||||||
|
const throwError = (message?: string, pollOptions?: PollOptions) => {
|
||||||
|
throw new Error(
|
||||||
|
`Poll strategies are not valid.${message ? ` ${message}` : ''}${
|
||||||
|
pollOptions
|
||||||
|
? ` Invalid poll strategy: \n${JSON.stringify(pollOptions, null, 2)}`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy.forEach((pollOptions: PollOptions, i: number) => {
|
||||||
|
const { maxPollCount, pollInterval } = pollOptions
|
||||||
|
|
||||||
|
if (maxPollCount < 1) {
|
||||||
|
throwError(`'maxPollCount' has to be greater than 0.`, pollOptions)
|
||||||
|
} else if (i !== 0) {
|
||||||
|
const previousPollOptions = strategy[i - 1]
|
||||||
|
|
||||||
|
if (maxPollCount <= previousPollOptions.maxPollCount) {
|
||||||
|
throwError(
|
||||||
|
`'maxPollCount' has to be greater than 'maxPollCount' in previous poll strategy.`,
|
||||||
|
pollOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (pollInterval < 1) {
|
||||||
|
throwError(`'pollInterval' has to be greater than 0.`, pollOptions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { RequestClient } from '../../../request/RequestClient'
|
import { RequestClient } from '../../../request/RequestClient'
|
||||||
import { SessionManager } from '../../../SessionManager'
|
import { SessionManager } from '../../../SessionManager'
|
||||||
import { executeScript } from '../executeScript'
|
import { executeOnComputeApi } from '../executeOnComputeApi'
|
||||||
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
|
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
|
||||||
import * as pollJobStateModule from '../pollJobState'
|
import * as pollJobStateModule from '../pollJobState'
|
||||||
import * as uploadTablesModule from '../uploadTables'
|
import * as uploadTablesModule from '../uploadTables'
|
||||||
import * as getTokensModule from '../../../auth/getTokens'
|
import * as getTokensModule from '../../../auth/getTokens'
|
||||||
import * as formatDataModule from '../../../utils/formatDataForRequest'
|
import * as formatDataModule from '../../../utils/formatDataForRequest'
|
||||||
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
||||||
import { PollOptions } from '../../../types'
|
import { PollOptions, JobSessionManager } from '../../../types'
|
||||||
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
|
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
|
||||||
import { Logger, LogLevel } from '@sasjs/utils'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
|
|
||||||
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
|
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
|
||||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||||
const defaultPollOptions: PollOptions = {
|
const defaultPollOptions: PollOptions = {
|
||||||
maxPollCount: 100,
|
maxPollCount: 100,
|
||||||
pollInterval: 500,
|
pollInterval: 500
|
||||||
streamLog: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('executeScript', () => {
|
describe('executeScript', () => {
|
||||||
@@ -26,7 +25,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
|
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -39,7 +38,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should try to get fresh tokens if an authConfig is provided', async () => {
|
it('should try to get fresh tokens if an authConfig is provided', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -56,7 +55,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should get a session from the session manager before executing', async () => {
|
it('should get a session from the session manager before executing', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -73,7 +72,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(sessionManager, 'getSession')
|
.spyOn(sessionManager, 'getSession')
|
||||||
.mockImplementation(() => Promise.reject('Test Error'))
|
.mockImplementation(() => Promise.reject('Test Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -86,7 +85,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch the PID when printPid is true', async () => {
|
it('should fetch the PID when printPid is true', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -114,7 +113,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(sessionManager, 'getVariable')
|
.spyOn(sessionManager, 'getVariable')
|
||||||
.mockImplementation(() => Promise.reject('Test Error'))
|
.mockImplementation(() => Promise.reject('Test Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -140,7 +139,7 @@ describe('executeScript', () => {
|
|||||||
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
|
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
|
||||||
)
|
)
|
||||||
|
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -164,7 +163,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should format data as CSV when it does not contain semicolons', async () => {
|
it('should format data as CSV when it does not contain semicolons', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -190,7 +189,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||||
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
||||||
|
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -218,14 +217,7 @@ describe('executeScript', () => {
|
|||||||
sasjs_tables: 'foo',
|
sasjs_tables: 'foo',
|
||||||
sasjs0data: 'bar'
|
sasjs0data: 'bar'
|
||||||
},
|
},
|
||||||
arguments: {
|
version: 2
|
||||||
_contextName: 'test context',
|
|
||||||
_OMITJSONLISTING: true,
|
|
||||||
_OMITJSONLOG: true,
|
|
||||||
_OMITSESSIONRESULTS: true,
|
|
||||||
_OMITTEXTLISTING: true,
|
|
||||||
_OMITTEXTLOG: true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mockAuthConfig.access_token
|
mockAuthConfig.access_token
|
||||||
)
|
)
|
||||||
@@ -236,7 +228,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||||
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
||||||
|
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -265,14 +257,7 @@ describe('executeScript', () => {
|
|||||||
sasjs0data: 'bar',
|
sasjs0data: 'bar',
|
||||||
_DEBUG: 131
|
_DEBUG: 131
|
||||||
},
|
},
|
||||||
arguments: {
|
version: 2
|
||||||
_contextName: 'test context',
|
|
||||||
_OMITJSONLISTING: true,
|
|
||||||
_OMITJSONLOG: true,
|
|
||||||
_OMITSESSIONRESULTS: false,
|
|
||||||
_OMITTEXTLISTING: true,
|
|
||||||
_OMITTEXTLOG: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mockAuthConfig.access_token
|
mockAuthConfig.access_token
|
||||||
)
|
)
|
||||||
@@ -283,7 +268,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(requestClient, 'post')
|
.spyOn(requestClient, 'post')
|
||||||
.mockImplementation(() => Promise.reject('Test Error'))
|
.mockImplementation(() => Promise.reject('Test Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -303,7 +288,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should immediately return the session when waitForResult is false', async () => {
|
it('should immediately return the session when waitForResult is false', async () => {
|
||||||
const result = await executeScript(
|
const result = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -323,7 +308,12 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should poll for job completion when waitForResult is true', async () => {
|
it('should poll for job completion when waitForResult is true', async () => {
|
||||||
await executeScript(
|
const jobSessionManager: JobSessionManager = {
|
||||||
|
session: mockSession,
|
||||||
|
sessionManager: sessionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -344,7 +334,8 @@ describe('executeScript', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
mockAuthConfig,
|
mockAuthConfig,
|
||||||
defaultPollOptions
|
defaultPollOptions,
|
||||||
|
jobSessionManager
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -353,7 +344,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(pollJobStateModule, 'pollJobState')
|
.spyOn(pollJobStateModule, 'pollJobState')
|
||||||
.mockImplementation(() => Promise.reject('Poll Error'))
|
.mockImplementation(() => Promise.reject('Poll Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -379,7 +370,7 @@ describe('executeScript', () => {
|
|||||||
Promise.reject({ response: { data: 'err=5113,' } })
|
Promise.reject({ response: { data: 'err=5113,' } })
|
||||||
)
|
)
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -405,7 +396,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
|
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -430,7 +421,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not fetch the logs for the job if debug is false', async () => {
|
it('should not fetch the logs for the job if debug is false', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -452,9 +443,11 @@ describe('executeScript', () => {
|
|||||||
it('should throw a ComputeJobExecutionError if the job has failed', async () => {
|
it('should throw a ComputeJobExecutionError if the job has failed', async () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(pollJobStateModule, 'pollJobState')
|
.spyOn(pollJobStateModule, 'pollJobState')
|
||||||
.mockImplementation(() => Promise.resolve('failed'))
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve(pollJobStateModule.JobState.Failed)
|
||||||
|
)
|
||||||
|
|
||||||
const error: ComputeJobExecutionError = await executeScript(
|
const error: ComputeJobExecutionError = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -485,9 +478,11 @@ describe('executeScript', () => {
|
|||||||
it('should throw a ComputeJobExecutionError if the job has errored out', async () => {
|
it('should throw a ComputeJobExecutionError if the job has errored out', async () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(pollJobStateModule, 'pollJobState')
|
.spyOn(pollJobStateModule, 'pollJobState')
|
||||||
.mockImplementation(() => Promise.resolve('error'))
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve(pollJobStateModule.JobState.Error)
|
||||||
|
)
|
||||||
|
|
||||||
const error: ComputeJobExecutionError = await executeScript(
|
const error: ComputeJobExecutionError = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -516,7 +511,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch the result if expectWebout is true', async () => {
|
it('should fetch the result if expectWebout is true', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -547,7 +542,7 @@ describe('executeScript', () => {
|
|||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -581,7 +576,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should clear the session after execution is complete', async () => {
|
it('should clear the session after execution is complete', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -608,7 +603,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(sessionManager, 'clearSession')
|
.spyOn(sessionManager, 'clearSession')
|
||||||
.mockImplementation(() => Promise.reject('Clear Session Error'))
|
.mockImplementation(() => Promise.reject('Clear Session Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -654,7 +649,9 @@ const setupMocks = () => {
|
|||||||
.mockImplementation(() => Promise.resolve(mockAuthConfig))
|
.mockImplementation(() => Promise.resolve(mockAuthConfig))
|
||||||
jest
|
jest
|
||||||
.spyOn(pollJobStateModule, 'pollJobState')
|
.spyOn(pollJobStateModule, 'pollJobState')
|
||||||
.mockImplementation(() => Promise.resolve('completed'))
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve(pollJobStateModule.JobState.Completed)
|
||||||
|
)
|
||||||
jest
|
jest
|
||||||
.spyOn(sessionManager, 'getVariable')
|
.spyOn(sessionManager, 'getVariable')
|
||||||
.mockImplementation(() =>
|
.mockImplementation(() =>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils/types'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import { Job, Session } from '../../../types'
|
import { Job, Session, SessionState } from '../../../types'
|
||||||
|
|
||||||
export const mockSession: Session = {
|
export const mockSession: Session = {
|
||||||
id: 's35510n',
|
id: 's35510n',
|
||||||
state: 'idle',
|
state: SessionState.Idle,
|
||||||
|
stateUrl: '',
|
||||||
links: [],
|
links: [],
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: 1
|
sessionInactiveTimeout: 1
|
||||||
},
|
},
|
||||||
creationTimeStamp: new Date().valueOf().toString()
|
creationTimeStamp: new Date().valueOf().toString(),
|
||||||
|
etag: 'etag-string'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockJob: Job = {
|
export const mockJob: Job = {
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { Logger, LogLevel } from '@sasjs/utils'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
import { RequestClient } from '../../../request/RequestClient'
|
import { RequestClient } from '../../../request/RequestClient'
|
||||||
import { mockAuthConfig, mockJob } from './mockResponses'
|
import { mockAuthConfig, mockJob } from './mockResponses'
|
||||||
import { pollJobState } from '../pollJobState'
|
import { pollJobState, doPoll, JobState } from '../pollJobState'
|
||||||
import * as getTokensModule from '../../../auth/getTokens'
|
import * as getTokensModule from '../../../auth/getTokens'
|
||||||
import * as saveLogModule from '../saveLog'
|
import * as saveLogModule from '../saveLog'
|
||||||
import * as getFileStreamModule from '../getFileStream'
|
import * as getFileStreamModule from '../getFileStream'
|
||||||
import * as isNodeModule from '../../../utils/isNode'
|
import * as isNodeModule from '../../../utils/isNode'
|
||||||
import { PollOptions } from '../../../types'
|
import * as delayModule from '../../../utils/delay'
|
||||||
|
import {
|
||||||
|
PollOptions,
|
||||||
|
PollStrategy,
|
||||||
|
SessionState,
|
||||||
|
JobSessionManager
|
||||||
|
} from '../../../types'
|
||||||
import { WriteStream } from 'fs'
|
import { WriteStream } from 'fs'
|
||||||
|
import { SessionManager } from '../../../SessionManager'
|
||||||
|
import { JobStatePollError } from '../../../types'
|
||||||
|
|
||||||
const baseUrl = 'http://localhost'
|
const baseUrl = 'http://localhost'
|
||||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||||
|
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
|
||||||
requestClient['httpClient'].defaults.baseURL = baseUrl
|
requestClient['httpClient'].defaults.baseURL = baseUrl
|
||||||
|
|
||||||
const defaultPollOptions: PollOptions = {
|
const defaultStreamLog = false
|
||||||
|
const defaultPollStrategy: PollOptions = {
|
||||||
maxPollCount: 100,
|
maxPollCount: 100,
|
||||||
pollInterval: 500,
|
pollInterval: 500
|
||||||
streamLog: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('pollJobState', () => {
|
describe('pollJobState', () => {
|
||||||
@@ -26,13 +35,10 @@ describe('pollJobState', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should get valid tokens if the authConfig has been provided', async () => {
|
it('should get valid tokens if the authConfig has been provided', async () => {
|
||||||
await pollJobState(
|
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||||
requestClient,
|
...defaultPollStrategy,
|
||||||
mockJob,
|
streamLog: defaultStreamLog
|
||||||
false,
|
})
|
||||||
mockAuthConfig,
|
|
||||||
defaultPollOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
|
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
|
||||||
requestClient,
|
requestClient,
|
||||||
@@ -46,7 +52,7 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
|
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
|
||||||
@@ -58,7 +64,7 @@ describe('pollJobState', () => {
|
|||||||
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
|
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
).catch((e: any) => e)
|
).catch((e: any) => e)
|
||||||
|
|
||||||
expect((error as Error).message).toContain('Job state link was not found.')
|
expect((error as Error).message).toContain('Job state link was not found.')
|
||||||
@@ -72,7 +78,7 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
mockAuthConfig,
|
mockAuthConfig,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
|
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
|
||||||
@@ -83,7 +89,7 @@ describe('pollJobState', () => {
|
|||||||
const { saveLog } = require('../saveLog')
|
const { saveLog } = require('../saveLog')
|
||||||
|
|
||||||
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||||
...defaultPollOptions,
|
...defaultPollStrategy,
|
||||||
streamLog: true
|
streamLog: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,7 +102,7 @@ describe('pollJobState', () => {
|
|||||||
const { saveLog } = require('../saveLog')
|
const { saveLog } = require('../saveLog')
|
||||||
|
|
||||||
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||||
...defaultPollOptions,
|
...defaultPollStrategy,
|
||||||
streamLog: true
|
streamLog: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -111,7 +117,7 @@ describe('pollJobState', () => {
|
|||||||
const { getFileStream } = require('../getFileStream')
|
const { getFileStream } = require('../getFileStream')
|
||||||
|
|
||||||
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||||
...defaultPollOptions,
|
...defaultPollStrategy,
|
||||||
streamLog: true
|
streamLog: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,7 +133,7 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
mockAuthConfig,
|
mockAuthConfig,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(saveLogModule.saveLog).not.toHaveBeenCalled()
|
expect(saveLogModule.saveLog).not.toHaveBeenCalled()
|
||||||
@@ -136,15 +142,18 @@ describe('pollJobState', () => {
|
|||||||
it('should return the current status when the max poll count is reached', async () => {
|
it('should return the current status when the max poll count is reached', async () => {
|
||||||
mockRunningPoll()
|
mockRunningPoll()
|
||||||
|
|
||||||
|
const pollOptions: PollOptions = {
|
||||||
|
...defaultPollStrategy,
|
||||||
|
maxPollCount: 1,
|
||||||
|
pollStrategy: []
|
||||||
|
}
|
||||||
|
|
||||||
const state = await pollJobState(
|
const state = await pollJobState(
|
||||||
requestClient,
|
requestClient,
|
||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
mockAuthConfig,
|
mockAuthConfig,
|
||||||
{
|
pollOptions
|
||||||
...defaultPollOptions,
|
|
||||||
maxPollCount: 1
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(state).toEqual('running')
|
expect(state).toEqual('running')
|
||||||
@@ -159,7 +168,7 @@ describe('pollJobState', () => {
|
|||||||
false,
|
false,
|
||||||
mockAuthConfig,
|
mockAuthConfig,
|
||||||
{
|
{
|
||||||
...defaultPollOptions,
|
...defaultPollStrategy,
|
||||||
maxPollCount: 200,
|
maxPollCount: 200,
|
||||||
pollInterval: 10
|
pollInterval: 10
|
||||||
}
|
}
|
||||||
@@ -176,7 +185,7 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(requestClient.get).toHaveBeenCalledTimes(2)
|
expect(requestClient.get).toHaveBeenCalledTimes(2)
|
||||||
@@ -192,7 +201,7 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
true,
|
true,
|
||||||
undefined,
|
undefined,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect((process as any).logger.info).toHaveBeenCalledTimes(4)
|
expect((process as any).logger.info).toHaveBeenCalledTimes(4)
|
||||||
@@ -222,7 +231,7 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(requestClient.get).toHaveBeenCalledTimes(2)
|
expect(requestClient.get).toHaveBeenCalledTimes(2)
|
||||||
@@ -237,13 +246,401 @@ describe('pollJobState', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
defaultPollOptions
|
defaultPollStrategy
|
||||||
).catch((e: any) => e)
|
).catch((e: any) => e)
|
||||||
|
|
||||||
expect(error.message).toEqual(
|
expect(error.message).toEqual(
|
||||||
'Error while polling job state for job j0b: Status Error'
|
'Error while polling job state for job j0b: Status Error'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should change poll strategies', async () => {
|
||||||
|
mockSimplePoll(6)
|
||||||
|
|
||||||
|
const delays: number[] = []
|
||||||
|
|
||||||
|
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
|
||||||
|
delays.push(ms)
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
const pollIntervals = [3, 4, 5, 6]
|
||||||
|
|
||||||
|
const pollStrategy = [
|
||||||
|
{ maxPollCount: 2, pollInterval: pollIntervals[1] },
|
||||||
|
{ maxPollCount: 3, pollInterval: pollIntervals[2] },
|
||||||
|
{ maxPollCount: 4, pollInterval: pollIntervals[3] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const pollOptions: PollOptions = {
|
||||||
|
maxPollCount: 1,
|
||||||
|
pollInterval: pollIntervals[0],
|
||||||
|
pollStrategy: pollStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
await pollJobState(requestClient, mockJob, false, undefined, pollOptions)
|
||||||
|
|
||||||
|
expect(delays).toEqual([pollIntervals[0], ...pollIntervals])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should change default poll strategies after completing provided poll options', async () => {
|
||||||
|
const delays: number[] = []
|
||||||
|
|
||||||
|
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
|
||||||
|
delays.push(ms)
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
const customPollOptions: PollOptions = {
|
||||||
|
maxPollCount: 0,
|
||||||
|
pollInterval: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
{ maxPollCount: 202, pollInterval: 300 },
|
||||||
|
{ maxPollCount: 300, pollInterval: 3000 },
|
||||||
|
{ maxPollCount: 500, pollInterval: 30000 },
|
||||||
|
{ maxPollCount: 3400, pollInterval: 60000 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// ~200 requests with delay 300ms
|
||||||
|
let request = requests.splice(0, 1)[0]
|
||||||
|
let { maxPollCount, pollInterval } = request
|
||||||
|
|
||||||
|
// should be only one interval because maxPollCount is equal to 0
|
||||||
|
const pollIntervals = [customPollOptions.pollInterval]
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(maxPollCount - 2).fill(pollInterval))
|
||||||
|
|
||||||
|
// ~300 requests with delay 3000
|
||||||
|
request = requests.splice(0, 1)[0]
|
||||||
|
let newAmount = request.maxPollCount
|
||||||
|
pollInterval = request.pollInterval
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(newAmount - maxPollCount).fill(pollInterval))
|
||||||
|
pollIntervals.push(...Array(2).fill(pollInterval))
|
||||||
|
|
||||||
|
// ~500 requests with delay 30000
|
||||||
|
request = requests.splice(0, 1)[0]
|
||||||
|
|
||||||
|
let oldAmount = newAmount
|
||||||
|
newAmount = request.maxPollCount
|
||||||
|
pollInterval = request.pollInterval
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
|
||||||
|
pollIntervals.push(...Array(2).fill(pollInterval))
|
||||||
|
|
||||||
|
// ~3400 requests with delay 60000
|
||||||
|
request = requests.splice(0, 1)[0]
|
||||||
|
|
||||||
|
oldAmount = newAmount
|
||||||
|
newAmount = request.maxPollCount
|
||||||
|
pollInterval = request.pollInterval
|
||||||
|
|
||||||
|
mockSimplePoll(newAmount)
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
|
||||||
|
|
||||||
|
await pollJobState(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
customPollOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(delays).toEqual(pollIntervals)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if not valid poll strategies provided', async () => {
|
||||||
|
// INFO: 'maxPollCount' has to be > 0
|
||||||
|
let invalidPollStrategy = {
|
||||||
|
maxPollCount: 0,
|
||||||
|
pollInterval: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollStrategy: PollStrategy = [invalidPollStrategy]
|
||||||
|
|
||||||
|
let expectedError = new Error(
|
||||||
|
`Poll strategies are not valid. 'maxPollCount' has to be greater than 0. Invalid poll strategy: \n${JSON.stringify(
|
||||||
|
invalidPollStrategy,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pollJobState(requestClient, mockJob, false, undefined, {
|
||||||
|
...defaultPollStrategy,
|
||||||
|
pollStrategy: pollStrategy
|
||||||
|
})
|
||||||
|
).rejects.toThrow(expectedError)
|
||||||
|
|
||||||
|
// INFO: 'maxPollCount' has to be > than 'maxPollCount' of the previous strategy
|
||||||
|
const validPollStrategy = {
|
||||||
|
maxPollCount: 5,
|
||||||
|
pollInterval: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidPollStrategy = {
|
||||||
|
maxPollCount: validPollStrategy.maxPollCount,
|
||||||
|
pollInterval: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
pollStrategy = [validPollStrategy, invalidPollStrategy]
|
||||||
|
|
||||||
|
expectedError = new Error(
|
||||||
|
`Poll strategies are not valid. 'maxPollCount' has to be greater than 'maxPollCount' in previous poll strategy. Invalid poll strategy: \n${JSON.stringify(
|
||||||
|
invalidPollStrategy,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pollJobState(requestClient, mockJob, false, undefined, {
|
||||||
|
...defaultPollStrategy,
|
||||||
|
pollStrategy: pollStrategy
|
||||||
|
})
|
||||||
|
).rejects.toThrow(expectedError)
|
||||||
|
|
||||||
|
// INFO: invalid 'pollInterval'
|
||||||
|
invalidPollStrategy = {
|
||||||
|
maxPollCount: 1,
|
||||||
|
pollInterval: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pollStrategy = [invalidPollStrategy]
|
||||||
|
|
||||||
|
expectedError = new Error(
|
||||||
|
`Poll strategies are not valid. 'pollInterval' has to be greater than 0. Invalid poll strategy: \n${JSON.stringify(
|
||||||
|
invalidPollStrategy,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pollJobState(requestClient, mockJob, false, undefined, {
|
||||||
|
...defaultPollStrategy,
|
||||||
|
pollStrategy: pollStrategy
|
||||||
|
})
|
||||||
|
).rejects.toThrow(expectedError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('doPoll', () => {
|
||||||
|
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
|
||||||
|
const jobSessionManager: JobSessionManager = {
|
||||||
|
sessionManager,
|
||||||
|
session: {
|
||||||
|
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
||||||
|
state: SessionState.NoState,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: sessionStateLink,
|
||||||
|
method: 'GET',
|
||||||
|
rel: 'state',
|
||||||
|
type: 'text/plain',
|
||||||
|
uri: sessionStateLink
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: {
|
||||||
|
sessionInactiveTimeout: 900
|
||||||
|
},
|
||||||
|
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
|
||||||
|
stateUrl: '',
|
||||||
|
etag: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check session state on every 10th job state poll', async () => {
|
||||||
|
const mockedGetSessionState = jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: SessionState.Idle,
|
||||||
|
responseStatus: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let getSessionStateCount = 0
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
getSessionStateCount++
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
result:
|
||||||
|
getSessionStateCount < 20 ? JobState.Running : JobState.Completed,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedGetSessionState).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle error while checking session state', async () => {
|
||||||
|
const sessionStateError = 'Error while getting session state.'
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.reject(sessionStateError)
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: JobState.Running,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
).rejects.toEqual(
|
||||||
|
new JobStatePollError(mockJob.id, new Error(sessionStateError))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if session state is not healthy', async () => {
|
||||||
|
const filteredSessionStates = Object.values(SessionState).filter(
|
||||||
|
(state) => state !== SessionState.Running && state !== SessionState.Idle
|
||||||
|
)
|
||||||
|
const randomSessionState =
|
||||||
|
filteredSessionStates[
|
||||||
|
Math.floor(Math.random() * filteredSessionStates.length)
|
||||||
|
]
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: randomSessionState,
|
||||||
|
responseStatus: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: JobState.Running,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockedClearSession = jest
|
||||||
|
.spyOn(sessionManager, 'clearSession')
|
||||||
|
.mockImplementation(() => Promise.resolve())
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
).rejects.toEqual(
|
||||||
|
new JobStatePollError(
|
||||||
|
mockJob.id,
|
||||||
|
new Error(
|
||||||
|
`Session state of the job is not 'running' or 'idle'. Session state is '${randomSessionState}'`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedClearSession).toHaveBeenCalledWith(
|
||||||
|
jobSessionManager.session.id,
|
||||||
|
mockAuthConfig.access_token
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle throw an error if response status of session state is not 200', async () => {
|
||||||
|
const sessionStateResponseStatus = 500
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: SessionState.Running,
|
||||||
|
responseStatus: sessionStateResponseStatus
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: JobState.Running,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockedClearSession = jest
|
||||||
|
.spyOn(sessionManager, 'clearSession')
|
||||||
|
.mockImplementation(() => Promise.resolve())
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
).rejects.toEqual(
|
||||||
|
new JobStatePollError(
|
||||||
|
mockJob.id,
|
||||||
|
new Error(
|
||||||
|
`Session response status is not 200. Session response status is ${sessionStateResponseStatus}.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedClearSession).toHaveBeenCalledWith(
|
||||||
|
jobSessionManager.session.id,
|
||||||
|
mockAuthConfig.access_token
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const setupMocks = () => {
|
const setupMocks = () => {
|
||||||
@@ -273,11 +670,14 @@ const setupMocks = () => {
|
|||||||
|
|
||||||
const mockSimplePoll = (runningCount = 2) => {
|
const mockSimplePoll = (runningCount = 2) => {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||||
count++
|
count++
|
||||||
|
|
||||||
if (url.includes('job')) {
|
if (url.includes('job')) {
|
||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
result:
|
result:
|
||||||
count === 0
|
count === 0
|
||||||
@@ -293,11 +693,14 @@ const mockSimplePoll = (runningCount = 2) => {
|
|||||||
|
|
||||||
const mockRunningPoll = () => {
|
const mockRunningPoll = () => {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||||
count++
|
count++
|
||||||
|
|
||||||
if (url.includes('job')) {
|
if (url.includes('job')) {
|
||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
result: count === 0 ? 'pending' : 'running',
|
result: count === 0 ? 'pending' : 'running',
|
||||||
etag: '',
|
etag: '',
|
||||||
@@ -308,11 +711,14 @@ const mockRunningPoll = () => {
|
|||||||
|
|
||||||
const mockLongPoll = () => {
|
const mockLongPoll = () => {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||||
count++
|
count++
|
||||||
|
|
||||||
if (url.includes('job')) {
|
if (url.includes('job')) {
|
||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
result: count <= 102 ? 'running' : 'completed',
|
result: count <= 102 ? 'running' : 'completed',
|
||||||
etag: '',
|
etag: '',
|
||||||
@@ -323,14 +729,18 @@ const mockLongPoll = () => {
|
|||||||
|
|
||||||
const mockPollWithSingleError = () => {
|
const mockPollWithSingleError = () => {
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|
||||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||||
count++
|
count++
|
||||||
|
|
||||||
if (url.includes('job')) {
|
if (url.includes('job')) {
|
||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
return Promise.reject('Status Error')
|
return Promise.reject('Status Error')
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
result: count === 0 ? 'pending' : 'completed',
|
result: count === 0 ? 'pending' : 'completed',
|
||||||
etag: '',
|
etag: '',
|
||||||
@@ -344,6 +754,7 @@ const mockErroredPoll = () => {
|
|||||||
if (url.includes('job')) {
|
if (url.includes('job')) {
|
||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject('Status Error')
|
return Promise.reject('Status Error')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Logger, LogLevel } from '@sasjs/utils'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
import { RequestClient } from '../../../request/RequestClient'
|
import { RequestClient } from '../../../request/RequestClient'
|
||||||
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
||||||
import * as writeStreamModule from '../writeStream'
|
import * as writeStreamModule from '../writeStream'
|
||||||
@@ -69,5 +69,5 @@ const setupMocks = () => {
|
|||||||
.mockImplementation(() => Promise.resolve('Test Log'))
|
.mockImplementation(() => Promise.resolve('Test Log'))
|
||||||
jest
|
jest
|
||||||
.spyOn(writeStreamModule, 'writeStream')
|
.spyOn(writeStreamModule, 'writeStream')
|
||||||
.mockImplementation(() => Promise.resolve())
|
.mockImplementation(() => Promise.resolve(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,27 @@ import {
|
|||||||
fileExists,
|
fileExists,
|
||||||
readFile,
|
readFile,
|
||||||
deleteFile
|
deleteFile
|
||||||
} from '@sasjs/utils'
|
} from '@sasjs/utils/file'
|
||||||
|
|
||||||
describe('writeStream', () => {
|
describe('writeStream', () => {
|
||||||
const filename = 'test.txt'
|
const filename = 'test.txt'
|
||||||
const content = 'test'
|
const content = 'test'
|
||||||
|
|
||||||
let stream: WriteStream
|
let stream: WriteStream
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
stream = await createWriteStream(filename)
|
stream = await createWriteStream(filename)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await deleteFile(filename).catch(() => {}) // Ignore errors if the file doesn't exist
|
||||||
|
stream = await createWriteStream(filename)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deleteFile(filename).catch(() => {}) // Ensure cleanup after test
|
||||||
|
})
|
||||||
|
|
||||||
it('should resolve when the stream is written successfully', async () => {
|
it('should resolve when the stream is written successfully', async () => {
|
||||||
await expect(writeStream(stream, content)).toResolve()
|
await expect(writeStream(stream, content)).toResolve()
|
||||||
await expect(fileExists(filename)).resolves.toEqual(true)
|
await expect(fileExists(filename)).resolves.toEqual(true)
|
||||||
@@ -25,11 +35,30 @@ describe('writeStream', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should reject when the write errors out', async () => {
|
it('should reject when the write errors out', async () => {
|
||||||
|
// Mock implementation of the write method
|
||||||
jest
|
jest
|
||||||
.spyOn(stream, 'write')
|
.spyOn(stream, 'write')
|
||||||
.mockImplementation((_, callback) => callback(new Error('Test Error')))
|
.mockImplementation(
|
||||||
|
(
|
||||||
|
chunk: any,
|
||||||
|
encodingOrCb?:
|
||||||
|
| BufferEncoding
|
||||||
|
| ((error: Error | null | undefined) => void),
|
||||||
|
cb?: (error: Error | null | undefined) => void
|
||||||
|
) => {
|
||||||
|
const callback =
|
||||||
|
typeof encodingOrCb === 'function' ? encodingOrCb : cb
|
||||||
|
if (callback) {
|
||||||
|
callback(new Error('Test Error')) // Simulate an error
|
||||||
|
}
|
||||||
|
return true // Simulate that the write operation was called
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call the writeStream function and catch the error
|
||||||
const error = await writeStream(stream, content).catch((e: any) => e)
|
const error = await writeStream(stream, content).catch((e: any) => e)
|
||||||
|
|
||||||
|
// Assert that the error is correctly handled
|
||||||
expect(error.message).toEqual('Test Error')
|
expect(error.message).toEqual('Test Error')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import { WriteStream } from '../../types'
|
|||||||
export const writeStream = async (
|
export const writeStream = async (
|
||||||
stream: WriteStream,
|
stream: WriteStream,
|
||||||
content: string
|
content: string
|
||||||
): Promise<void> =>
|
): Promise<boolean> => {
|
||||||
stream.write(content + '\n', (e: any) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (e) return Promise.reject(e)
|
stream.write(content + '\n', (err: Error | null | undefined) => {
|
||||||
|
if (err) {
|
||||||
return Promise.resolve()
|
reject(err) // Reject on write error
|
||||||
|
} else {
|
||||||
|
resolve(true) // Resolve on successful write
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
|
|||||||
import { openWebPage } from './openWebPage'
|
import { openWebPage } from './openWebPage'
|
||||||
import { verifySas9Login } from './verifySas9Login'
|
import { verifySas9Login } from './verifySas9Login'
|
||||||
import { verifySasViyaLogin } from './verifySasViyaLogin'
|
import { verifySasViyaLogin } from './verifySasViyaLogin'
|
||||||
|
import { isLogInSuccessHeaderPresent } from './'
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
public userName = ''
|
public userName = ''
|
||||||
@@ -14,6 +15,7 @@ export class AuthManager {
|
|||||||
private loginUrl: string
|
private loginUrl: string
|
||||||
private logoutUrl: string
|
private logoutUrl: string
|
||||||
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
|
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private serverUrl: string,
|
private serverUrl: string,
|
||||||
private serverType: ServerType,
|
private serverType: ServerType,
|
||||||
@@ -27,6 +29,8 @@ export class AuthManager {
|
|||||||
: this.serverType === ServerType.SasViya
|
: this.serverType === ServerType.SasViya
|
||||||
? '/SASLogon/logout.do?'
|
? '/SASLogon/logout.do?'
|
||||||
: '/SASLogon/logout'
|
: '/SASLogon/logout'
|
||||||
|
|
||||||
|
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,7 +133,7 @@ export class AuthManager {
|
|||||||
|
|
||||||
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
|
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
|
||||||
|
|
||||||
let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
|
let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse)
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
if (isCredentialsVerifyError(loginResponse)) {
|
if (isCredentialsVerifyError(loginResponse)) {
|
||||||
@@ -214,7 +218,7 @@ export class AuthManager {
|
|||||||
* - a boolean `isLoggedIn`
|
* - a boolean `isLoggedIn`
|
||||||
* - a string `userName`,
|
* - a string `userName`,
|
||||||
* - a string `userFullName` and
|
* - a string `userFullName` and
|
||||||
* - a form `loginForm` if not loggedin.
|
* - a form `loginForm` if not loggedIn.
|
||||||
*/
|
*/
|
||||||
public async checkSession(): Promise<LoginResultInternal> {
|
public async checkSession(): Promise<LoginResultInternal> {
|
||||||
const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
|
const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
|
||||||
@@ -381,9 +385,3 @@ const isCredentialsVerifyError = (response: string): boolean =>
|
|||||||
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
|
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
|
||||||
response
|
response
|
||||||
)
|
)
|
||||||
|
|
||||||
const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
|
|
||||||
if (serverType === ServerType.Sasjs) return response?.loggedin
|
|
||||||
|
|
||||||
return /You have signed in/gm.test(response)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
|
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges the auth code for an access token for the given client.
|
* Exchanges the auth code for an access token for the given client.
|
||||||
@@ -31,6 +33,16 @@ export async function getAccessTokenForSasjs(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
throw prefixMessage(err, 'Error while getting access token. ')
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
getTokenRequestErrorPrefix(
|
||||||
|
'fetching access token',
|
||||||
|
'getAccessTokenForSasjs',
|
||||||
|
ServerType.Sasjs,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
import { CertificateError } from '../types/errors'
|
import { CertificateError } from '../types/errors'
|
||||||
|
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges the auth code for an access token for the given client.
|
* Exchange the auth code for access / refresh tokens for the given client / secret pair.
|
||||||
* @param requestClient - the pre-configured HTTP request client
|
* @param requestClient - the pre-configured HTTP request client.
|
||||||
* @param clientId - the client ID to authenticate with.
|
* @param clientId - the client ID to authenticate with.
|
||||||
* @param clientSecret - the client secret to authenticate with.
|
* @param clientSecret - the client secret to authenticate with.
|
||||||
* @param authCode - the auth code received from the server.
|
* @param authCode - the auth code received from the server.
|
||||||
@@ -16,29 +17,44 @@ export async function getAccessTokenForViya(
|
|||||||
clientSecret: string,
|
clientSecret: string,
|
||||||
authCode: string
|
authCode: string
|
||||||
): Promise<SasAuthResponse> {
|
): Promise<SasAuthResponse> {
|
||||||
const url = '/SASLogon/oauth/token'
|
|
||||||
let token
|
let token
|
||||||
|
|
||||||
if (typeof Buffer === 'undefined') {
|
if (typeof Buffer === 'undefined') {
|
||||||
token = btoa(clientId + ':' + clientSecret)
|
token = btoa(clientId + ':' + clientSecret)
|
||||||
} else {
|
} else {
|
||||||
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = '/SASLogon/oauth/token'
|
||||||
const headers = {
|
const headers = {
|
||||||
Authorization: 'Basic ' + token,
|
Authorization: 'Basic ' + token,
|
||||||
Accept: 'application/json'
|
Accept: 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = new URLSearchParams({
|
const dataJson = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code: authCode
|
code: authCode
|
||||||
})
|
})
|
||||||
|
const data = new URLSearchParams(dataJson)
|
||||||
|
|
||||||
const authResponse = await requestClient
|
const authResponse = await requestClient
|
||||||
.post(url, data, undefined, 'application/x-www-form-urlencoded', headers)
|
.post(url, data, undefined, 'application/x-www-form-urlencoded', headers)
|
||||||
.then((res) => res.result as SasAuthResponse)
|
.then((res) => res.result as SasAuthResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err instanceof CertificateError) throw err
|
if (err instanceof CertificateError) throw err
|
||||||
throw prefixMessage(err, 'Error while getting access token. ')
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
getTokenRequestErrorPrefix(
|
||||||
|
'fetching access token',
|
||||||
|
'getAccessTokenForViya',
|
||||||
|
ServerType.SasViya,
|
||||||
|
url,
|
||||||
|
dataJson,
|
||||||
|
headers,
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return authResponse
|
return authResponse
|
||||||
|
|||||||
88
src/auth/getTokenRequestErrorPrefix.ts
Normal file
88
src/auth/getTokenRequestErrorPrefix.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
|
||||||
|
type Server = ServerType.SasViya | ServerType.Sasjs
|
||||||
|
type Operation = 'fetching access token' | 'refreshing tokens'
|
||||||
|
|
||||||
|
const getServerName = (server: Server) =>
|
||||||
|
server === ServerType.SasViya ? 'Viya' : 'Sasjs'
|
||||||
|
|
||||||
|
const getResponseTitle = (server: Server) =>
|
||||||
|
`Response from ${getServerName(server)} is below.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forms error prefix for requests related to token operations.
|
||||||
|
* @param operation - string describing operation ('fetching access token' or 'refreshing tokens').
|
||||||
|
* @param funcName - name of the function sent the request.
|
||||||
|
* @param server - server type (SASVIYA or SASJS).
|
||||||
|
* @param url - endpoint used to send the request.
|
||||||
|
* @param data - request payload.
|
||||||
|
* @param headers - request headers.
|
||||||
|
* @param clientId - client ID to authenticate with.
|
||||||
|
* @param clientSecret - client secret to authenticate with.
|
||||||
|
* @returns - string containing request information. Example:
|
||||||
|
* Error while fetching access token from /SASLogon/oauth/token
|
||||||
|
* Thrown by the @sasjs/adapter getAccessTokenForViya function.
|
||||||
|
* Payload:
|
||||||
|
* {
|
||||||
|
* "grant_type": "authorization_code",
|
||||||
|
* "code": "example_code"
|
||||||
|
* }
|
||||||
|
* Headers:
|
||||||
|
* {
|
||||||
|
* "Authorization": "Basic NEdMQXBwOjRHTEFwcDE=",
|
||||||
|
* "Accept": "application/json"
|
||||||
|
* }
|
||||||
|
* ClientId: exampleClientId
|
||||||
|
* ClientSecret: exampleClientSecret
|
||||||
|
*
|
||||||
|
* Response from Viya is below.
|
||||||
|
* Auth error: {
|
||||||
|
* "error": "invalid_token",
|
||||||
|
* "error_description": "No scopes were granted"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const getTokenRequestErrorPrefix = (
|
||||||
|
operation: Operation,
|
||||||
|
funcName: string,
|
||||||
|
server: Server,
|
||||||
|
url: string,
|
||||||
|
data?: {},
|
||||||
|
headers?: {},
|
||||||
|
clientId?: string,
|
||||||
|
clientSecret?: string
|
||||||
|
) => {
|
||||||
|
const stringify = (obj: {}) => JSON.stringify(obj, null, 2)
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`Error while ${operation} from ${url}`,
|
||||||
|
`Thrown by the @sasjs/adapter ${funcName} function.`
|
||||||
|
]
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
lines.push('Payload:')
|
||||||
|
lines.push(stringify(data))
|
||||||
|
}
|
||||||
|
if (headers) {
|
||||||
|
lines.push('Headers:')
|
||||||
|
lines.push(stringify(headers))
|
||||||
|
}
|
||||||
|
if (clientId) lines.push(`ClientId: ${clientId}`)
|
||||||
|
if (clientSecret) lines.push(`ClientSecret: ${clientSecret}`)
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`${getResponseTitle(server)}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
return lines.join(`\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse error prefix to get response payload.
|
||||||
|
* @param prefix - error prefix generated by getTokenRequestErrorPrefix function.
|
||||||
|
* @param server - server type (SASVIYA or SASJS).
|
||||||
|
* @returns - response payload.
|
||||||
|
*/
|
||||||
|
export const getTokenRequestErrorPrefixResponse = (
|
||||||
|
prefix: string,
|
||||||
|
server: ServerType.SasViya | ServerType.Sasjs
|
||||||
|
) => prefix.split(`${getResponseTitle(server)}\n`).pop() as string
|
||||||
@@ -22,6 +22,7 @@ export async function getTokens(
|
|||||||
): Promise<AuthConfig> {
|
): Promise<AuthConfig> {
|
||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
let { access_token, refresh_token, client, secret } = authConfig
|
let { access_token, refresh_token, client, secret } = authConfig
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isAccessTokenExpiring(access_token) ||
|
isAccessTokenExpiring(access_token) ||
|
||||||
isRefreshTokenExpiring(refresh_token)
|
isRefreshTokenExpiring(refresh_token)
|
||||||
@@ -29,6 +30,7 @@ export async function getTokens(
|
|||||||
if (hasTokenExpired(refresh_token)) {
|
if (hasTokenExpired(refresh_token)) {
|
||||||
const error =
|
const error =
|
||||||
'Unable to obtain new access token. Your refresh token has expired.'
|
'Unable to obtain new access token. Your refresh token has expired.'
|
||||||
|
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
|
|
||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
@@ -47,5 +49,6 @@ export async function getTokens(
|
|||||||
: await refreshTokensForSasjs(requestClient, refresh_token)
|
: await refreshTokensForSasjs(requestClient, refresh_token)
|
||||||
;({ access_token, refresh_token } = tokens)
|
;({ access_token, refresh_token } = tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { access_token, refresh_token, client, secret }
|
return { access_token, refresh_token, client, secret }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './AuthManager'
|
export * from './AuthManager'
|
||||||
export * from './isAuthorizeFormRequired'
|
export * from './isAuthorizeFormRequired'
|
||||||
export * from './isLoginRequired'
|
export * from './isLoginRequired'
|
||||||
|
export * from './loginHeader'
|
||||||
|
|||||||
97
src/auth/loginHeader.ts
Normal file
97
src/auth/loginHeader.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import { getUserLanguage } from '../utils'
|
||||||
|
|
||||||
|
const enLoginSuccessHeader = 'You have signed in.'
|
||||||
|
|
||||||
|
export const defaultSuccessHeaderKey = 'default'
|
||||||
|
|
||||||
|
// The following headers provided by https://github.com/sasjs/adapter/issues/835#issuecomment-2177818601
|
||||||
|
export const loginSuccessHeaders: { [key: string]: string } = {
|
||||||
|
es: `Ya se ha iniciado la sesi\u00f3n.`,
|
||||||
|
th: `\u0e04\u0e38\u0e13\u0e25\u0e07\u0e0a\u0e37\u0e48\u0e2d\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e41\u0e25\u0e49\u0e27`,
|
||||||
|
ja: `\u30b5\u30a4\u30f3\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002`,
|
||||||
|
nb: `Du har logget deg p\u00e5.`,
|
||||||
|
sl: `Prijavili ste se.`,
|
||||||
|
ar: `\u0644\u0642\u062f \u0642\u0645\u062a `,
|
||||||
|
sk: `Prihl\u00e1sili ste sa.`,
|
||||||
|
zh_HK: `\u60a8\u5df2\u767b\u5165\u3002`,
|
||||||
|
zh_CN: `\u60a8\u5df2\u767b\u5f55\u3002`,
|
||||||
|
it: `L'utente si \u00e8 connesso.`,
|
||||||
|
sv: `Du har loggat in.`,
|
||||||
|
he: `\u05e0\u05db\u05e0\u05e1\u05ea `,
|
||||||
|
nl: `U hebt zich aangemeld.`,
|
||||||
|
pl: `Zosta\u0142e\u015b zalogowany.`,
|
||||||
|
ko: `\ub85c\uadf8\uc778\ud588\uc2b5\ub2c8\ub2e4.`,
|
||||||
|
zh_TW: `\u60a8\u5df2\u767b\u5165\u3002`,
|
||||||
|
tr: `Oturum a\u00e7t\u0131n\u0131z.`,
|
||||||
|
iw: `\u05e0\u05db\u05e0\u05e1\u05ea `,
|
||||||
|
fr: `Vous \u00eates connect\u00e9.`,
|
||||||
|
uk: `\u0412\u0438 \u0432\u0432\u0456\u0439\u0448\u043b\u0438 \u0432 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441.`,
|
||||||
|
pt_BR: `Voc\u00ea se conectou.`,
|
||||||
|
no: `Du har logget deg p\u00e5.`,
|
||||||
|
cs: `Jste p\u0159ihl\u00e1\u0161eni.`,
|
||||||
|
fi: `Olet kirjautunut sis\u00e4\u00e4n.`,
|
||||||
|
ru: `\u0412\u044b \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u043b\u0438 \u0432\u0445\u043e\u0434 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.`,
|
||||||
|
el: `\u0388\u03c7\u03b5\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af.`,
|
||||||
|
hr: `Prijavili ste se.`,
|
||||||
|
da: `Du er logget p\u00e5.`,
|
||||||
|
de: `Sie sind jetzt angemeldet.`,
|
||||||
|
sh: `Prijavljeni ste.`,
|
||||||
|
pt: `Iniciou sess\u00e3o.`,
|
||||||
|
hu: `Bejelentkezett.`,
|
||||||
|
sr: `Prijavljeni ste.`,
|
||||||
|
en: enLoginSuccessHeader,
|
||||||
|
[defaultSuccessHeaderKey]: enLoginSuccessHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides expected login header based on language settings of the browser.
|
||||||
|
* @returns - expected header as a string.
|
||||||
|
*/
|
||||||
|
export const getExpectedLogInSuccessHeader = (): string => {
|
||||||
|
// get default success header
|
||||||
|
let successHeader = loginSuccessHeaders[defaultSuccessHeaderKey]
|
||||||
|
|
||||||
|
// get user language based on language settings of the browser
|
||||||
|
const userLang = getUserLanguage()
|
||||||
|
|
||||||
|
if (userLang) {
|
||||||
|
// get success header on exact match of the language code
|
||||||
|
let userLangSuccessHeader = loginSuccessHeaders[userLang]
|
||||||
|
|
||||||
|
// handle case when there is no exact match of the language code
|
||||||
|
if (!userLangSuccessHeader) {
|
||||||
|
// get all supported language codes
|
||||||
|
const headerLanguages = Object.keys(loginSuccessHeaders)
|
||||||
|
|
||||||
|
// find language code on partial match
|
||||||
|
const headerLanguage = headerLanguages.find((language) =>
|
||||||
|
new RegExp(language, 'i').test(userLang)
|
||||||
|
)
|
||||||
|
|
||||||
|
// reassign success header if partial match was found
|
||||||
|
if (headerLanguage) {
|
||||||
|
successHeader = loginSuccessHeaders[headerLanguage]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
successHeader = userLangSuccessHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return successHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if Login success header is present in the response based on language settings of the browser.
|
||||||
|
* @param serverType - server type.
|
||||||
|
* @param response - response object.
|
||||||
|
* @returns - boolean indicating if Login success header is present.
|
||||||
|
*/
|
||||||
|
export const isLogInSuccessHeaderPresent = (
|
||||||
|
serverType: ServerType,
|
||||||
|
response: any
|
||||||
|
): boolean => {
|
||||||
|
if (serverType === ServerType.Sasjs) return response?.loggedIn
|
||||||
|
|
||||||
|
return new RegExp(getExpectedLogInSuccessHeader(), 'gm').test(response)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
|
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges the refresh token for an access token for the given client.
|
* Exchanges the refresh token for an access token for the given client.
|
||||||
@@ -28,7 +30,15 @@ export async function refreshTokensForSasjs(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
throw prefixMessage(err, 'Error while refreshing tokens: ')
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
getTokenRequestErrorPrefix(
|
||||||
|
'refreshing tokens',
|
||||||
|
'refreshTokensForSasjs',
|
||||||
|
ServerType.Sasjs,
|
||||||
|
url
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return authResponse
|
return authResponse
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
import { isNode } from '../utils'
|
import { isNode } from '../utils'
|
||||||
|
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges the refresh token for an access token for the given client.
|
* Exchanges the refresh token for an access token for the given client.
|
||||||
@@ -46,7 +47,19 @@ export async function refreshTokensForViya(
|
|||||||
)
|
)
|
||||||
.then((res) => res.result)
|
.then((res) => res.result)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
throw prefixMessage(err, 'Error while refreshing tokens: ')
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
getTokenRequestErrorPrefix(
|
||||||
|
'refreshing tokens',
|
||||||
|
'refreshTokensForViya',
|
||||||
|
ServerType.SasViya,
|
||||||
|
url,
|
||||||
|
formData,
|
||||||
|
headers,
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return authResponse
|
return authResponse
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
import { AuthManager } from '../AuthManager'
|
import { AuthManager } from '../AuthManager'
|
||||||
import * as dotenv from 'dotenv'
|
import * as dotenv from 'dotenv'
|
||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
mockedCurrentUserApi,
|
mockedCurrentUserApi,
|
||||||
mockLoginAuthoriseRequiredResponse,
|
mockLoginAuthoriseRequiredResponse
|
||||||
mockLoginSuccessResponse
|
|
||||||
} from './mockResponses'
|
} from './mockResponses'
|
||||||
import { serialize } from '../../utils'
|
import { serialize } from '../../utils'
|
||||||
import * as openWebPageModule from '../openWebPage'
|
import * as openWebPageModule from '../openWebPage'
|
||||||
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
|
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
|
||||||
import * as verifySas9LoginModule from '../verifySas9Login'
|
import * as verifySas9LoginModule from '../verifySas9Login'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
|
import { getExpectedLogInSuccessHeader } from '../'
|
||||||
|
|
||||||
jest.mock('axios')
|
jest.mock('axios')
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
|
|
||||||
@@ -125,6 +130,7 @@ describe('AuthManager', () => {
|
|||||||
requestClient,
|
requestClient,
|
||||||
authCallback
|
authCallback
|
||||||
)
|
)
|
||||||
|
|
||||||
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
@@ -133,8 +139,9 @@ describe('AuthManager', () => {
|
|||||||
loginForm: { name: 'test' }
|
loginForm: { name: 'test' }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
mockedAxios.post.mockImplementation(() =>
|
mockedAxios.post.mockImplementation(() =>
|
||||||
Promise.resolve({ data: mockLoginSuccessResponse })
|
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
|
||||||
)
|
)
|
||||||
|
|
||||||
const loginResponse = await authManager.logIn(userName, password)
|
const loginResponse = await authManager.logIn(userName, password)
|
||||||
@@ -152,7 +159,7 @@ describe('AuthManager', () => {
|
|||||||
`/SASLogon/login`,
|
`/SASLogon/login`,
|
||||||
loginParams,
|
loginParams,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: '*/*'
|
Accept: '*/*'
|
||||||
@@ -170,6 +177,7 @@ describe('AuthManager', () => {
|
|||||||
requestClient,
|
requestClient,
|
||||||
authCallback
|
authCallback
|
||||||
)
|
)
|
||||||
|
|
||||||
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
@@ -178,8 +186,9 @@ describe('AuthManager', () => {
|
|||||||
loginForm: { name: 'test' }
|
loginForm: { name: 'test' }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
mockedAxios.post.mockImplementation(() =>
|
mockedAxios.post.mockImplementation(() =>
|
||||||
Promise.resolve({ data: mockLoginSuccessResponse })
|
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
|
||||||
)
|
)
|
||||||
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
|
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
|
||||||
|
|
||||||
@@ -198,7 +207,7 @@ describe('AuthManager', () => {
|
|||||||
`/SASLogon/login`,
|
`/SASLogon/login`,
|
||||||
loginParams,
|
loginParams,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: '*/*'
|
Accept: '*/*'
|
||||||
@@ -247,7 +256,7 @@ describe('AuthManager', () => {
|
|||||||
`/SASLogon/login`,
|
`/SASLogon/login`,
|
||||||
loginParams,
|
loginParams,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: '*/*'
|
Accept: '*/*'
|
||||||
@@ -365,7 +374,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual(userName)
|
expect(loginResponse.userName).toEqual(userName)
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -409,7 +418,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual(userName)
|
expect(loginResponse.userName).toEqual(userName)
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -453,7 +462,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual('')
|
expect(loginResponse.userName).toEqual('')
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -497,7 +506,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual('')
|
expect(loginResponse.userName).toEqual('')
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -530,7 +539,7 @@ describe('AuthManager', () => {
|
|||||||
1,
|
1,
|
||||||
`http://test-server.com/identities/users/@currentUser`,
|
`http://test-server.com/identities/users/@currentUser`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
transformResponse: undefined,
|
transformResponse: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -564,7 +573,7 @@ describe('AuthManager', () => {
|
|||||||
1,
|
1,
|
||||||
`http://test-server.com/SASStoredProcess`,
|
`http://test-server.com/SASStoredProcess`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
transformResponse: undefined,
|
transformResponse: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -593,7 +602,7 @@ describe('AuthManager', () => {
|
|||||||
1,
|
1,
|
||||||
`http://test-server.com/identities/users/@currentUser`,
|
`http://test-server.com/identities/users/@currentUser`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
transformResponse: undefined,
|
transformResponse: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -612,7 +621,7 @@ describe('AuthManager', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getHeadersJson = {
|
const getHeadersJson = {
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json'
|
Accept: 'application/json'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import { generateToken, mockSasjsAuthResponse } from './mockResponses'
|
import { generateToken, mockSasjsAuthResponse } from './mockResponses'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { getAccessTokenForSasjs } from '../getAccessTokenForSasjs'
|
import { getAccessTokenForSasjs } from '../getAccessTokenForSasjs'
|
||||||
@@ -55,7 +55,7 @@ describe('getAccessTokenForSasjs', () => {
|
|||||||
authConfig.refresh_token
|
authConfig.refresh_token
|
||||||
).catch((e: any) => e)
|
).catch((e: any) => e)
|
||||||
|
|
||||||
expect(error).toContain('Error while getting access token')
|
expect(error).toContain('Error while fetching access token')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { getAccessTokenForViya } from '../getAccessTokenForViya'
|
import { getAccessTokenForViya } from '../getAccessTokenForViya'
|
||||||
@@ -66,7 +66,7 @@ describe('getAccessTokenForViya', () => {
|
|||||||
authConfig.refresh_token
|
authConfig.refresh_token
|
||||||
).catch((e: any) => e)
|
).catch((e: any) => e)
|
||||||
|
|
||||||
expect(error).toContain('Error while getting access token')
|
expect(error).toContain('Error while fetching access token')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
81
src/auth/spec/getTokenRequestErrorPrefix.spec.ts
Normal file
81
src/auth/spec/getTokenRequestErrorPrefix.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import { getTokenRequestErrorPrefix } from '../getTokenRequestErrorPrefix'
|
||||||
|
|
||||||
|
describe('getTokenRequestErrorPrefix', () => {
|
||||||
|
it('should return error prefix', () => {
|
||||||
|
// INFO: Viya with only required attributes
|
||||||
|
let operation: 'fetching access token' = 'fetching access token'
|
||||||
|
const funcName = 'testFunc'
|
||||||
|
const url = '/SASjsApi/auth/token'
|
||||||
|
|
||||||
|
let expectedPrefix = `Error while ${operation} from ${url}
|
||||||
|
Thrown by the @sasjs/adapter ${funcName} function.
|
||||||
|
|
||||||
|
Response from Viya is below.
|
||||||
|
`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTokenRequestErrorPrefix(operation, funcName, ServerType.SasViya, url)
|
||||||
|
).toEqual(expectedPrefix)
|
||||||
|
|
||||||
|
// INFO: Sasjs with data and headers
|
||||||
|
const data = {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: 'testCode'
|
||||||
|
}
|
||||||
|
const headers = {
|
||||||
|
Authorization: 'Basic test=',
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPrefix = `Error while ${operation} from ${url}
|
||||||
|
Thrown by the @sasjs/adapter ${funcName} function.
|
||||||
|
Payload:
|
||||||
|
${JSON.stringify(data, null, 2)}
|
||||||
|
Headers:
|
||||||
|
${JSON.stringify(headers, null, 2)}
|
||||||
|
|
||||||
|
Response from Sasjs is below.
|
||||||
|
`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTokenRequestErrorPrefix(
|
||||||
|
operation,
|
||||||
|
funcName,
|
||||||
|
ServerType.Sasjs,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
headers
|
||||||
|
)
|
||||||
|
).toEqual(expectedPrefix)
|
||||||
|
|
||||||
|
// INFO: Viya with all attributes
|
||||||
|
const clientId = 'testId'
|
||||||
|
const clientSecret = 'testSecret'
|
||||||
|
|
||||||
|
expectedPrefix = `Error while ${operation} from ${url}
|
||||||
|
Thrown by the @sasjs/adapter ${funcName} function.
|
||||||
|
Payload:
|
||||||
|
${JSON.stringify(data, null, 2)}
|
||||||
|
Headers:
|
||||||
|
${JSON.stringify(headers, null, 2)}
|
||||||
|
ClientId: ${clientId}
|
||||||
|
ClientSecret: ${clientSecret}
|
||||||
|
|
||||||
|
Response from Viya is below.
|
||||||
|
`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTokenRequestErrorPrefix(
|
||||||
|
operation,
|
||||||
|
funcName,
|
||||||
|
ServerType.SasViya,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
)
|
||||||
|
).toEqual(expectedPrefix)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import * as refreshTokensModule from '../refreshTokensForViya'
|
import * as refreshTokensModule from '../refreshTokensForViya'
|
||||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||||
import { getTokens } from '../getTokens'
|
import { getTokens } from '../getTokens'
|
||||||
|
|||||||
82
src/auth/spec/loginHeader.spec.ts
Normal file
82
src/auth/spec/loginHeader.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import {
|
||||||
|
loginSuccessHeaders,
|
||||||
|
isLogInSuccessHeaderPresent,
|
||||||
|
defaultSuccessHeaderKey
|
||||||
|
} from '../'
|
||||||
|
|
||||||
|
describe('isLogInSuccessHeaderPresent', () => {
|
||||||
|
let languageGetter: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
languageGetter = jest.spyOn(window.navigator, 'language', 'get')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check SASVIYA and SAS9 login success header based on language preferences of the browser', () => {
|
||||||
|
// test SASVIYA server type
|
||||||
|
Object.keys(loginSuccessHeaders).forEach((key) => {
|
||||||
|
languageGetter.mockReturnValue(key)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(
|
||||||
|
ServerType.SasViya,
|
||||||
|
loginSuccessHeaders[key]
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
// test SAS9 server type
|
||||||
|
Object.keys(loginSuccessHeaders).forEach((key) => {
|
||||||
|
languageGetter.mockReturnValue(key)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(ServerType.Sas9, loginSuccessHeaders[key])
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
// test possible longer language codes
|
||||||
|
const possibleLanguageCodes = [
|
||||||
|
{ short: 'en', long: 'en-US' },
|
||||||
|
{ short: 'fr', long: 'fr-FR' },
|
||||||
|
{ short: 'es', long: 'es-ES' }
|
||||||
|
]
|
||||||
|
|
||||||
|
possibleLanguageCodes.forEach((key) => {
|
||||||
|
const { short, long } = key
|
||||||
|
languageGetter.mockReturnValue(long)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(
|
||||||
|
ServerType.SasViya,
|
||||||
|
loginSuccessHeaders[short]
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
// test falling back to default language code
|
||||||
|
languageGetter.mockReturnValue('WRONG-LANGUAGE')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(
|
||||||
|
ServerType.Sas9,
|
||||||
|
loginSuccessHeaders[defaultSuccessHeaderKey]
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check SASVJS login success header', () => {
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: true })
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: false })
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
expect(isLogInSuccessHeaderPresent(ServerType.Sasjs, undefined)).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
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 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 = {
|
export const mockAuthResponse: SasAuthResponse = {
|
||||||
access_token: 'acc355',
|
access_token: 'acc355',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { refreshTokensForSasjs } from '../refreshTokensForSasjs'
|
import { refreshTokensForSasjs } from '../refreshTokensForSasjs'
|
||||||
|
import { getTokenRequestErrorPrefixResponse } from '../getTokenRequestErrorPrefix'
|
||||||
|
|
||||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||||
|
|
||||||
@@ -38,9 +40,9 @@ describe('refreshTokensForSasjs', () => {
|
|||||||
const error = await refreshTokensForSasjs(
|
const error = await refreshTokensForSasjs(
|
||||||
requestClient,
|
requestClient,
|
||||||
refresh_token
|
refresh_token
|
||||||
).catch((e: any) => e)
|
).catch((e: any) => getTokenRequestErrorPrefixResponse(e, ServerType.Sasjs))
|
||||||
|
|
||||||
expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
|
expect(error).toEqual(tokenError)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils'
|
import { AuthConfig, ServerType } from '@sasjs/utils/types'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { refreshTokensForViya } from '../refreshTokensForViya'
|
import { refreshTokensForViya } from '../refreshTokensForViya'
|
||||||
import * as IsNodeModule from '../../utils/isNode'
|
import * as IsNodeModule from '../../utils/isNode'
|
||||||
|
import { getTokenRequestErrorPrefixResponse } from '../getTokenRequestErrorPrefix'
|
||||||
|
|
||||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||||
|
|
||||||
@@ -67,9 +68,11 @@ describe('refreshTokensForViya', () => {
|
|||||||
authConfig.client,
|
authConfig.client,
|
||||||
authConfig.secret,
|
authConfig.secret,
|
||||||
authConfig.refresh_token
|
authConfig.refresh_token
|
||||||
).catch((e: any) => e)
|
).catch((e: any) =>
|
||||||
|
getTokenRequestErrorPrefixResponse(e, ServerType.SasViya)
|
||||||
|
)
|
||||||
|
|
||||||
expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
|
expect(error).toEqual(tokenError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw an error if environment is not Node', async () => {
|
it('should throw an error if environment is not Node', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { verifySas9Login } from '../verifySas9Login'
|
import { verifySas9Login } from '../verifySas9Login'
|
||||||
import * as delayModule from '../../utils/delay'
|
import * as delayModule from '../../utils/delay'
|
||||||
|
import { getExpectedLogInSuccessHeader } from '../'
|
||||||
|
|
||||||
describe('verifySas9Login', () => {
|
describe('verifySas9Login', () => {
|
||||||
const serverUrl = 'http://test-server.com'
|
const serverUrl = 'http://test-server.com'
|
||||||
@@ -18,7 +19,9 @@ describe('verifySas9Login', () => {
|
|||||||
const popup = {
|
const popup = {
|
||||||
window: {
|
window: {
|
||||||
location: { href: serverUrl + `/SASLogon` },
|
location: { href: serverUrl + `/SASLogon` },
|
||||||
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
|
document: {
|
||||||
|
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} as unknown as Window
|
} as unknown as Window
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { verifySasViyaLogin } from '../verifySasViyaLogin'
|
import { verifySasViyaLogin } from '../verifySasViyaLogin'
|
||||||
import * as delayModule from '../../utils/delay'
|
import * as delayModule from '../../utils/delay'
|
||||||
|
import { getExpectedLogInSuccessHeader } from '../'
|
||||||
|
|
||||||
describe('verifySasViyaLogin', () => {
|
describe('verifySasViyaLogin', () => {
|
||||||
const serverUrl = 'http://test-server.com'
|
const serverUrl = 'http://test-server.com'
|
||||||
@@ -19,7 +20,9 @@ describe('verifySasViyaLogin', () => {
|
|||||||
const popup = {
|
const popup = {
|
||||||
window: {
|
window: {
|
||||||
location: { href: serverUrl + `/SASLogon` },
|
location: { href: serverUrl + `/SASLogon` },
|
||||||
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
|
document: {
|
||||||
|
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} as unknown as Window
|
} as unknown as Window
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { delay } from '../utils'
|
import { delay } from '../utils'
|
||||||
|
import { getExpectedLogInSuccessHeader } from './'
|
||||||
|
|
||||||
export async function verifySas9Login(loginPopup: Window): Promise<{
|
export async function verifySas9Login(loginPopup: Window): Promise<{
|
||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
@@ -6,13 +7,17 @@ export async function verifySas9Login(loginPopup: Window): Promise<{
|
|||||||
let isLoggedIn = false
|
let isLoggedIn = false
|
||||||
let startTime = new Date()
|
let startTime = new Date()
|
||||||
let elapsedSeconds = 0
|
let elapsedSeconds = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
if (loginPopup.closed) break
|
if (loginPopup.closed) break
|
||||||
|
|
||||||
isLoggedIn =
|
isLoggedIn =
|
||||||
loginPopup.window.location.href.includes('SASLogon') &&
|
loginPopup.window.location.href.includes('SASLogon') &&
|
||||||
loginPopup.window.document.body.innerText.includes('You have signed in.')
|
loginPopup.window.document.body.innerText.includes(
|
||||||
|
getExpectedLogInSuccessHeader()
|
||||||
|
)
|
||||||
|
|
||||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||||
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { delay } from '../utils'
|
import { delay } from '../utils'
|
||||||
|
import { getExpectedLogInSuccessHeader } from './'
|
||||||
|
|
||||||
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
@@ -6,23 +7,32 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
|||||||
let isLoggedIn = false
|
let isLoggedIn = false
|
||||||
let startTime = new Date()
|
let startTime = new Date()
|
||||||
let elapsedSeconds = 0
|
let elapsedSeconds = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
|
|
||||||
if (loginPopup.closed) break
|
if (loginPopup.closed) break
|
||||||
|
|
||||||
isLoggedIn = isLoggedInSASVIYA()
|
isLoggedIn = isLoggedInSASVIYA()
|
||||||
|
|
||||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||||
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
||||||
|
|
||||||
let isAuthorized = false
|
let isAuthorized = false
|
||||||
|
|
||||||
startTime = new Date()
|
startTime = new Date()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
|
|
||||||
if (loginPopup.closed) break
|
if (loginPopup.closed) break
|
||||||
|
|
||||||
isAuthorized =
|
isAuthorized =
|
||||||
loginPopup.window.location.href.includes('SASLogon') ||
|
loginPopup.window.location.href.includes('SASLogon') ||
|
||||||
loginPopup.window.document.body?.innerText?.includes(
|
loginPopup.window.document.body?.innerText?.includes(
|
||||||
'You have signed in.'
|
getExpectedLogInSuccessHeader()
|
||||||
)
|
)
|
||||||
|
|
||||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||||
} while (!isAuthorized && elapsedSeconds < 5 * 60)
|
} while (!isAuthorized && elapsedSeconds < 5 * 60)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { convertToCSV } from '../utils/convertToCsv'
|
import { convertToCSV } from '../utils/convertToCsv'
|
||||||
|
import { isNode } from '../utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of the approaches SASjs takes to send tables-formatted JSON (see README)
|
* One of the approaches SASjs takes to send tables-formatted JSON (see README)
|
||||||
@@ -26,12 +27,15 @@ export const generateFileUploadForm = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof FormData === 'undefined' && formData instanceof NodeFormData) {
|
// INFO: unfortunately it is not possible to check if formData is instance of NodeFormData or FormData because it will return true for both
|
||||||
formData.append(name, csv, {
|
if (isNode()) {
|
||||||
|
// INFO: environment is Node and formData is instance of NodeFormData
|
||||||
|
;(formData as NodeFormData).append(name, csv, {
|
||||||
filename: `${name}.csv`,
|
filename: `${name}.csv`,
|
||||||
contentType: 'application/csv'
|
contentType: 'application/csv'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
// INFO: environment is Browser and formData is instance of FormData
|
||||||
const file = new Blob([csv], {
|
const file = new Blob([csv], {
|
||||||
type: 'application/csv'
|
type: 'application/csv'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
|
import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
|
||||||
import { splitChunks } from '../utils/splitChunks'
|
import { splitChunks } from '../utils/splitChunks'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { generateFileUploadForm } from '../generateFileUploadForm'
|
import { generateFileUploadForm } from '../generateFileUploadForm'
|
||||||
|
import { convertToCSV } from '../../utils/convertToCsv'
|
||||||
|
import NodeFormData from 'form-data'
|
||||||
|
import * as isNodeModule from '../../utils/isNode'
|
||||||
|
|
||||||
describe('generateFileUploadForm', () => {
|
describe('generateFileUploadForm', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -11,44 +14,94 @@ describe('generateFileUploadForm', () => {
|
|||||||
;(global as any).Blob = BlobMock
|
;(global as any).Blob = BlobMock
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should generate file upload form from data', () => {
|
describe('browser', () => {
|
||||||
const formData = new FormData()
|
afterAll(() => {
|
||||||
const testTable = 'sometable'
|
jest.restoreAllMocks()
|
||||||
const testTableWithNullVars: { [key: string]: any } = {
|
})
|
||||||
[testTable]: [
|
|
||||||
{ var1: 'string', var2: 232, nullvar: 'A' },
|
|
||||||
{ var1: 'string', var2: 232, nullvar: 'B' },
|
|
||||||
{ var1: 'string', var2: 232, nullvar: '_' },
|
|
||||||
{ var1: 'string', var2: 232, nullvar: 0 },
|
|
||||||
{ var1: 'string', var2: 232, nullvar: 'z' },
|
|
||||||
{ var1: 'string', var2: 232, nullvar: null }
|
|
||||||
],
|
|
||||||
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
|
|
||||||
}
|
|
||||||
const tableName = Object.keys(testTableWithNullVars).filter((key: string) =>
|
|
||||||
Array.isArray(testTableWithNullVars[key])
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
jest.spyOn(formData, 'append').mockImplementation(() => {})
|
it('should generate file upload form from data', () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
const testTable = 'sometable'
|
||||||
|
const testTableWithNullVars: { [key: string]: any } = {
|
||||||
|
[testTable]: [
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 'A' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 'B' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: '_' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 0 },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 'z' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: null }
|
||||||
|
],
|
||||||
|
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
|
||||||
|
}
|
||||||
|
const tableName = Object.keys(testTableWithNullVars).filter(
|
||||||
|
(key: string) => Array.isArray(testTableWithNullVars[key])
|
||||||
|
)[0]
|
||||||
|
|
||||||
generateFileUploadForm(formData, testTableWithNullVars)
|
jest.spyOn(formData, 'append').mockImplementation(() => {})
|
||||||
|
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
|
||||||
|
|
||||||
expect(formData.append).toHaveBeenCalledOnce()
|
generateFileUploadForm(formData, testTableWithNullVars)
|
||||||
expect(formData.append).toHaveBeenCalledWith(
|
|
||||||
tableName,
|
expect(formData.append).toHaveBeenCalledOnce()
|
||||||
{},
|
expect(formData.append).toHaveBeenCalledWith(
|
||||||
`${tableName}.csv`
|
tableName,
|
||||||
)
|
{},
|
||||||
|
`${tableName}.csv`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if too large string was provided', () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
|
||||||
|
|
||||||
|
expect(() => generateFileUploadForm(formData, data)).toThrow(
|
||||||
|
new Error(
|
||||||
|
'The max length of a string value in SASjs is 32765 characters.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw an error if too large string was provided', () => {
|
describe('node', () => {
|
||||||
const formData = new FormData()
|
it('should generate file upload form from data', () => {
|
||||||
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
|
const formData = new NodeFormData()
|
||||||
|
const testTable = 'sometable'
|
||||||
|
const testTableWithNullVars: { [key: string]: any } = {
|
||||||
|
[testTable]: [
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 'A' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 'B' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: '_' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 0 },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: 'z' },
|
||||||
|
{ var1: 'string', var2: 232, nullvar: null }
|
||||||
|
],
|
||||||
|
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
|
||||||
|
}
|
||||||
|
const tableName = Object.keys(testTableWithNullVars).filter(
|
||||||
|
(key: string) => Array.isArray(testTableWithNullVars[key])
|
||||||
|
)[0]
|
||||||
|
const csv = convertToCSV(testTableWithNullVars, tableName)
|
||||||
|
|
||||||
expect(() => generateFileUploadForm(formData, data)).toThrow(
|
jest.spyOn(formData, 'append').mockImplementation(() => {})
|
||||||
new Error(
|
|
||||||
'The max length of a string value in SASjs is 32765 characters.'
|
generateFileUploadForm(formData, testTableWithNullVars)
|
||||||
|
|
||||||
|
expect(formData.append).toHaveBeenCalledOnce()
|
||||||
|
expect(formData.append).toHaveBeenCalledWith(tableName, csv, {
|
||||||
|
contentType: 'application/csv',
|
||||||
|
filename: `${tableName}.csv`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if too large string was provided', () => {
|
||||||
|
const formData = new NodeFormData()
|
||||||
|
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
|
||||||
|
|
||||||
|
expect(() => generateFileUploadForm(formData, data)).toThrow(
|
||||||
|
new Error(
|
||||||
|
'The max length of a string value in SASjs is 32765 characters.'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
getValidJson,
|
getValidJson,
|
||||||
parseSasViyaDebugResponse,
|
parseSasViyaDebugResponse,
|
||||||
parseWeboutResponse,
|
parseWeboutResponse
|
||||||
SASJS_LOGS_SEPARATOR
|
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { UploadFile } from '../types/UploadFile'
|
import { UploadFile } from '../types/UploadFile'
|
||||||
import {
|
import {
|
||||||
@@ -93,15 +92,24 @@ export class FileUploader extends BaseJobExecutor {
|
|||||||
this.requestClient,
|
this.requestClient,
|
||||||
config.serverUrl
|
config.serverUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
break
|
break
|
||||||
case ServerType.Sas9:
|
case ServerType.Sas9:
|
||||||
jsonResponse =
|
jsonResponse =
|
||||||
typeof res.result === 'string'
|
typeof res.result === 'string'
|
||||||
? parseWeboutResponse(res.result, uploadUrl)
|
? parseWeboutResponse(res.result, uploadUrl)
|
||||||
: res.result
|
: res.result
|
||||||
|
|
||||||
|
break
|
||||||
|
case ServerType.Sasjs:
|
||||||
|
jsonResponse =
|
||||||
|
typeof res.result === 'string'
|
||||||
|
? getValidJson(res.result)
|
||||||
|
: res.result
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else if (this.serverType !== ServerType.Sasjs) {
|
} else {
|
||||||
jsonResponse =
|
jsonResponse =
|
||||||
typeof res.result === 'string'
|
typeof res.result === 'string'
|
||||||
? getValidJson(res.result)
|
? getValidJson(res.result)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
|
|||||||
|
|
||||||
if (config.debug) {
|
if (config.debug) {
|
||||||
requestParams['_omittextlog'] = 'false'
|
requestParams['_omittextlog'] = 'false'
|
||||||
requestParams['_omitsessionresults'] = 'false'
|
requestParams['_omitSessionResults'] = 'false'
|
||||||
|
|
||||||
requestParams['_debug'] = 131
|
requestParams['_debug'] = 131
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { ErrorResponse } from '../types/errors'
|
import { ErrorResponse } from '../types/errors'
|
||||||
import { convertToCSV, isRelativePath } from '../utils'
|
import { convertToCSV, isRelativePath } from '../utils'
|
||||||
import { BaseJobExecutor } from './JobExecutor'
|
import { BaseJobExecutor } from './JobExecutor'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import {
|
import {
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
ExtraResponseAttributes,
|
ExtraResponseAttributes,
|
||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
LoginRequiredError
|
LoginRequiredError
|
||||||
} from '../types/errors'
|
} from '../types/errors'
|
||||||
import { generateFileUploadForm } from '../file/generateFileUploadForm'
|
import { generateFileUploadForm } from '../file/generateFileUploadForm'
|
||||||
|
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
|
import { getFormData } from '../utils'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isRelativePath,
|
isRelativePath,
|
||||||
@@ -53,8 +53,7 @@ export class SasjsJobExecutor extends BaseJobExecutor {
|
|||||||
* Use the available form data object (FormData in Browser, NodeFormData in
|
* Use the available form data object (FormData in Browser, NodeFormData in
|
||||||
* Node)
|
* Node)
|
||||||
*/
|
*/
|
||||||
let formData =
|
let formData = getFormData()
|
||||||
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
// file upload approach
|
// file upload approach
|
||||||
@@ -74,8 +73,10 @@ export class SasjsJobExecutor extends BaseJobExecutor {
|
|||||||
/* The NodeFormData object does not set the request header - so, set it */
|
/* The NodeFormData object does not set the request header - so, set it */
|
||||||
const contentType =
|
const contentType =
|
||||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
? `multipart/form-data; boundary=${
|
||||||
: undefined
|
formData.getHeaders()['content-type']
|
||||||
|
}`
|
||||||
|
: 'multipart/form-data'
|
||||||
|
|
||||||
const requestPromise = new Promise((resolve, reject) => {
|
const requestPromise = new Promise((resolve, reject) => {
|
||||||
this.requestClient!.post(
|
this.requestClient!.post(
|
||||||
@@ -93,8 +94,10 @@ export class SasjsJobExecutor extends BaseJobExecutor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result } = res.result
|
const { result } = res
|
||||||
if (result && result.trim()) res.result = getValidJson(result)
|
|
||||||
|
if (result && typeof result === 'string' && result.trim())
|
||||||
|
res.result = getValidJson(result)
|
||||||
|
|
||||||
this.requestClient!.appendRequest(res, sasJob, config.debug)
|
this.requestClient!.appendRequest(res, sasJob, config.debug)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import {
|
import {
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
ExtraResponseAttributes,
|
ExtraResponseAttributes,
|
||||||
@@ -16,10 +16,11 @@ import { SASViyaApiClient } from '../SASViyaApiClient'
|
|||||||
import {
|
import {
|
||||||
isRelativePath,
|
isRelativePath,
|
||||||
parseSasViyaDebugResponse,
|
parseSasViyaDebugResponse,
|
||||||
appendExtraResponseAttributes
|
appendExtraResponseAttributes,
|
||||||
|
parseWeboutResponse,
|
||||||
|
getFormData
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { BaseJobExecutor } from './JobExecutor'
|
import { BaseJobExecutor } from './JobExecutor'
|
||||||
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
|
||||||
|
|
||||||
export interface WaitingRequstPromise {
|
export interface WaitingRequstPromise {
|
||||||
promise: Promise<any> | null
|
promise: Promise<any> | null
|
||||||
@@ -112,8 +113,7 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
* Use the available form data object (FormData in Browser, NodeFormData in
|
* Use the available form data object (FormData in Browser, NodeFormData in
|
||||||
* Node)
|
* Node)
|
||||||
*/
|
*/
|
||||||
let formData =
|
let formData = getFormData()
|
||||||
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const stringifiedData = JSON.stringify(data)
|
const stringifiedData = JSON.stringify(data)
|
||||||
@@ -150,8 +150,10 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
/* The NodeFormData object does not set the request header - so, set it */
|
/* The NodeFormData object does not set the request header - so, set it */
|
||||||
const contentType =
|
const contentType =
|
||||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
? `multipart/form-data; boundary=${
|
||||||
: undefined
|
formData.getHeaders()['content-type']
|
||||||
|
}`
|
||||||
|
: 'multipart/form-data'
|
||||||
|
|
||||||
const requestPromise = new Promise((resolve, reject) => {
|
const requestPromise = new Promise((resolve, reject) => {
|
||||||
this.requestClient!.post(
|
this.requestClient!.post(
|
||||||
|
|||||||
@@ -233,7 +233,8 @@ export default class SASjs {
|
|||||||
this.requestClient = new RequestClient(
|
this.requestClient = new RequestClient(
|
||||||
this.sasjsConfig.serverUrl,
|
this.sasjsConfig.serverUrl,
|
||||||
this.sasjsConfig.httpsAgentOptions,
|
this.sasjsConfig.httpsAgentOptions,
|
||||||
this.sasjsConfig.requestHistoryLimit
|
this.sasjsConfig.requestHistoryLimit,
|
||||||
|
this.sasjsConfig.verbose
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.requestClient.setConfig(
|
this.requestClient.setConfig(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import {
|
import {
|
||||||
isRelativePath,
|
isRelativePath,
|
||||||
parseSasViyaDebugResponse,
|
|
||||||
appendExtraResponseAttributes,
|
appendExtraResponseAttributes,
|
||||||
convertToCSV
|
convertToCSV
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
import {
|
||||||
|
AxiosError,
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosRequestHeaders,
|
||||||
|
AxiosResponse
|
||||||
|
} from 'axios'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { CsrfToken } from '..'
|
import { CsrfToken } from '..'
|
||||||
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
|
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
|
||||||
@@ -10,7 +16,7 @@ import {
|
|||||||
JobExecutionError,
|
JobExecutionError,
|
||||||
CertificateError
|
CertificateError
|
||||||
} from '../types/errors'
|
} from '../types/errors'
|
||||||
import { SASjsRequest } from '../types'
|
import { SASjsRequest, HttpClient, VerboseMode } from '../types'
|
||||||
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
|
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
|
||||||
@@ -20,45 +26,13 @@ import {
|
|||||||
createAxiosInstance
|
createAxiosInstance
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
|
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
|
||||||
|
import { inspect } from 'util'
|
||||||
export interface HttpClient {
|
|
||||||
get<T>(
|
|
||||||
url: string,
|
|
||||||
accessToken: string | undefined,
|
|
||||||
contentType: string,
|
|
||||||
overrideHeaders: { [key: string]: string | number }
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
post<T>(
|
|
||||||
url: string,
|
|
||||||
data: any,
|
|
||||||
accessToken: string | undefined,
|
|
||||||
contentType: string,
|
|
||||||
overrideHeaders: { [key: string]: string | number }
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
put<T>(
|
|
||||||
url: string,
|
|
||||||
data: any,
|
|
||||||
accessToken: string | undefined,
|
|
||||||
overrideHeaders: { [key: string]: string | number }
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
delete<T>(
|
|
||||||
url: string,
|
|
||||||
accessToken: string | undefined
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
|
|
||||||
saveLocalStorageToken(accessToken: string, refreshToken: string): void
|
|
||||||
clearCsrfTokens(): void
|
|
||||||
clearLocalStorageTokens(): void
|
|
||||||
getBaseUrl(): string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RequestClient implements HttpClient {
|
export class RequestClient implements HttpClient {
|
||||||
private requests: SASjsRequest[] = []
|
private requests: SASjsRequest[] = []
|
||||||
private requestsLimit: number = 10
|
private requestsLimit: number = 10
|
||||||
|
private httpInterceptor?: number
|
||||||
|
private verboseMode: VerboseMode = false
|
||||||
|
|
||||||
protected csrfToken: CsrfToken = { headerName: '', value: '' }
|
protected csrfToken: CsrfToken = { headerName: '', value: '' }
|
||||||
protected fileUploadCsrfToken: CsrfToken | undefined
|
protected fileUploadCsrfToken: CsrfToken | undefined
|
||||||
@@ -67,10 +41,17 @@ export class RequestClient implements HttpClient {
|
|||||||
constructor(
|
constructor(
|
||||||
protected baseUrl: string,
|
protected baseUrl: string,
|
||||||
httpsAgentOptions?: https.AgentOptions,
|
httpsAgentOptions?: https.AgentOptions,
|
||||||
requestsLimit?: number
|
requestsLimit?: number,
|
||||||
|
verboseMode?: VerboseMode
|
||||||
) {
|
) {
|
||||||
this.createHttpClient(baseUrl, httpsAgentOptions)
|
this.createHttpClient(baseUrl, httpsAgentOptions)
|
||||||
|
|
||||||
if (requestsLimit) this.requestsLimit = requestsLimit
|
if (requestsLimit) this.requestsLimit = requestsLimit
|
||||||
|
|
||||||
|
if (verboseMode) {
|
||||||
|
this.setVerboseMode(verboseMode)
|
||||||
|
this.enableVerboseMode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
|
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
|
||||||
@@ -90,6 +71,7 @@ export class RequestClient implements HttpClient {
|
|||||||
this.csrfToken = { headerName: '', value: '' }
|
this.csrfToken = { headerName: '', value: '' }
|
||||||
this.fileUploadCsrfToken = { headerName: '', value: '' }
|
this.fileUploadCsrfToken = { headerName: '', value: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearLocalStorageTokens() {
|
public clearLocalStorageTokens() {
|
||||||
localStorage.setItem('accessToken', '')
|
localStorage.setItem('accessToken', '')
|
||||||
localStorage.setItem('refreshToken', '')
|
localStorage.setItem('refreshToken', '')
|
||||||
@@ -178,8 +160,9 @@ export class RequestClient implements HttpClient {
|
|||||||
const requestConfig: AxiosRequestConfig = {
|
const requestConfig: AxiosRequestConfig = {
|
||||||
headers,
|
headers,
|
||||||
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
||||||
withCredentials: true
|
withXSRFToken: true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType === 'text/plain') {
|
if (contentType === 'text/plain') {
|
||||||
requestConfig.transformResponse = undefined
|
requestConfig.transformResponse = undefined
|
||||||
}
|
}
|
||||||
@@ -208,6 +191,13 @@ export class RequestClient implements HttpClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param contentType Newer version of Axios is more strict so if you don't
|
||||||
|
* set the contentType to `form data` while sending a FormData object
|
||||||
|
* application/json will be used by default, axios won’t treat it as FormData.
|
||||||
|
* Instead, it serializes data as JSON—resulting in a payload like
|
||||||
|
* {"sometable":{}} and we lose the multipart/form-data formatting.
|
||||||
|
*/
|
||||||
public async post<T>(
|
public async post<T>(
|
||||||
url: string,
|
url: string,
|
||||||
data: any,
|
data: any,
|
||||||
@@ -224,7 +214,7 @@ export class RequestClient implements HttpClient {
|
|||||||
return this.httpClient
|
return this.httpClient
|
||||||
.post<T>(url, data, {
|
.post<T>(url, data, {
|
||||||
headers,
|
headers,
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
...additionalSettings
|
...additionalSettings
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -251,7 +241,7 @@ export class RequestClient implements HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.put<T>(url, data, { headers, withCredentials: true })
|
.put<T>(url, data, { headers, withXSRFToken: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
throwIfError(response)
|
throwIfError(response)
|
||||||
return this.parseResponse<T>(response)
|
return this.parseResponse<T>(response)
|
||||||
@@ -270,7 +260,7 @@ export class RequestClient implements HttpClient {
|
|||||||
const headers = this.getHeaders(accessToken, 'application/json')
|
const headers = this.getHeaders(accessToken, 'application/json')
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.delete<T>(url, { headers, withCredentials: true })
|
.delete<T>(url, { headers, withXSRFToken: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
throwIfError(response)
|
throwIfError(response)
|
||||||
return this.parseResponse<T>(response)
|
return this.parseResponse<T>(response)
|
||||||
@@ -288,7 +278,7 @@ export class RequestClient implements HttpClient {
|
|||||||
const headers = this.getHeaders(accessToken, 'application/json')
|
const headers = this.getHeaders(accessToken, 'application/json')
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.patch<T>(url, data, { headers, withCredentials: true })
|
.patch<T>(url, data, { headers, withXSRFToken: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
throwIfError(response)
|
throwIfError(response)
|
||||||
return this.parseResponse<T>(response)
|
return this.parseResponse<T>(response)
|
||||||
@@ -389,6 +379,166 @@ export class RequestClient implements HttpClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds colors to the string.
|
||||||
|
* If verboseMode is set to 'bleached', colors should be disabled
|
||||||
|
* @param str - string to be prettified.
|
||||||
|
* @returns - prettified string
|
||||||
|
*/
|
||||||
|
private prettifyString = (str: any) =>
|
||||||
|
inspect(str, { colors: this.verboseMode !== 'bleached' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats HTTP request/response body.
|
||||||
|
* @param body - HTTP request/response body.
|
||||||
|
* @returns - formatted string.
|
||||||
|
*/
|
||||||
|
private parseInterceptedBody = (body: any) => {
|
||||||
|
if (!body) return ''
|
||||||
|
|
||||||
|
let parsedBody
|
||||||
|
|
||||||
|
// Tries to parse body into JSON object.
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(body)
|
||||||
|
} catch (error) {
|
||||||
|
parsedBody = body
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedBody = body
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyLines = this.prettifyString(parsedBody).split('\n')
|
||||||
|
|
||||||
|
// Leaves first 50 lines
|
||||||
|
if (bodyLines.length > 51) {
|
||||||
|
bodyLines.splice(50)
|
||||||
|
bodyLines.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosResponse = (response: AxiosResponse) => {
|
||||||
|
const { status, config, request, data } = response
|
||||||
|
|
||||||
|
const reqHeaders = request?._header ?? 'Not provided\n'
|
||||||
|
const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
|
||||||
|
|
||||||
|
const resHeaders = this.formatHeaders(rawHeaders)
|
||||||
|
const parsedResBody = this.parseInterceptedBody(data)
|
||||||
|
|
||||||
|
process.logger?.info(`HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${this.parseInterceptedBody(config.data)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${this.prettifyString(status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||||
|
`)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError = (error: AxiosError) => {
|
||||||
|
// Message indicating absent value.
|
||||||
|
const noValueMessage = 'Not provided'
|
||||||
|
const { response, request, config } = error
|
||||||
|
|
||||||
|
// Fallback request object that can be safely used to form request summary.
|
||||||
|
// _header is not present in responses with status 1**
|
||||||
|
// rawHeaders are not present in responses with status 1**
|
||||||
|
let fallbackRequest = {
|
||||||
|
_header: `${noValueMessage}\n`,
|
||||||
|
res: { rawHeaders: [noValueMessage] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
fallbackRequest = {
|
||||||
|
_header:
|
||||||
|
request._header ?? request._currentRequest?._header ?? noValueMessage,
|
||||||
|
res: { rawHeaders: request.res?.rawHeaders ?? [noValueMessage] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback response object that can be safely used to form response summary.
|
||||||
|
let fallbackResponse = response || {
|
||||||
|
status: noValueMessage,
|
||||||
|
request: fallbackRequest,
|
||||||
|
config: config || {
|
||||||
|
data: noValueMessage,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
data: noValueMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, request: req, data: resData } = fallbackResponse
|
||||||
|
const { _header: reqHeaders, res } = req
|
||||||
|
|
||||||
|
const resHeaders = this.formatHeaders(res.rawHeaders)
|
||||||
|
const parsedResBody = this.parseInterceptedBody(resData)
|
||||||
|
|
||||||
|
process.logger?.info(`HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${this.parseInterceptedBody(config?.data)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${this.prettifyString(status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||||
|
`)
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts an array of strings into a single string with the following format:
|
||||||
|
// <headerName>: <headerValue>
|
||||||
|
private formatHeaders = (rawHeaders: string[]): string => {
|
||||||
|
return rawHeaders.reduce((acc, value, i) => {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
acc += `${i === 0 ? '' : '\n'}${value}`
|
||||||
|
} else {
|
||||||
|
acc += `: ${value}`
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets verbose mode.
|
||||||
|
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
|
||||||
|
*/
|
||||||
|
public setVerboseMode = (verboseMode: VerboseMode) => {
|
||||||
|
this.verboseMode = verboseMode
|
||||||
|
|
||||||
|
if (this.verboseMode) this.enableVerboseMode()
|
||||||
|
else this.disableVerboseMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns on verbose mode to log every HTTP response.
|
||||||
|
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
|
||||||
|
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
|
||||||
|
*/
|
||||||
|
public enableVerboseMode = (
|
||||||
|
successCallBack = this.handleAxiosResponse,
|
||||||
|
errorCallBack = this.handleAxiosError
|
||||||
|
) => {
|
||||||
|
this.httpInterceptor = this.httpClient.interceptors.response.use(
|
||||||
|
successCallBack,
|
||||||
|
errorCallBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off verbose mode to log every HTTP response.
|
||||||
|
*/
|
||||||
|
public disableVerboseMode = () => {
|
||||||
|
if (this.httpInterceptor) {
|
||||||
|
this.httpClient.interceptors.response.eject(this.httpInterceptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected getHeaders = (
|
protected getHeaders = (
|
||||||
accessToken: string | undefined,
|
accessToken: string | undefined,
|
||||||
contentType: string
|
contentType: string
|
||||||
@@ -487,7 +637,7 @@ export class RequestClient implements HttpClient {
|
|||||||
// Fetching root and creating CSRF cookie
|
// Fetching root and creating CSRF cookie
|
||||||
await this.httpClient
|
await this.httpClient
|
||||||
.get('/', {
|
.get('/', {
|
||||||
withCredentials: true
|
withXSRFToken: true
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const cookie =
|
const cookie =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { AxiosRequestConfig } from 'axios'
|
import { AxiosRequestConfig } from 'axios'
|
||||||
import axiosCookieJarSupport from 'axios-cookiejar-support'
|
import { wrapper } from 'axios-cookiejar-support'
|
||||||
import * as tough from 'tough-cookie'
|
import * as tough from 'tough-cookie'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { RequestClient, throwIfError } from './RequestClient'
|
import { RequestClient, throwIfError } from './RequestClient'
|
||||||
@@ -17,8 +17,8 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
this.httpClient.defaults.validateStatus = (status) =>
|
this.httpClient.defaults.validateStatus = (status) =>
|
||||||
status >= 200 && status < 303
|
status >= 200 && status < 303
|
||||||
|
|
||||||
if (axiosCookieJarSupport) {
|
if (wrapper) {
|
||||||
axiosCookieJarSupport(this.httpClient)
|
wrapper(this.httpClient)
|
||||||
this.httpClient.defaults.jar = new tough.CookieJar()
|
this.httpClient.defaults.jar = new tough.CookieJar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
const requestConfig: AxiosRequestConfig = {
|
const requestConfig: AxiosRequestConfig = {
|
||||||
headers,
|
headers,
|
||||||
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
||||||
withCredentials: true
|
withXSRFToken: true
|
||||||
}
|
}
|
||||||
if (contentType === 'text/plain') {
|
if (contentType === 'text/plain') {
|
||||||
requestConfig.transformResponse = undefined
|
requestConfig.transformResponse = undefined
|
||||||
@@ -103,7 +103,7 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.post<T>(url, data, { headers, withCredentials: true })
|
.post<T>(url, data, { headers, withXSRFToken: true })
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (response.status === 302) {
|
if (response.status === 302) {
|
||||||
return await this.get(
|
return await this.get(
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import { RequestClient } from './RequestClient'
|
import { RequestClient } from './RequestClient'
|
||||||
import { AxiosResponse } from 'axios'
|
import { AxiosResponse } from 'axios'
|
||||||
import { SASJS_LOGS_SEPARATOR } from '../utils'
|
import { SasjsParsedResponse } from '../types'
|
||||||
|
|
||||||
interface SasjsParsedResponse<T> {
|
|
||||||
result: T
|
|
||||||
log: string
|
|
||||||
etag: string
|
|
||||||
status: number
|
|
||||||
printOutput?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specific request client for SASJS.
|
* Specific request client for SASJS.
|
||||||
* Append tokens in headers.
|
* Append tokens in headers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class SasjsRequestClient extends RequestClient {
|
export class SasjsRequestClient extends RequestClient {
|
||||||
getHeaders = (accessToken: string | undefined, contentType: string) => {
|
getHeaders = (accessToken: string | undefined, contentType: string) => {
|
||||||
const headers: any = {}
|
const headers: any = {}
|
||||||
@@ -45,13 +36,30 @@ export class SasjsRequestClient extends RequestClient {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
|
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
|
||||||
const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR)
|
const { data } = response
|
||||||
|
const splittedResponse: string[] = data.split(SASJS_LOGS_SEPARATOR)
|
||||||
|
|
||||||
webout = splittedResponse[0]
|
webout = splittedResponse.splice(0, 1)[0]
|
||||||
if (webout) parsedResponse = webout
|
if (webout !== undefined) parsedResponse = webout
|
||||||
|
|
||||||
log = splittedResponse[1]
|
// log can contain nested logs
|
||||||
printOutput = splittedResponse[2]
|
const logs = splittedResponse.splice(0, splittedResponse.length - 1)
|
||||||
|
|
||||||
|
// tests if string ends with SASJS_LOGS_SEPARATOR
|
||||||
|
const endingWithLogSepRegExp = new RegExp(`${SASJS_LOGS_SEPARATOR}$`)
|
||||||
|
|
||||||
|
// at this point splittedResponse can contain only one item
|
||||||
|
const lastChunk = splittedResponse[0]
|
||||||
|
|
||||||
|
if (lastChunk) {
|
||||||
|
// if the last chunk doesn't end with SASJS_LOGS_SEPARATOR, then it is a printOutput
|
||||||
|
// else the last chunk is part of the log and has to be joined
|
||||||
|
if (!endingWithLogSepRegExp.test(data)) printOutput = lastChunk
|
||||||
|
else if (logs.length > 1) logs.push(lastChunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// join logs into single log with SASJS_LOGS_SEPARATOR
|
||||||
|
log = logs.join(SASJS_LOGS_SEPARATOR)
|
||||||
} else {
|
} else {
|
||||||
parsedResponse = response.data
|
parsedResponse = response.data
|
||||||
}
|
}
|
||||||
@@ -59,7 +67,7 @@ export class SasjsRequestClient extends RequestClient {
|
|||||||
|
|
||||||
const returnResult: SasjsParsedResponse<T> = {
|
const returnResult: SasjsParsedResponse<T> = {
|
||||||
result: parsedResponse as T,
|
result: parsedResponse as T,
|
||||||
log,
|
log: log || '',
|
||||||
etag,
|
etag,
|
||||||
status: response.status
|
status: response.status
|
||||||
}
|
}
|
||||||
@@ -69,3 +77,6 @@ export class SasjsRequestClient extends RequestClient {
|
|||||||
return returnResult
|
return returnResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SASJS_LOGS_SEPARATOR =
|
||||||
|
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
|||||||
177
src/request/spec/SasjsRequestClient.spec.ts
Normal file
177
src/request/spec/SasjsRequestClient.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
|
||||||
|
import { SasjsParsedResponse } from '../../types'
|
||||||
|
import { AxiosRequestHeaders, AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
describe('SasjsRequestClient', () => {
|
||||||
|
const requestClient = new SasjsRequestClient('')
|
||||||
|
const etag = 'etag'
|
||||||
|
const status = 200
|
||||||
|
|
||||||
|
const webout = `hello`
|
||||||
|
const log = `1 The SAS System Tuesday, 25 July 2023 12:51:00
|
||||||
|
|
||||||
|
|
||||||
|
PROC MIGRATE will preserve current SAS file attributes and is
|
||||||
|
recommended for converting all your SAS libraries from any
|
||||||
|
SAS 8 release to SAS 9. For details and examples, please see
|
||||||
|
http://support.sas.com/rnd/migration/index.html
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
NOTE: SAS initialization used:
|
||||||
|
real time 0.01 seconds
|
||||||
|
cpu time 0.02 seconds
|
||||||
|
|
||||||
|
|
||||||
|
`
|
||||||
|
const printOutput = 'printOutPut'
|
||||||
|
|
||||||
|
describe('parseResponse', () => {})
|
||||||
|
|
||||||
|
it('should parse response with 1 log', () => {
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${log}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse response with 1 log and printOutput', () => {
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${printOutput}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${log}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status,
|
||||||
|
printOutput: `
|
||||||
|
${printOutput}`
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse response with nested logs', () => {
|
||||||
|
const logWithNestedLog = `root log start
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
root log end`
|
||||||
|
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${logWithNestedLog}
|
||||||
|
${SASJS_LOGS_SEPARATOR}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${logWithNestedLog}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse response with nested logs and printOutput', () => {
|
||||||
|
const logWithNestedLog = `root log start
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
log with indentation
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
some SAS code containing ${SASJS_LOGS_SEPARATOR}
|
||||||
|
root log end`
|
||||||
|
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${logWithNestedLog}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${printOutput}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${logWithNestedLog}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status,
|
||||||
|
printOutput: `
|
||||||
|
${printOutput}`
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SASJS_LOGS_SEPARATOR', () => {
|
||||||
|
it('SASJS_LOGS_SEPARATOR should be hardcoded', () => {
|
||||||
|
expect(SASJS_LOGS_SEPARATOR).toEqual(
|
||||||
|
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
130
src/spec/SAS9ApiClient.spec.ts
Normal file
130
src/spec/SAS9ApiClient.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
import * as https from 'https'
|
||||||
|
import NodeFormData from 'form-data'
|
||||||
|
import { SAS9ApiClient } from '../SAS9ApiClient'
|
||||||
|
import { Sas9RequestClient } from '../request/Sas9RequestClient'
|
||||||
|
|
||||||
|
// Mock the Sas9RequestClient so that we can control its behavior
|
||||||
|
jest.mock('../request/Sas9RequestClient', () => {
|
||||||
|
return {
|
||||||
|
Sas9RequestClient: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
(serverUrl: string, httpsAgentOptions?: https.AgentOptions) => {
|
||||||
|
return {
|
||||||
|
login: jest.fn().mockResolvedValue(undefined),
|
||||||
|
post: jest.fn().mockResolvedValue({ result: 'execution result' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SAS9ApiClient', () => {
|
||||||
|
const serverUrl = 'http://test-server.com'
|
||||||
|
const jobsPath = '/SASStoredProcess/do'
|
||||||
|
let client: SAS9ApiClient
|
||||||
|
let mockRequestClient: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new SAS9ApiClient(serverUrl, jobsPath)
|
||||||
|
// Retrieve the instance of the mocked Sas9RequestClient
|
||||||
|
mockRequestClient = (Sas9RequestClient as jest.Mock).mock.results[0].value
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getConfig', () => {
|
||||||
|
it('should return the correct configuration', () => {
|
||||||
|
const config = client.getConfig()
|
||||||
|
expect(config).toEqual({ serverUrl })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setConfig', () => {
|
||||||
|
it('should update the serverUrl when a valid value is provided', () => {
|
||||||
|
const newUrl = 'http://new-server.com'
|
||||||
|
client.setConfig(newUrl)
|
||||||
|
expect(client.getConfig()).toEqual({ serverUrl: newUrl })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not update the serverUrl when an empty string is provided', () => {
|
||||||
|
const originalConfig = client.getConfig()
|
||||||
|
client.setConfig('')
|
||||||
|
expect(client.getConfig()).toEqual(originalConfig)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeScript', () => {
|
||||||
|
const linesOfCode = ['line1;', 'line2;']
|
||||||
|
const userName = 'testUser'
|
||||||
|
const password = 'testPass'
|
||||||
|
const fixedTimestamp = '1234567890'
|
||||||
|
const expectedFilename = `sasjs-execute-sas9-${fixedTimestamp}.sas`
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Stub generateTimestamp so that we get a consistent filename in our tests.
|
||||||
|
jest
|
||||||
|
.spyOn(require('@sasjs/utils/time'), 'generateTimestamp')
|
||||||
|
.mockReturnValue(fixedTimestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute the script and return the result', async () => {
|
||||||
|
const result = await client.executeScript(linesOfCode, userName, password)
|
||||||
|
|
||||||
|
// Verify that login is called with the correct parameters.
|
||||||
|
expect(mockRequestClient.login).toHaveBeenCalledWith(
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
jobsPath
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build the expected stored process URL.
|
||||||
|
const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner`
|
||||||
|
const expectedUrl =
|
||||||
|
`${jobsPath}/?` + '_program=' + codeInjectorPath + '&_debug=log'
|
||||||
|
|
||||||
|
// Verify that post was called with the expected stored process URL.
|
||||||
|
expect(mockRequestClient.post).toHaveBeenCalledWith(
|
||||||
|
expectedUrl,
|
||||||
|
expect.any(NodeFormData),
|
||||||
|
undefined,
|
||||||
|
expect.stringContaining('multipart/form-data; boundary='),
|
||||||
|
expect.objectContaining({
|
||||||
|
'Content-Length': expect.any(Number),
|
||||||
|
'Content-Type': expect.stringContaining(
|
||||||
|
'multipart/form-data; boundary='
|
||||||
|
),
|
||||||
|
Accept: '*/*'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// The method should return the result from the post call.
|
||||||
|
expect(result).toEqual('execution result')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include the force output code in the uploaded form data', async () => {
|
||||||
|
await client.executeScript(linesOfCode, userName, password)
|
||||||
|
// Retrieve the form data passed to post
|
||||||
|
const postCallArgs = (mockRequestClient.post as jest.Mock).mock.calls[0]
|
||||||
|
const formData: NodeFormData = postCallArgs[1]
|
||||||
|
|
||||||
|
// We can inspect the boundary and ensure that the filename was generated correctly.
|
||||||
|
expect(formData.getBoundary()).toBeDefined()
|
||||||
|
|
||||||
|
// The filename is used as the key for the form field.
|
||||||
|
const formDataBuffer = formData.getBuffer().toString()
|
||||||
|
expect(formDataBuffer).toContain(expectedFilename)
|
||||||
|
// Also check that the force output code is appended.
|
||||||
|
expect(formDataBuffer).toContain("put 'Executed sasjs run';")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
231
src/spec/SASjsApiClient.spec.ts
Normal file
231
src/spec/SASjsApiClient.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import NodeFormData from 'form-data'
|
||||||
|
import {
|
||||||
|
SASjsApiClient,
|
||||||
|
SASjsAuthResponse,
|
||||||
|
ScriptExecutionResult
|
||||||
|
} from '../SASjsApiClient'
|
||||||
|
import { AuthConfig, ServicePackSASjs } from '@sasjs/utils/types'
|
||||||
|
import { ExecutionQuery } from '../types'
|
||||||
|
|
||||||
|
// Create a mock request client with a post method.
|
||||||
|
const mockPost = jest.fn()
|
||||||
|
const mockRequestClient = {
|
||||||
|
post: mockPost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instead of referencing external variables, inline the dummy values in the mock factories.
|
||||||
|
jest.mock('../auth/getTokens', () => ({
|
||||||
|
getTokens: jest.fn().mockResolvedValue({ access_token: 'dummyAccessToken' })
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('../auth/getAccessTokenForSasjs', () => ({
|
||||||
|
getAccessTokenForSasjs: jest.fn().mockResolvedValue({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
} as any)
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('../auth/refreshTokensForSasjs', () => ({
|
||||||
|
refreshTokensForSasjs: jest.fn().mockResolvedValue({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
} as any)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// For deployZipFile, mock the file reading function.
|
||||||
|
jest.mock('@sasjs/utils/file', () => ({
|
||||||
|
createReadStream: jest.fn().mockResolvedValue('readStreamDummy')
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Dummy result to compare against.
|
||||||
|
const dummyResult = {
|
||||||
|
status: 'OK',
|
||||||
|
message: 'Success',
|
||||||
|
streamServiceName: 'service',
|
||||||
|
example: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SASjsApiClient', () => {
|
||||||
|
let client: SASjsApiClient
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new SASjsApiClient(mockRequestClient as any)
|
||||||
|
mockPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deploy', () => {
|
||||||
|
it('should deploy service pack using JSON', async () => {
|
||||||
|
// Arrange: Simulate a successful response.
|
||||||
|
mockPost.mockResolvedValue({ result: dummyResult })
|
||||||
|
|
||||||
|
const dataJson: ServicePackSASjs = {
|
||||||
|
appLoc: '',
|
||||||
|
someOtherProp: 'value'
|
||||||
|
} as any
|
||||||
|
const appLoc = '/base/appLoc'
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
client: 'clientId',
|
||||||
|
secret: 'secret',
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await client.deploy(dataJson, appLoc, authConfig)
|
||||||
|
|
||||||
|
// Assert: Ensure that the JSON gets the appLoc set if not defined.
|
||||||
|
expect(dataJson.appLoc).toBe(appLoc)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/drive/deploy',
|
||||||
|
dataJson,
|
||||||
|
'dummyAccessToken',
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ maxContentLength: Infinity, maxBodyLength: Infinity }
|
||||||
|
)
|
||||||
|
expect(result).toEqual(dummyResult)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deployZipFile', () => {
|
||||||
|
it('should deploy zip file and return the result', async () => {
|
||||||
|
// Arrange: Simulate a successful response.
|
||||||
|
mockPost.mockResolvedValue({ result: dummyResult })
|
||||||
|
const zipFilePath = 'path/to/deploy.zip'
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
client: 'clientId',
|
||||||
|
secret: 'secret',
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await client.deployZipFile(zipFilePath, authConfig)
|
||||||
|
|
||||||
|
// Assert: Verify that POST is called with multipart form-data.
|
||||||
|
expect(mockPost).toHaveBeenCalled()
|
||||||
|
const callArgs = mockPost.mock.calls[0]
|
||||||
|
expect(callArgs[0]).toBe('SASjsApi/drive/deploy/upload')
|
||||||
|
expect(result).toEqual(dummyResult)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeJob', () => {
|
||||||
|
it('should execute a job with absolute program path', async () => {
|
||||||
|
// Arrange
|
||||||
|
const query: ExecutionQuery = { _program: '/absolute/path' } as any
|
||||||
|
const appLoc = '/base/appLoc'
|
||||||
|
const authConfig: AuthConfig = { access_token: 'anyToken' } as any
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
result: { jobId: 123 },
|
||||||
|
log: 'execution log'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { result, log } = await client.executeJob(query, appLoc, authConfig)
|
||||||
|
|
||||||
|
// Assert: The program path should not be prefixed.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/stp/execute',
|
||||||
|
{ _debug: 131, ...query, _program: '/absolute/path' },
|
||||||
|
'anyToken'
|
||||||
|
)
|
||||||
|
expect(result).toEqual({ jobId: 123 })
|
||||||
|
expect(log).toBe('execution log')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute a job with relative program path', async () => {
|
||||||
|
// Arrange
|
||||||
|
const query: ExecutionQuery = { _program: 'relative/path' } as any
|
||||||
|
const appLoc = '/base/appLoc'
|
||||||
|
mockPost.mockResolvedValue({ result: { jobId: 456 }, log: 'another log' })
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { result, log } = await client.executeJob(query, appLoc)
|
||||||
|
|
||||||
|
// Assert: The program path should be prefixed with appLoc.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/stp/execute',
|
||||||
|
{ _debug: 131, ...query, _program: '/base/appLoc/relative/path' },
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
expect(result).toEqual({ jobId: 456 })
|
||||||
|
expect(log).toBe('another log')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeScript', () => {
|
||||||
|
it('should execute a script and return the execution result', async () => {
|
||||||
|
// Arrange
|
||||||
|
const code = 'data _null_; run;'
|
||||||
|
const runTime = 'sas'
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
client: 'clientId',
|
||||||
|
secret: 'secret',
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh'
|
||||||
|
}
|
||||||
|
const responsePayload = {
|
||||||
|
log: 'log output',
|
||||||
|
printOutput: 'print output',
|
||||||
|
result: 'web output'
|
||||||
|
}
|
||||||
|
mockPost.mockResolvedValue(responsePayload)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result: ScriptExecutionResult = await client.executeScript(
|
||||||
|
code,
|
||||||
|
runTime,
|
||||||
|
authConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/code/execute',
|
||||||
|
{ code, runTime },
|
||||||
|
'dummyAccessToken'
|
||||||
|
)
|
||||||
|
expect(result.log).toBe('log output')
|
||||||
|
expect(result.printOutput).toBe('print output')
|
||||||
|
expect(result.webout).toBe('web output')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error with a prefixed message when POST fails', async () => {
|
||||||
|
// Arrange
|
||||||
|
const code = 'data _null_; run;'
|
||||||
|
const errorMessage = 'Network Error'
|
||||||
|
mockPost.mockRejectedValue(new Error(errorMessage))
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(client.executeScript(code)).rejects.toThrow(
|
||||||
|
/Error while sending POST request to execute code/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAccessToken', () => {
|
||||||
|
it('should exchange auth code for access token', async () => {
|
||||||
|
// Act
|
||||||
|
const result = await client.getAccessToken('clientId', 'authCode123')
|
||||||
|
|
||||||
|
// Assert: The result should match the dummy auth response.
|
||||||
|
expect(result).toEqual({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refreshTokens', () => {
|
||||||
|
it('should exchange refresh token for new tokens', async () => {
|
||||||
|
// Act
|
||||||
|
const result = await client.refreshTokens('refreshToken123')
|
||||||
|
|
||||||
|
// Assert: The result should match the dummy auth response.
|
||||||
|
expect(result).toEqual({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,39 +2,42 @@ import * as pem from 'pem'
|
|||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { app, mockedAuthResponse } from './SAS_server_app'
|
import { app, mockedAuthResponse } from './SAS_server_app'
|
||||||
import { ServerType } from '@sasjs/utils'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import SASjs from '../SASjs'
|
import SASjs from '../SASjs'
|
||||||
import * as axiosModules from '../utils/createAxiosInstance'
|
import * as axiosModules from '../utils/createAxiosInstance'
|
||||||
|
import axios, { AxiosRequestHeaders } from 'axios'
|
||||||
import {
|
import {
|
||||||
LoginRequiredError,
|
LoginRequiredError,
|
||||||
AuthorizeError,
|
AuthorizeError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
InternalServerError
|
InternalServerError,
|
||||||
} from '../types/errors'
|
VerboseMode
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
} from '../types'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
|
import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix'
|
||||||
|
import { AxiosResponse, AxiosError } from 'axios'
|
||||||
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
|
import * as UtilsModule from 'util'
|
||||||
|
|
||||||
const axiosActual = jest.requireActual('axios')
|
const axiosActual = jest.requireActual('axios')
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(axiosModules, 'createAxiosInstance')
|
.spyOn(axiosModules, 'createAxiosInstance')
|
||||||
.mockImplementation((baseURL: string, httpsAgent?: https.Agent) =>
|
.mockImplementation((baseURL: string, httpsAgent?: https.Agent) =>
|
||||||
axiosActual.create({ baseURL, httpsAgent })
|
axiosActual.create({ baseURL, httpsAgent, withXSRFToken: true })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
jest.mock('util', () => {
|
||||||
|
const actualUtil = jest.requireActual('util')
|
||||||
|
return {
|
||||||
|
...actualUtil,
|
||||||
|
inspect: jest.fn(actualUtil.inspect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const PORT = 8000
|
const PORT = 8000
|
||||||
const SERVER_URL = `https://localhost:${PORT}/`
|
const SERVER_URL = `https://localhost:${PORT}/`
|
||||||
|
|
||||||
const ERROR_MESSAGES = {
|
|
||||||
selfSigned: 'self signed certificate',
|
|
||||||
CCA: 'unable to verify the first certificate'
|
|
||||||
}
|
|
||||||
|
|
||||||
const incorrectAuthCodeErr = {
|
|
||||||
error: 'unauthorized',
|
|
||||||
error_description: 'Bad credentials'
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RequestClient', () => {
|
describe('RequestClient', () => {
|
||||||
let server: http.Server
|
let server: http.Server
|
||||||
|
|
||||||
@@ -66,14 +69,425 @@ describe('RequestClient', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should response the POST method with Unauthorized', async () => {
|
it('should response the POST method with Unauthorized', async () => {
|
||||||
await expect(
|
const expectedError = new LoginRequiredError({
|
||||||
adapter.getAccessToken('clientId', 'clientSecret', 'incorrect')
|
error: 'unauthorized',
|
||||||
).rejects.toEqual(
|
error_description: 'Bad credentials'
|
||||||
prefixMessage(
|
})
|
||||||
new LoginRequiredError(incorrectAuthCodeErr),
|
|
||||||
'Error while getting access token. '
|
const rejectionErrorMessage = await adapter
|
||||||
|
.getAccessToken('clientId', 'clientSecret', 'incorrect')
|
||||||
|
.catch((err) =>
|
||||||
|
getTokenRequestErrorPrefixResponse(err.message, ServerType.SasViya)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
expect(rejectionErrorMessage).toEqual(expectedError.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('defaultInterceptionCallBacks for successful requests and failed requests', () => {
|
||||||
|
const reqHeaders = `POST https://sas.server.com/compute/sessions/session_id/jobs HTTP/1.1
|
||||||
|
Accept: application/json
|
||||||
|
Content-Type: application/json
|
||||||
|
User-Agent: axios/0.27.2
|
||||||
|
Content-Length: 334
|
||||||
|
host: sas.server.io
|
||||||
|
Connection: close
|
||||||
|
`
|
||||||
|
const reqData = `{
|
||||||
|
name: 'test_job',
|
||||||
|
description: 'Powered by SASjs',
|
||||||
|
code: ['test_code'],
|
||||||
|
variables: {
|
||||||
|
SYS_JES_JOB_URI: '',
|
||||||
|
_program: '/Public/sasjs/jobs/jobs/test_job'
|
||||||
|
},
|
||||||
|
arguments: {
|
||||||
|
_contextName: 'SAS Job Execution compute context',
|
||||||
|
_OMITJSONLISTING: true,
|
||||||
|
_OMITJSONLOG: true,
|
||||||
|
_omitSessionResults: true,
|
||||||
|
_OMITTEXTLISTING: true,
|
||||||
|
_OMITTEXTLOG: true
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const resHeaders = ['content-type', 'application/json']
|
||||||
|
const resData = {
|
||||||
|
id: 'id_string',
|
||||||
|
name: 'name_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
createdBy: 'createdBy_string',
|
||||||
|
code: 'TEST CODE',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'self',
|
||||||
|
href: 'self_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
results: { '_webout.json': '_webout.json_string' },
|
||||||
|
logStatistics: {
|
||||||
|
lineCount: 1,
|
||||||
|
modifiedTimeStamp: 'modifiedTimeStamp_string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beforeAll(() => {
|
||||||
|
;(process as any).logger = new Logger(LogLevel.Off)
|
||||||
|
jest.spyOn((process as any).logger, 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 1**', () => {
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const noValueMessage = 'Not provided'
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${noValueMessage}
|
||||||
|
\n${requestClient['parseInterceptedBody'](noValueMessage)}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 2**', () => {
|
||||||
|
const status = getRandomStatus([
|
||||||
|
200, 201, 202, 203, 204, 205, 206, 207, 208, 226
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockedResponse: AxiosResponse = {
|
||||||
|
data: resData,
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
data: reqData,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosResponse'](mockedResponse)
|
||||||
|
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders[0]}: ${resHeaders[1]}${
|
||||||
|
requestClient['parseInterceptedBody'](resData)
|
||||||
|
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 3**', () => {
|
||||||
|
const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308])
|
||||||
|
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const noValueMessage = 'Not provided'
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${noValueMessage}
|
||||||
|
\n${requestClient['parseInterceptedBody'](noValueMessage)}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 4**', () => {
|
||||||
|
const spyIsAxiosError = jest
|
||||||
|
.spyOn(axios, 'isAxiosError')
|
||||||
|
.mockImplementation(() => true)
|
||||||
|
|
||||||
|
const status = getRandomStatus([
|
||||||
|
400, 401, 402, 403, 404, 407, 408, 409, 410, 411, 412, 413, 414, 415,
|
||||||
|
416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockedResponse: AxiosResponse = {
|
||||||
|
data: resData,
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
data: reqData,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||||
|
}
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: mockedResponse
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders[0]}: ${resHeaders[1]}${
|
||||||
|
requestClient['parseInterceptedBody'](resData)
|
||||||
|
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
|
||||||
|
spyIsAxiosError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 5**', () => {
|
||||||
|
const spyIsAxiosError = jest
|
||||||
|
.spyOn(axios, 'isAxiosError')
|
||||||
|
.mockImplementation(() => true)
|
||||||
|
|
||||||
|
const status = getRandomStatus([
|
||||||
|
500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockedResponse: AxiosResponse = {
|
||||||
|
data: resData,
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
data: reqData,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||||
|
}
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: mockedResponse
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders[0]}: ${resHeaders[1]}${
|
||||||
|
requestClient['parseInterceptedBody'](resData)
|
||||||
|
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
|
||||||
|
spyIsAxiosError.mockReset()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enableVerboseMode', () => {
|
||||||
|
it('should add defaultInterceptionCallBack functions to response interceptors', () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
const interceptorSpy = jest.spyOn(
|
||||||
|
requestClient['httpClient'].interceptors.response,
|
||||||
|
'use'
|
||||||
|
)
|
||||||
|
|
||||||
|
requestClient.enableVerboseMode()
|
||||||
|
|
||||||
|
expect(interceptorSpy).toHaveBeenCalledWith(
|
||||||
|
requestClient['handleAxiosResponse'],
|
||||||
|
requestClient['handleAxiosError']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add callback functions to response interceptors', () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
const interceptorSpy = jest.spyOn(
|
||||||
|
requestClient['httpClient'].interceptors.response,
|
||||||
|
'use'
|
||||||
|
)
|
||||||
|
|
||||||
|
const successCallback = (response: AxiosResponse) => {
|
||||||
|
console.log('success')
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
const failureCallback = (response: AxiosError) => {
|
||||||
|
console.log('failure')
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
requestClient.enableVerboseMode(successCallback, failureCallback)
|
||||||
|
|
||||||
|
expect(interceptorSpy).toHaveBeenCalledWith(
|
||||||
|
successCallback,
|
||||||
|
failureCallback
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setVerboseMode', () => {
|
||||||
|
it(`should set verbose mode`, () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
let verbose: VerboseMode = false
|
||||||
|
requestClient.setVerboseMode(verbose)
|
||||||
|
|
||||||
|
expect(requestClient['verboseMode']).toEqual(verbose)
|
||||||
|
|
||||||
|
verbose = true
|
||||||
|
requestClient.setVerboseMode(verbose)
|
||||||
|
|
||||||
|
expect(requestClient['verboseMode']).toEqual(verbose)
|
||||||
|
|
||||||
|
verbose = 'bleached'
|
||||||
|
requestClient.setVerboseMode(verbose)
|
||||||
|
|
||||||
|
expect(requestClient['verboseMode']).toEqual(verbose)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prettifyString', () => {
|
||||||
|
const inspectMock = UtilsModule.inspect as unknown as jest.Mock
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the mock before each test to ensure a clean slate
|
||||||
|
inspectMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`should call inspect without colors when verbose mode is set to 'bleached'`, () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient.setVerboseMode('bleached')
|
||||||
|
|
||||||
|
const testStr = JSON.stringify({ test: 'test' })
|
||||||
|
requestClient['prettifyString'](testStr)
|
||||||
|
|
||||||
|
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
|
||||||
|
colors: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`should call inspect with colors when verbose mode is set to true`, () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient.setVerboseMode(true)
|
||||||
|
|
||||||
|
const testStr = JSON.stringify({ test: 'test' })
|
||||||
|
requestClient['prettifyString'](testStr)
|
||||||
|
|
||||||
|
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
|
||||||
|
colors: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disableVerboseMode', () => {
|
||||||
|
it('should eject interceptor', () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
|
||||||
|
const interceptorSpy = jest.spyOn(
|
||||||
|
requestClient['httpClient'].interceptors.response,
|
||||||
|
'eject'
|
||||||
|
)
|
||||||
|
|
||||||
|
const interceptorId = 100
|
||||||
|
|
||||||
|
requestClient['httpInterceptor'] = interceptorId
|
||||||
|
requestClient.disableVerboseMode()
|
||||||
|
|
||||||
|
expect(interceptorSpy).toHaveBeenCalledWith(interceptorId)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('handleError', () => {
|
describe('handleError', () => {
|
||||||
@@ -209,15 +623,15 @@ describe('RequestClient - Self Signed Server', () => {
|
|||||||
serverType: ServerType.SasViya
|
serverType: ServerType.SasViya
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
const expectedError = 'self-signed certificate'
|
||||||
adapterWithoutCertificate.getAccessToken(
|
|
||||||
'clientId',
|
const rejectionErrorMessage = await adapterWithoutCertificate
|
||||||
'clientSecret',
|
.getAccessToken('clientId', 'clientSecret', 'authCode')
|
||||||
'authCode'
|
.catch((err) =>
|
||||||
|
getTokenRequestErrorPrefixResponse(err.message, ServerType.SasViya)
|
||||||
)
|
)
|
||||||
).rejects.toThrow(
|
|
||||||
`Error while getting access token. ${ERROR_MESSAGES.selfSigned}`
|
expect(rejectionErrorMessage).toEqual(expectedError)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should response the POST method using insecure flag', async () => {
|
it('should response the POST method using insecure flag', async () => {
|
||||||
@@ -247,14 +661,18 @@ describe('RequestClient - Self Signed Server', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should response the POST method with Unauthorized', async () => {
|
it('should response the POST method with Unauthorized', async () => {
|
||||||
await expect(
|
const expectedError = new LoginRequiredError({
|
||||||
adapter.getAccessToken('clientId', 'clientSecret', 'incorrect')
|
error: 'unauthorized',
|
||||||
).rejects.toEqual(
|
error_description: 'Bad credentials'
|
||||||
prefixMessage(
|
})
|
||||||
new LoginRequiredError(incorrectAuthCodeErr),
|
|
||||||
'Error while getting access token. '
|
const rejectionErrorMessage = await adapter
|
||||||
|
.getAccessToken('clientId', 'clientSecret', 'incorrect')
|
||||||
|
.catch((err) =>
|
||||||
|
getTokenRequestErrorPrefixResponse(err.message, ServerType.SasViya)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
expect(rejectionErrorMessage).toEqual(expectedError.message)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -285,3 +703,11 @@ const createCertificate = async (): Promise<pem.CertificateCreationResult> => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random status code.
|
||||||
|
* @param statuses - an array of available statuses.
|
||||||
|
* @returns - random item from an array of statuses.
|
||||||
|
*/
|
||||||
|
const getRandomStatus = (statuses: number[]) =>
|
||||||
|
statuses[Math.floor(Math.random() * statuses.length)]
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import express = require('express')
|
import express = require('express')
|
||||||
|
import cors from 'cors'
|
||||||
|
|
||||||
export const app = express()
|
export const app = express()
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: 'http://localhost', // Allow requests only from this origin
|
||||||
|
credentials: true // Allow credentials (cookies, auth headers, etc.)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const mockedAuthResponse = {
|
export const mockedAuthResponse = {
|
||||||
access_token: 'access_token',
|
access_token: 'access_token',
|
||||||
token_type: 'bearer',
|
token_type: 'bearer',
|
||||||
@@ -12,11 +20,11 @@ export const mockedAuthResponse = {
|
|||||||
jti: 'jti'
|
jti: 'jti'
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', function (req: any, res: any) {
|
app.get('/', (req: any, res: any) => {
|
||||||
res.send('Hello World')
|
res.send('Hello World')
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/SASLogon/oauth/token', function (req: any, res: any) {
|
app.post('/SASLogon/oauth/token', (req: any, res: any) => {
|
||||||
let valid = true
|
let valid = true
|
||||||
|
|
||||||
// capture the encoded form data
|
// capture the encoded form data
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { SessionManager } from '../SessionManager'
|
|||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
import * as dotenv from 'dotenv'
|
import * as dotenv from 'dotenv'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Logger, LogLevel } from '@sasjs/utils'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
import { Session, Context } from '../types'
|
import { Session, SessionState, Context } from '../types'
|
||||||
|
|
||||||
jest.mock('axios')
|
jest.mock('axios')
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
@@ -11,21 +11,34 @@ const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
|||||||
|
|
||||||
describe('SessionManager', () => {
|
describe('SessionManager', () => {
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
process.env.SERVER_URL = 'https://server.com'
|
||||||
|
|
||||||
const sessionManager = new SessionManager(
|
const sessionManager = new SessionManager(
|
||||||
process.env.SERVER_URL as string,
|
process.env.SERVER_URL as string,
|
||||||
process.env.DEFAULT_COMPUTE_CONTEXT as string,
|
process.env.DEFAULT_COMPUTE_CONTEXT as string,
|
||||||
requestClient
|
requestClient
|
||||||
)
|
)
|
||||||
|
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
|
||||||
|
const sessionEtag = 'etag-string'
|
||||||
|
|
||||||
const getMockSession = () => ({
|
const getMockSession = (): Session => ({
|
||||||
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
||||||
state: '',
|
state: SessionState.NoState,
|
||||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
links: [
|
||||||
|
{
|
||||||
|
href: sessionStateLink,
|
||||||
|
method: 'GET',
|
||||||
|
rel: 'state',
|
||||||
|
type: 'text/plain',
|
||||||
|
uri: sessionStateLink
|
||||||
|
}
|
||||||
|
],
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: 900
|
sessionInactiveTimeout: 900
|
||||||
},
|
},
|
||||||
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`
|
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
|
||||||
|
stateUrl: sessionStateLink,
|
||||||
|
etag: sessionEtag
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -89,19 +102,21 @@ describe('SessionManager', () => {
|
|||||||
describe('waitForSession', () => {
|
describe('waitForSession', () => {
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
state: '',
|
state: SessionState.NoState,
|
||||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: 0
|
sessionInactiveTimeout: 0
|
||||||
},
|
},
|
||||||
creationTimeStamp: ''
|
creationTimeStamp: '',
|
||||||
|
stateUrl: sessionStateLink,
|
||||||
|
etag: sessionEtag
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
;(process as any).logger = new Logger(LogLevel.Off)
|
;(process as any).logger = new Logger(LogLevel.Off)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should reject with NoSessionStateError if SAS server did not provide session state', async () => {
|
it('should log http response code and session state if SAS server did not provide session state', async () => {
|
||||||
let requestAttempt = 0
|
let requestAttempt = 0
|
||||||
const requestAttemptLimit = 10
|
const requestAttemptLimit = 10
|
||||||
const sessionState = 'idle'
|
const sessionState = 'idle'
|
||||||
@@ -124,15 +139,17 @@ describe('SessionManager', () => {
|
|||||||
sessionManager['waitForSession'](session, null, 'access_token')
|
sessionManager['waitForSession'](session, null, 'access_token')
|
||||||
).resolves.toEqual(sessionState)
|
).resolves.toEqual(sessionState)
|
||||||
|
|
||||||
|
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
|
||||||
|
|
||||||
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
|
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
|
||||||
expect((process as any).logger.info).toHaveBeenCalledTimes(3)
|
expect((process as any).logger.info).toHaveBeenCalledTimes(3)
|
||||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
`Polling: ${process.env.SERVER_URL}`
|
`Polling: ${sessionStateUrl}`
|
||||||
)
|
)
|
||||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
`Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}`
|
`Could not get session state. Server responded with 304 whilst checking state: ${sessionStateUrl}`
|
||||||
)
|
)
|
||||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||||
3,
|
3,
|
||||||
@@ -142,7 +159,7 @@ describe('SessionManager', () => {
|
|||||||
|
|
||||||
it('should throw an error if there is no session link', async () => {
|
it('should throw an error if there is no session link', async () => {
|
||||||
const customSession = JSON.parse(JSON.stringify(session))
|
const customSession = JSON.parse(JSON.stringify(session))
|
||||||
customSession.links = []
|
customSession.stateUrl = ''
|
||||||
|
|
||||||
mockedAxios.get.mockImplementation(() =>
|
mockedAxios.get.mockImplementation(() =>
|
||||||
Promise.resolve({ data: customSession.state, status: 200 })
|
Promise.resolve({ data: customSession.state, status: 200 })
|
||||||
@@ -156,6 +173,7 @@ describe('SessionManager', () => {
|
|||||||
it('should throw an error if could not get session state', async () => {
|
it('should throw an error if could not get session state', async () => {
|
||||||
const gettingSessionStatus = 500
|
const gettingSessionStatus = 500
|
||||||
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
|
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
|
||||||
|
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
|
||||||
|
|
||||||
mockedAxios.get.mockImplementation(() =>
|
mockedAxios.get.mockImplementation(() =>
|
||||||
Promise.reject({
|
Promise.reject({
|
||||||
@@ -168,7 +186,7 @@ describe('SessionManager', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
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}`
|
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${sessionStateUrl}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sessionManager['waitForSession'](session, null, 'access_token')
|
sessionManager['waitForSession'](session, null, 'access_token')
|
||||||
@@ -427,4 +445,45 @@ describe('SessionManager', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createAndWaitForSession', () => {
|
||||||
|
it('should create session with etag and stateUrl', async () => {
|
||||||
|
const etag = sessionEtag
|
||||||
|
const customSession: any = getMockSession()
|
||||||
|
delete customSession.etag
|
||||||
|
delete customSession.stateUrl
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'post').mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
result: customSession,
|
||||||
|
etag
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'setCurrentContext')
|
||||||
|
.mockImplementation(() => Promise.resolve())
|
||||||
|
|
||||||
|
sessionManager['currentContext'] = {
|
||||||
|
name: 'context name',
|
||||||
|
id: 'string',
|
||||||
|
createdBy: 'string',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve({ result: SessionState.Idle, responseStatus: 200 })
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedSession = await sessionManager['createAndWaitForSession']()
|
||||||
|
|
||||||
|
expect(customSession.id).toEqual(expectedSession.id)
|
||||||
|
expect(
|
||||||
|
customSession.links.find((l: any) => l.rel === 'state').href
|
||||||
|
).toEqual(expectedSession.stateUrl)
|
||||||
|
expect(expectedSession.etag).toEqual(etag)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
33
src/types/FileResource.ts
Normal file
33
src/types/FileResource.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface FileResource {
|
||||||
|
creationTimeStamp: string
|
||||||
|
modifiedTimeStamp: string
|
||||||
|
createdBy: string
|
||||||
|
modifiedBy: string
|
||||||
|
id: string
|
||||||
|
properties: Properties
|
||||||
|
contentDisposition: string
|
||||||
|
contentType: string
|
||||||
|
encoding: string
|
||||||
|
links: Link[]
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
searchable: boolean
|
||||||
|
fileStatus: string
|
||||||
|
fileVersion: number
|
||||||
|
typeDefName: string
|
||||||
|
version: number
|
||||||
|
virusDetected: boolean
|
||||||
|
urlDetected: boolean
|
||||||
|
quarantine: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
method: string
|
||||||
|
rel: string
|
||||||
|
href: string
|
||||||
|
uri: string
|
||||||
|
type?: string
|
||||||
|
responseType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Properties {}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export interface PollOptions {
|
export interface PollOptions {
|
||||||
maxPollCount: number
|
maxPollCount: number
|
||||||
pollInterval: number
|
pollInterval: number // milliseconds
|
||||||
streamLog: boolean
|
pollStrategy?: PollStrategy
|
||||||
|
streamLog?: boolean
|
||||||
logFolderPath?: string
|
logFolderPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PollStrategy = PollOptions[]
|
||||||
|
|||||||
55
src/types/RequestClient.ts
Normal file
55
src/types/RequestClient.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { CsrfToken } from '..'
|
||||||
|
|
||||||
|
export interface HttpClient {
|
||||||
|
get<T>(
|
||||||
|
url: string,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
contentType: string,
|
||||||
|
overrideHeaders: { [key: string]: string | number }
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
post<T>(
|
||||||
|
url: string,
|
||||||
|
data: any,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
contentType: string,
|
||||||
|
overrideHeaders: { [key: string]: string | number }
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
put<T>(
|
||||||
|
url: string,
|
||||||
|
data: any,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
overrideHeaders: { [key: string]: string | number }
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
delete<T>(
|
||||||
|
url: string,
|
||||||
|
accessToken: string | undefined
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
|
||||||
|
saveLocalStorageToken(accessToken: string, refreshToken: string): void
|
||||||
|
clearCsrfTokens(): void
|
||||||
|
clearLocalStorageTokens(): void
|
||||||
|
getBaseUrl(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SASjsRequest {
|
||||||
|
serviceLink: string
|
||||||
|
timestamp: Date
|
||||||
|
sourceCode: string
|
||||||
|
generatedCode: string
|
||||||
|
logFile: string
|
||||||
|
SASWORK: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SasjsParsedResponse<T> {
|
||||||
|
result: T
|
||||||
|
log: string
|
||||||
|
etag: string
|
||||||
|
status: number
|
||||||
|
printOutput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerboseMode = boolean | 'bleached'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import { VerboseMode } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specifies the configuration for the SASjs instance - eg where and how to
|
* Specifies the configuration for the SASjs instance - eg where and how to
|
||||||
@@ -45,6 +46,10 @@ export class SASjsConfig {
|
|||||||
* Set to `true` to enable additional debugging.
|
* Set to `true` to enable additional debugging.
|
||||||
*/
|
*/
|
||||||
debug: boolean = true
|
debug: boolean = true
|
||||||
|
/**
|
||||||
|
* Set to `true` to enable verbose mode that will log a summary of every HTTP response.
|
||||||
|
*/
|
||||||
|
verbose?: VerboseMode = 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 services directly.
|
||||||
* Example value: 'SAS Job Execution compute context'
|
* Example value: 'SAS Job Execution compute context'
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Represents a SASjs request, its response and logs.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export interface SASjsRequest {
|
|
||||||
serviceLink: string
|
|
||||||
timestamp: Date
|
|
||||||
sourceCode: string
|
|
||||||
generatedCode: string
|
|
||||||
logFile: string
|
|
||||||
SASWORK: any
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,34 @@
|
|||||||
import { Link } from './Link'
|
import { Link } from './Link'
|
||||||
|
import { SessionManager } from '../SessionManager'
|
||||||
|
|
||||||
|
export enum SessionState {
|
||||||
|
Completed = 'completed',
|
||||||
|
Running = 'running',
|
||||||
|
Pending = 'pending',
|
||||||
|
Idle = 'idle',
|
||||||
|
Unavailable = 'unavailable',
|
||||||
|
NoState = '',
|
||||||
|
Failed = 'failed',
|
||||||
|
Error = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string
|
id: string
|
||||||
state: string
|
state: SessionState
|
||||||
|
stateUrl: string
|
||||||
links: Link[]
|
links: Link[]
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: number
|
sessionInactiveTimeout: number
|
||||||
}
|
}
|
||||||
creationTimeStamp: string
|
creationTimeStamp: string
|
||||||
|
etag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionVariable {
|
export interface SessionVariable {
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JobSessionManager {
|
||||||
|
session: Session
|
||||||
|
sessionManager: SessionManager
|
||||||
|
}
|
||||||
|
|||||||
28
src/types/Tables.spec.ts
Normal file
28
src/types/Tables.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import SASjs from '../SASjs'
|
||||||
|
|
||||||
|
describe('Tables - basic coverage', () => {
|
||||||
|
const adapter = new SASjs()
|
||||||
|
|
||||||
|
it('should throw an error if first argument is not an array', () => {
|
||||||
|
expect(() => adapter.Tables({}, 'test')).toThrow('First argument')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if second argument is not a string', () => {
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => adapter.Tables([], 1234)).toThrow('Second argument')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if macro name ends with a number', () => {
|
||||||
|
expect(() => adapter.Tables([], 'test1')).toThrow('number at the end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if no arguments are passed', () => {
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => adapter.Tables()).toThrow('Missing arguments')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create Tables class successfully with _tables property', () => {
|
||||||
|
const tables = adapter.Tables([], 'test')
|
||||||
|
expect(tables).toHaveProperty('_tables')
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/types/Tables.ts
Normal file
29
src/types/Tables.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ArgumentError } from './errors'
|
||||||
|
|
||||||
|
export class Tables {
|
||||||
|
_tables: { [macroName: string]: Record<string, any> }
|
||||||
|
|
||||||
|
constructor(table: Record<string, any>, macroName: string) {
|
||||||
|
this._tables = {}
|
||||||
|
|
||||||
|
this.add(table, macroName)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(table: Record<string, any> | null, macroName: string) {
|
||||||
|
if (table && macroName) {
|
||||||
|
if (!(table instanceof Array)) {
|
||||||
|
throw new ArgumentError('First argument must be array')
|
||||||
|
}
|
||||||
|
if (typeof macroName !== 'string') {
|
||||||
|
throw new ArgumentError('Second argument must be string')
|
||||||
|
}
|
||||||
|
if (!isNaN(Number(macroName[macroName.length - 1]))) {
|
||||||
|
throw new ArgumentError('Macro name cannot have number at the end')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ArgumentError('Missing arguments')
|
||||||
|
}
|
||||||
|
|
||||||
|
this._tables[macroName] = table
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
export interface WriteStream {
|
import { WriteStream as FsWriteStream } from 'fs'
|
||||||
write: (content: string, callback: (err?: Error) => any) => void
|
|
||||||
path: string
|
export interface WriteStream extends FsWriteStream {
|
||||||
|
write(
|
||||||
|
chunk: any,
|
||||||
|
encoding?: BufferEncoding | ((error: Error | null | undefined) => void),
|
||||||
|
cb?: (error: Error | null | undefined) => void
|
||||||
|
): boolean
|
||||||
|
path: string | Buffer
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/types/errors/ArgumentError.ts
Normal file
7
src/types/errors/ArgumentError.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class ArgumentError extends Error {
|
||||||
|
constructor(public message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ArgumentError'
|
||||||
|
Object.setPrototypeOf(this, ArgumentError.prototype)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ describe('RootFolderNotFoundError', () => {
|
|||||||
|
|
||||||
const error = new RootFolderNotFoundError(
|
const error = new RootFolderNotFoundError(
|
||||||
'/myProject',
|
'/myProject',
|
||||||
'https://analytium.co.uk',
|
'https://sas.4gl.io',
|
||||||
token
|
token
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ describe('RootFolderNotFoundError', () => {
|
|||||||
it('when access token is not provided, error message should not contain scopes', () => {
|
it('when access token is not provided, error message should not contain scopes', () => {
|
||||||
const error = new RootFolderNotFoundError(
|
const error = new RootFolderNotFoundError(
|
||||||
'/myProject',
|
'/myProject',
|
||||||
'https://analytium.co.uk'
|
'https://sas.4gl.io'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||||
@@ -30,7 +30,7 @@ describe('RootFolderNotFoundError', () => {
|
|||||||
|
|
||||||
it('should include the folder path and SASDrive URL in the message', () => {
|
it('should include the folder path and SASDrive URL in the message', () => {
|
||||||
const folderPath = '/myProject'
|
const folderPath = '/myProject'
|
||||||
const serverUrl = 'https://analytium.co.uk'
|
const serverUrl = 'https://sas.4gl.io'
|
||||||
const error = new RootFolderNotFoundError(folderPath, serverUrl)
|
const error = new RootFolderNotFoundError(folderPath, serverUrl)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './ArgumentError'
|
||||||
export * from './AuthorizeError'
|
export * from './AuthorizeError'
|
||||||
export * from './CertificateError'
|
export * from './CertificateError'
|
||||||
export * from './ComputeJobExecutionError'
|
export * from './ComputeJobExecutionError'
|
||||||
|
|||||||
30
src/types/errors/spec/SAS9AuthError.spec.ts
Normal file
30
src/types/errors/spec/SAS9AuthError.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SAS9AuthError } from '../SAS9AuthError'
|
||||||
|
|
||||||
|
describe('SAS9AuthError', () => {
|
||||||
|
it('should have the correct error message', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error.message).toBe(
|
||||||
|
'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have the correct error name', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error.name).toBe('AuthorizeError')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be an instance of SAS9AuthError', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error).toBeInstanceOf(SAS9AuthError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be an instance of Error', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set the prototype correctly', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(Object.getPrototypeOf(error)).toBe(SAS9AuthError.prototype)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,10 +6,13 @@ export * from './Job'
|
|||||||
export * from './JobDefinition'
|
export * from './JobDefinition'
|
||||||
export * from './JobResult'
|
export * from './JobResult'
|
||||||
export * from './Link'
|
export * from './Link'
|
||||||
|
export * from './Login'
|
||||||
export * from './SASjsConfig'
|
export * from './SASjsConfig'
|
||||||
export * from './SASjsRequest'
|
export * from './RequestClient'
|
||||||
export * from './Session'
|
export * from './Session'
|
||||||
export * from './UploadFile'
|
export * from './UploadFile'
|
||||||
export * from './PollOptions'
|
export * from './PollOptions'
|
||||||
export * from './WriteStream'
|
export * from './WriteStream'
|
||||||
export * from './ExecuteScript'
|
export * from './ExecuteScript'
|
||||||
|
export * from './errors'
|
||||||
|
export * from './Tables'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SASjsRequest } from '../types/SASjsRequest'
|
import { SASjsRequest } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comparator for SASjs request timestamps.
|
* Comparator for SASjs request timestamps.
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const SASJS_LOGS_SEPARATOR =
|
|
||||||
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
|
||||||
@@ -10,10 +10,14 @@ export const convertToCSV = (
|
|||||||
tableName: string
|
tableName: string
|
||||||
) => {
|
) => {
|
||||||
if (!data[tableName]) {
|
if (!data[tableName]) {
|
||||||
throw prefixMessage(
|
const error = prefixMessage(
|
||||||
'No table provided to be converted to CSV.',
|
'No table provided to be converted to CSV.',
|
||||||
'Error while converting to CSV. '
|
'Error while converting to CSV. '
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (typeof error === 'string') throw new Error(error)
|
||||||
|
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = data[tableName]
|
const table = data[tableName]
|
||||||
|
|||||||
5
src/utils/getFormData.ts
Normal file
5
src/utils/getFormData.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { isNode } from './'
|
||||||
|
import NodeFormData from 'form-data'
|
||||||
|
|
||||||
|
export const getFormData = (): NodeFormData | FormData =>
|
||||||
|
isNode() ? new NodeFormData() : new FormData()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user