1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 09:24:35 +00:00

Compare commits

..

1 Commits

Author SHA1 Message Date
435993e50e fix: sasjs-tests are passing on sas9 except one 2023-02-09 22:04:47 +05:00
138 changed files with 41887 additions and 26770 deletions

View File

@@ -106,16 +106,6 @@
"userTesting",
"doc"
]
},
{
"login": "saramartinelli1992",
"name": "Sara",
"avatar_url": "https://avatars.githubusercontent.com/u/100193908?v=4",
"profile": "https://github.com/saramartinelli1992",
"contributions": [
"userTesting",
"platform"
]
}
],
"contributorsPerLine": 7,

View File

@@ -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.
- [ ] Unit tests coverage has been increased and a new threshold is set.
- [ ] 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)).
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya

View File

@@ -1,12 +0,0 @@
## Expected behaviour
*Describe what should be happening*
## Current behaviour
*Describe what is actually happening*
## Environment info
**Client tech stack**: *Angular, React, Vue, VanillaJS, NodeJS etc.*
**Server type**: SASJS|SASVIYA|SAS9
**Login mechanism**: Default|Redirected
**Debug**: true|false
**Use Compute Api (relevant only on VIYA)**: true|false

View File

@@ -5,3 +5,7 @@ groups:
- YuryShkoda
- medjedovicm
- sabhas
- name: SASjs QA
reviewers: 1
usernames:
- VladislavParhomchik

View File

@@ -1,27 +0,0 @@
# Client
client
tls-client
dev tun
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
proto udp
remote vpn.4gl.io 7194
resolv-retry infinite
# this will fallback from udp6 to udp4 as well
connect-timeout 5
data-ciphers AES-256-CBC:AES-256-GCM
auth SHA256
script-security 2
keepalive 10 120
remote-cert-tls server
# Keys
ca ca.crt
cert user.crt
key user.key
tls-auth tls.key 1
# Security
nobind
persist-key
persist-tun
verb 3

View File

@@ -10,4 +10,4 @@ jobs:
- uses: actions/checkout@v2
- uses: uesteibar/reviewer-lottery@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
repo-token: ${{ secrets.GH_TOKEN }}

View File

@@ -1,58 +0,0 @@
# 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 }}

72
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
# 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
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/fermium]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Check npm audit
run: npm audit --production --audit-level=low
- name: Install Dependencies
run: npm ci
- 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
- name: Install SSH Key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.DCGITLAB_KEY }}
known_hosts: 'placeholder'
- name: Deploy sasjs-tests
run: |
npm install -g replace-in-files-cli
cd sasjs-tests
replace-in-files --regex='"@sasjs/adapter".*' --replacement='"@sasjs/adapter":"latest",' ./package.json
npm i
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='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
npm run update:adapter && npm run build
scp -o stricthostkeychecking=no -r ./build/* ${{ secrets.DCGITLAB_DEPLOY_PATH_VIYA }}
- name: Run cypress on sasjs
run: |
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
# 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 }}

View File

@@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
node-version: [lts/hydrogen]
node-version: [lts/fermium]
steps:
- name: Checkout
@@ -21,16 +21,7 @@ jobs:
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-
cache: npm
- name: Install Dependencies
run: npm ci
@@ -46,8 +37,8 @@ jobs:
- name: Push generated docs
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GH_TOKEN }}
publish_branch: gh-pages
publish_dir: ./docs
publish_dir: ./docs
cname: adapter.sasjs.io

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
node-version: [lts/hydrogen]
node-version: [lts/fermium]
steps:
- uses: actions/checkout@v2
@@ -22,16 +22,7 @@ jobs:
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-
cache: npm
- name: Install Dependencies
run: npm ci
@@ -45,7 +36,7 @@ jobs:
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Matrix message

View File

@@ -1,107 +0,0 @@
# 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 Server Tests
on:
pull_request:
jobs:
test:
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: Install Dependencies
run: npm ci
- name: Install Rimraf
run: npm i rimraf
- name: Build Package
run: npm run package:lib
env:
CI: true
- name: Write VPN Files
run: |
echo "$CA_CRT" > .github/vpn/ca.crt
echo "$USER_CRT" > .github/vpn/user.crt
echo "$USER_KEY" > .github/vpn/user.key
echo "$TLS_KEY" > .github/vpn/tls.key
shell: bash
env:
CA_CRT: ${{ secrets.CA_CRT}}
USER_CRT: ${{ secrets.USER_CRT }}
USER_KEY: ${{ secrets.USER_KEY }}
TLS_KEY: ${{ secrets.TLS_KEY }}
- name: 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
run: |
sudo apt install apt-transport-https
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
sudo apt-key add openvpn-repo-pkg-key.pub
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list
sudo apt update
sudo apt install openvpn3=17~betaUb22042+jammy
- name: Start Open VPN 3
run: openvpn3 session-start --config .github/vpn/config.ovpn
- name: install pm2
run: npm i -g pm2
- name: Fetch SASJS server
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
- name: Deploy sasjs-tests
run: |
npm install -g replace-in-files-cli
cd sasjs-tests
replace-in-files --regex='"@sasjs/adapter".*' --replacement='"@sasjs/adapter":"latest",' ./package.json
npm i
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='"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
pm2 start --name sasjs-test npm -- start
- name: Sleep for 10 seconds
run: sleep 10s
shell: bash
- name: Run cypress on sasjs
run: |
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
cat ./cypress.json
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}

View File

@@ -1,3 +0,0 @@
{
"cSpell.words": ["SASVIYA"]
}

View File

@@ -151,19 +151,7 @@ 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 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
To execute a script on Viya a session has to be created first which is time-consuming (~15sec). That is why a Session Manager has been created which is implementing the following logic:
1. When the first session is requested, we also create one more session (hot session) for future requests. Please notice two pending POST requests to create a session within the same context: ![the first session request](./screenshots/session-manager-first-request.png)
2. When a subsequent request for a session is received and there is a hot session available (not expired), this session is returned and an asynchronous request to create another hot session is sent. Please notice that there is a pending POST request to create a new session while a job has been already finished execution (POST request with status 201): ![subsequent session request](./screenshots/subsequent-session-request.png)
3. When a subsequent request for a session is received and there is no available hot session, 2 requests are sent asynchronously to create a session. The first created session will be returned and another session will be reserved for future requests.
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.
### Variable Types
@@ -277,7 +265,6 @@ 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).
* `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.
* `verbose` - optional, if `true` then a summary of every HTTP response is logged.
* `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.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
@@ -345,7 +332,7 @@ If you find this library useful, help us grow our star graph!
## Contributors ✨
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@@ -354,21 +341,18 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://krishna-acondy.io/"><img src="https://avatars.githubusercontent.com/u/2980428?v=4?s=100" width="100px;" alt="Krishna Acondy"/><br /><sub><b>Krishna Acondy</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=krishna-acondy" title="Code">💻</a> <a href="#infra-krishna-acondy" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#blog-krishna-acondy" title="Blogposts">📝</a> <a href="#content-krishna-acondy" title="Content">🖋</a> <a href="#ideas-krishna-acondy" title="Ideas, Planning, & Feedback">🤔</a> <a href="#video-krishna-acondy" title="Videos">📹</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt="Yury Shkoda"/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Code">💻</a> <a href="#infra-YuryShkoda" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-YuryShkoda" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Tests">⚠️</a> <a href="#video-YuryShkoda" title="Videos">📹</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt="Mihajlo Medjedovic"/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Code">💻</a> <a href="#infra-medjedovicm" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Amedjedovicm" title="Reviewed Pull Requests">👀</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/allanbowe"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt="Allan Bowe"/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Aallanbowe" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Tests">⚠️</a> <a href="#mentoring-allanbowe" title="Mentoring">🧑‍🏫</a> <a href="#maintenance-allanbowe" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt="Muhammad Saad "/><br /><sub><b>Muhammad Saad </b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asaadjutt01" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Tests">⚠️</a> <a href="#mentoring-saadjutt01" title="Mentoring">🧑‍🏫</a> <a href="#infra-saadjutt01" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt="Sabir Hassan"/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asabhas" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Tests">⚠️</a> <a href="#ideas-sabhas" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt="VladislavParhomchik"/><br /><sub><b>VladislavParhomchik</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=VladislavParhomchik" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3AVladislavParhomchik" title="Reviewed Pull Requests">👀</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://rudvfaden.github.io/"><img src="https://avatars.githubusercontent.com/u/2445577?v=4?s=100" width="100px;" alt="Rud Faden"/><br /><sub><b>Rud Faden</b></sub></a><br /><a href="#userTesting-rudvfaden" title="User Testing">📓</a> <a href="https://github.com/sasjs/adapter/commits?author=rudvfaden" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saramartinelli1992"><img src="https://avatars.githubusercontent.com/u/100193908?v=4?s=100" width="100px;" alt="Sara"/><br /><sub><b>Sara</b></sub></a><br /><a href="#userTesting-saramartinelli1992" title="User Testing">📓</a> <a href="#platform-saramartinelli1992" title="Packaging/porting to new platform">📦</a></td>
</tr>
</tbody>
<tr>
<td align="center"><a href="https://krishna-acondy.io/"><img src="https://avatars.githubusercontent.com/u/2980428?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krishna Acondy</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=krishna-acondy" title="Code">💻</a> <a href="#infra-krishna-acondy" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#blog-krishna-acondy" title="Blogposts">📝</a> <a href="#content-krishna-acondy" title="Content">🖋</a> <a href="#ideas-krishna-acondy" title="Ideas, Planning, & Feedback">🤔</a> <a href="#video-krishna-acondy" title="Videos">📹</a></td>
<td align="center"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Code">💻</a> <a href="#infra-YuryShkoda" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-YuryShkoda" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Tests">⚠️</a> <a href="#video-YuryShkoda" title="Videos">📹</a></td>
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Code">💻</a> <a href="#infra-medjedovicm" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Amedjedovicm" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://github.com/allanbowe"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Aallanbowe" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Tests">⚠️</a> <a href="#mentoring-allanbowe" title="Mentoring">🧑‍🏫</a> <a href="#maintenance-allanbowe" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Muhammad Saad </b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asaadjutt01" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Tests">⚠️</a> <a href="#mentoring-saadjutt01" title="Mentoring">🧑‍🏫</a> <a href="#infra-saadjutt01" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asabhas" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Tests">⚠️</a> <a href="#ideas-sabhas" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>VladislavParhomchik</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=VladislavParhomchik" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3AVladislavParhomchik" title="Reviewed Pull Requests">👀</a></td>
</tr>
<tr>
<td align="center"><a href="http://rudvfaden.github.io/"><img src="https://avatars.githubusercontent.com/u/2445577?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rud Faden</b></sub></a><br /><a href="#userTesting-rudvfaden" title="User Testing">📓</a> <a href="https://github.com/sasjs/adapter/commits?author=rudvfaden" title="Documentation">📖</a></td>
</tr>
</table>
<!-- markdownlint-restore -->

View File

@@ -4,56 +4,75 @@ const password = Cypress.env('password')
const testingFinishTimeout = Cypress.env('testingFinishTimeout')
context('sasjs-tests', function () {
before(() => {
this.beforeAll(() => {
cy.visit(sasjsTestsUrl)
})
beforeEach(() => {
this.beforeEach(() => {
cy.reload()
})
function loginIfNeeded() {
it('Should have all tests successfull', (done) => {
cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) {
cy.get('input[placeholder="User Name"]')
.should('be.visible')
.type(username)
cy.get('input[placeholder="Password"]')
.should('be.visible')
.type(password)
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 successful with debug on', () => {
loginIfNeeded()
it('Should have all tests successfull with debug on', (done) => {
cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.get('.ui.fitted.toggle.checkbox label').should('be.visible').click()
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')
cy.get('.ui.fitted.toggle.checkbox label')
.click()
.then(() => {
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
})
})
})
})
})
})
})

View File

@@ -1,25 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

View File

@@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

Binary file not shown.

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/combine/npm/chart.js@2.9.3,npm/jquery@3.5.1,npm/@sasjs/adapter@4"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/chart.js@2.9.3,npm/jquery@3.5.1,npm/@sasjs/adapter@1"></script>
<script>
var sasJs = new SASjs.default({
appLoc: "/Public/app/readme"

View File

@@ -41,14 +41,7 @@ module.exports = {
// ],
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
statements: 64.03,
branches: 45.11,
functions: 54.18,
lines: 64.53
}
},
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
@@ -142,8 +135,6 @@ module.exports = {
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
testEnvironment: 'node',
// Adds a location field to test results
// testLocationInResults: false,

24336
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m",
"preinstall": "npm run nodeVersionMessage",
"prebuild": "npm run nodeVersionMessage",
"build": "npx rimraf build && npx rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && npx rimraf build/src && npx rimraf node",
"build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && rimraf build/src && rimraf node",
"package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack",
"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}\"",
@@ -45,45 +45,43 @@
"license": "ISC",
"devDependencies": {
"@cypress/webpack-preprocessor": "5.9.1",
"@types/cors": "^2.8.17",
"@types/express": "4.17.13",
"@types/jest": "29.5.14",
"@types/jest": "27.4.0",
"@types/mime": "2.0.3",
"@types/pem": "1.9.6",
"@types/tough-cookie": "4.0.2",
"@types/tough-cookie": "4.0.1",
"copyfiles": "2.4.1",
"cors": "^2.8.5",
"cp": "0.2.0",
"cypress": "7.7.0",
"dotenv": "16.0.0",
"express": "4.17.3",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-extended": "4.0.2",
"jest": "27.4.7",
"jest-extended": "2.0.0",
"node-polyfill-webpack-plugin": "1.1.4",
"path": "0.12.7",
"pem": "1.14.5",
"prettier": "2.8.7",
"pem": "1.14.6",
"prettier": "2.7.1",
"process": "0.11.10",
"rimraf": "3.0.2",
"semantic-release": "19.0.3",
"terser-webpack-plugin": "5.3.6",
"ts-jest": "29.2.6",
"terser-webpack-plugin": "5.3.1",
"ts-jest": "27.1.3",
"ts-loader": "9.4.0",
"tslint": "6.1.3",
"tslint-config-prettier": "1.18.0",
"typedoc": "0.23.24",
"typedoc-plugin-rename-defaults": "0.6.4",
"typescript": "4.9.5",
"webpack": "5.76.2",
"typescript": "4.8.3",
"webpack": "5.69.0",
"webpack-cli": "4.9.2"
},
"main": "index.js",
"dependencies": {
"@sasjs/utils": "3.5.2",
"axios": "1.8.2",
"axios-cookiejar-support": "5.0.5",
"form-data": "4.0.4",
"@sasjs/utils": "2.52.0",
"axios": "0.27.2",
"axios-cookiejar-support": "1.0.1",
"form-data": "4.0.0",
"https": "1.0.0",
"tough-cookie": "4.1.3"
"tough-cookie": "4.0.0"
}
}

View File

@@ -1,3 +1 @@
SKIP_PREFLIGHT_CHECK=true
# Removes index.html inline scripts
INLINE_RUNTIME_CHUNK=false
SKIP_PREFLIGHT_CHECK=true

View File

@@ -13,11 +13,11 @@
# misc
.DS_Store
.env.*
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
sasjsbuild
sasjsresults

View File

@@ -1,19 +0,0 @@
{
"lineEndings": "off",
"noTrailingSpaces": true,
"noEncodedPasswords": true,
"hasDoxygenHeader": true,
"noSpacesInFileNames": true,
"lowerCaseFileNames": true,
"maxLineLength": 80,
"maxHeaderLineLength": 80,
"maxDataLineLength": 80,
"noTabIndentation": true,
"indentationMultiple": 2,
"hasMacroNameInMend": true,
"noNestedMacros": true,
"hasMacroParentheses": true,
"strictMacroDefinition": true,
"noGremlins": true,
"defaultHeader": "/**{lineEnding} @file{lineEnding} @brief <Your brief here>{lineEnding} <h4> SAS Macros </h4>{lineEnding}**/"
}

View File

@@ -65,7 +65,6 @@ The code below will work on ALL SAS platforms (Viya, SAS 9 EBI, SASjs Server).
```sas
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc;
%let apploc=/Public/app/adapter-tests;
filename ft15f001 temp lrecl=1000;
parmcards4;
%webout(FETCH)
@@ -81,7 +80,7 @@ parmcards4;
%mend; %x()
%webout(CLOSE)
;;;;
%mx_createwebservice(path=&apploc/services/common,name=sendObj)
%mx_createwebservice(path=/Public/app/common,name=sendObj)
parmcards4;
%webout(FETCH)
%webout(OPEN)
@@ -96,7 +95,7 @@ parmcards4;
%mend; %x()
%webout(CLOSE)
;;;;
%mx_createwebservice(path=&apploc/services/common,name=sendArr)
%mx_createwebservice(path=/Public/app/common,name=sendArr)
parmcards4;
data work.macvars;
set sashelp.vmacro;
@@ -105,14 +104,14 @@ parmcards4;
%webout(OBJ,macvars)
%webout(CLOSE)
;;;;
%mx_createwebservice(path=&apploc/services/common,name=sendMacVars)
%mx_createwebservice(path=/Public/app/common,name=sendMacVars)
parmcards4;
If you can keep your head when all about you
Are losing theirs and blaming it on you,
If you can trust yourself when all men doubt you,
But make allowance for their doubting too;
;;;;
%mx_createwebservice(path=&apploc/services/common,name=makeErr)
%mx_createwebservice(path=/Public/app/common,name=makeErr)
parmcards4;
%webout(OPEN)
data _null_;
@@ -121,7 +120,7 @@ data _null_;
run;
%webout(CLOSE)
;;;;
%mx_createwebservice(path=&apploc/services/common,name=invalidJSON)
%mx_createwebservice(path=/Public/app/common,name=invalidJSON)
```
You should now be able to access the tests in your browser at the deployed path on your server.

View File

@@ -1,15 +0,0 @@
// 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;
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -8,21 +8,21 @@
"@sasjs/test-framework": "1.5.7",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.41",
"@types/react": "^16.0.1",
"@types/react-dom": "^16.0.0",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7",
"react": "^16.0.1",
"react-dom": "^16.0.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scripts": "^5.0.1",
"typescript": "^4.1.3"
},
"scripts": {
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
"build": "NODE_OPTIONS=--openssl-legacy-provider craco build",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz --legacy-peer-deps",
"deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
@@ -43,8 +43,6 @@
]
},
"devDependencies": {
"@craco/craco": "6.4.3",
"node-sass": "9.0.0",
"source-map-loader": "0.2.4"
"node-sass": "7.0.3"
}
}

View File

@@ -3,8 +3,8 @@
"password": "",
"sasJsConfig": {
"serverUrl": "",
"appLoc": "/Public/app/adapter-tests/services",
"serverType": "SASJS",
"appLoc": "/Public/app",
"serverType": "SASVIYA",
"debug": false,
"contextName": "sasjs adapter compute context",
"useComputeApi": true

View File

@@ -4,7 +4,7 @@ if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then
echo "Cypress sasjs testing passed!"
else
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/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:4gl.io/send/m.room.message?access_token=$1
echo "Cypress sasjs testing failed!"
exit 1
fi

View File

@@ -1,13 +0,0 @@
/**
@file
@brief Makes an invalid JSON file
<h4> SAS Macros </h4>
**/
%webout(OPEN)
data _null_;
file _webout;
put ' the discovery channel ';
run;
%webout(CLOSE)

View File

@@ -1,11 +0,0 @@
/**
@file
@brief Makes an error
<h4> SAS Macros </h4>
**/
If you can keep your head when all about you
Are losing theirs and blaming it on you,
If you can trust yourself when all men doubt you,
But make allowance for their doubting too;

View File

@@ -1,21 +0,0 @@
/**
@file
@brief Returns JSON in Array format
<h4> SAS Macros </h4>
**/
%webout(FETCH)
%webout(OPEN)
%macro x();
%if %symexist(sasjs_tables) %then
%do i=1 %to %sysfunc(countw(&sasjs_tables));
%let table=%scan(&sasjs_tables,&i);
%webout(ARR,&table,missing=STRING,showmeta=YES)
%end;
%else %do i=1 %to &_webin_file_count;
%webout(ARR,&&_webin_name&i,missing=STRING,showmeta=YES)
%end;
%mend x;
%x()
%webout(CLOSE)

View File

@@ -1,13 +0,0 @@
/**
@file
@brief Returns Macro Variables
<h4> SAS Macros </h4>
**/
data work.macvars;
set sashelp.vmacro;
run;
%webout(OPEN)
%webout(OBJ,macvars)
%webout(CLOSE)

View File

@@ -1,21 +0,0 @@
/**
@file
@brief Returns JSON in Object format
<h4> SAS Macros </h4>
**/
%webout(FETCH)
%webout(OPEN)
%macro x();
%if %symexist(sasjs_tables) %then
%do i=1 %to %sysfunc(countw(&sasjs_tables));
%let table=%scan(&sasjs_tables,&i);
%webout(OBJ,&table,missing=STRING,showmeta=YES)
%end;
%else %do i=1 %to &_webin_file_count;
%webout(OBJ,&&_webin_name&i,missing=STRING,showmeta=YES)
%end;
%mend x;
%x()
%webout(CLOSE)

View File

@@ -1,40 +0,0 @@
ALPHABETICAL_INDEX = NO
ENABLE_PREPROCESSING = NO
EXTENSION_MAPPING = sas=Java ddl=Java
EXTRACT_LOCAL_CLASSES = NO
FILE_PATTERNS = *.sas \
*.ddl \
*.dox
GENERATE_LATEX = NO
GENERATE_TREEVIEW = YES
HIDE_FRIEND_COMPOUNDS = YES
HIDE_IN_BODY_DOCS = YES
HIDE_SCOPE_NAMES = YES
HIDE_UNDOC_CLASSES = YES
HIDE_UNDOC_MEMBERS = YES
HTML_OUTPUT = $(DOXY_HTML_OUTPUT)
HTML_HEADER = $(HTML_HEADER)
HTML_EXTRA_FILES = $(HTML_EXTRA_FILES)
HTML_FOOTER = $(HTML_FOOTER)
HTML_EXTRA_STYLESHEET = $(HTML_EXTRA_STYLESHEET)
INHERIT_DOCS = NO
INLINE_INFO = NO
INPUT = $(DOXY_INPUT)
LAYOUT_FILE = $(LAYOUT_FILE)
USE_MDFILE_AS_MAINPAGE = README.md
MAX_INITIALIZER_LINES = 0
PROJECT_NAME = $(PROJECT_NAME)
PROJECT_LOGO = $(PROJECT_LOGO)
PROJECT_BRIEF = $(PROJECT_BRIEF)
RECURSIVE = YES
REPEAT_BRIEF = NO
SHOW_NAMESPACES = NO
SHOW_USED_FILES = NO
SOURCE_BROWSER = YES
SOURCE_TOOLTIPS = NO
STRICT_PROTO_MATCHING = YES
STRIP_CODE_COMMENTS = NO
SUBGROUPING = NO
TAB_SIZE = 2
VERBATIM_HEADERS = NO

View File

@@ -1,112 +0,0 @@
<doxygenlayout version="1.0">
<!-- Generated by doxygen 1.8.14 -->
<!-- Navigation index tabs for HTML output -->
<navindex>
<tab type="mainpage" visible="yes" title="Home"/>
<tab type="pages" visible="no" title="" intro=""/>
<tab type="modules" visible="no" title="" intro=""/>
<tab type="namespaces" visible="no" title="">
<tab type="namespacelist" visible="no" title="" intro=""/>
<tab type="namespacemembers" visible="no" title="" intro=""/>
</tab>
<tab type="classes" visible="no" title="">
<tab type="classlist" visible="no" title="" intro=""/>
<tab type="classindex" visible="no" title=""/>
<tab type="hierarchy" visible="no" title="" intro=""/>
<tab type="classmembers" visible="no" title="" intro=""/>
</tab>
<tab type="filelist" visible="yes" title="" intro="List of Files"/>
<tab type="examples" visible="no" title="" intro=""/>
<tab type="user" url="data_lineage.svg" title="Lineage"/>
</navindex>
<!-- Layout definition for a file page -->
<file>
<briefdescription visible="yes"/>
<includes visible="$SHOW_INCLUDE_FILES"/>
<includegraph visible="$INCLUDE_GRAPH"/>
<includedbygraph visible="$INCLUDED_BY_GRAPH"/>
<sourcelink visible="yes"/>
<memberdecl>
<classes visible="no" title=""/>
<namespaces visible="no" title=""/>
<constantgroups visible="no" title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<functions title=""/>
<variables title=""/>
<membergroups visible="no"/>
</memberdecl>
<detaileddescription title=""/>
<memberdef>
<inlineclasses title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<functions title=""/>
<variables title=""/>
</memberdef>
<authorsection/>
</file>
<!-- Layout definition for a group page -->
<group>
<briefdescription visible="no"/>
<groupgraph visible="$GROUP_GRAPHS"/>
<memberdecl>
<nestedgroups visible="no" title=""/>
<dirs visible="yes" title=""/>
<files visible="yes" title=""/>
<namespaces visible="no" title=""/>
<classes visible="no" title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<enumvalues title=""/>
<functions title=""/>
<variables title=""/>
<signals title=""/>
<publicslots title=""/>
<protectedslots title=""/>
<privateslots title=""/>
<events title=""/>
<properties title=""/>
<friends title=""/>
<membergroups visible="yes"/>
</memberdecl>
<detaileddescription title=""/>
<memberdef>
<pagedocs/>
<inlineclasses title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<enumvalues title=""/>
<functions title=""/>
<variables title=""/>
<signals title=""/>
<publicslots title=""/>
<protectedslots title=""/>
<privateslots title=""/>
<events title=""/>
<properties title=""/>
<friends title=""/>
</memberdef>
<authorsection visible="yes"/>
</group>
<!-- Layout definition for a directory page -->
<directory>
<briefdescription visible="yes"/>
<detaileddescription visible="yes" title=""/>
<directorygraph visible="yes"/>
<memberdecl>
<dirs visible="yes"/>
<files visible="yes"/>
</memberdecl>
</directory>
</doxygenlayout>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -1,33 +0,0 @@
<!-- HTML footer for doxygen 1.8.17-->
<!-- start footer part -->
<!--BEGIN GENERATE_TREEVIEW-->
<div id="nav-path" class="navpath">
<!-- id is needed for treeview function! -->
<ul>
$navpath
<li class="footer">
$generatedby
<a href="https://www.doxygen.org/index.html">
<img class="footer" src="$relpath^doxygen.svg" alt="doxygen"
/></a>
$doxygenversion
</li>
<li>
<i> For more information visit the </i>
<a href="https://cli.sasjs.io">SASjs cli</a> documentation.
</li>
</ul>
</div>
<!--END GENERATE_TREEVIEW-->
<!--BEGIN !GENERATE_TREEVIEW-->
<hr class="footer" />
<address class="footer">
<small>
$generatedby &#160;<a href="http://www.doxygen.org/index.html">
<img class="footer" src="$relpath^doxygen.svg" alt="doxygen" />
</a>
$doxygenversion
</small>
</address>
<!--END !GENERATE_TREEVIEW-->

View File

@@ -1,57 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!-- HTML header for doxygen 1.8.17-->
<html xmlns="https://www.w3.org/1999/xhtml" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/xhtml;charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=9" />
<meta name="generator" content="Doxygen $doxygenversion" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--BEGIN PROJECT_NAME-->
<title>$projectname: $title</title>
<meta name="description" content="$projectbrief" />
<!--END PROJECT_NAME-->
<!--BEGIN !PROJECT_NAME-->
<title>$title</title>
<!--END !PROJECT_NAME-->
<link href="$relpath^tabs.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="$relpath^jquery.js"></script>
<script type="text/javascript" src="$relpath^dynsections.js"></script>
$treeview $search $mathjax
<link href="$relpath^$stylesheet" rel="stylesheet" type="text/css" />
<link rel="shortcut icon" href="$relpath^favicon.ico" type="image/x-icon" />
$extrastylesheet
</head>
<body>
<div id="top">
<!-- do not remove this div, it is closed by doxygen! -->
<!--BEGIN TITLEAREA-->
<div id="titlearea">
<table cellspacing="0" cellpadding="0">
<tbody>
<tr style="height: 56px">
<!--BEGIN PROJECT_LOGO-->
<td id="projectlogo">
<a href="$relpath^"
><img alt="Logo" src="$relpath^$projectlogo"
/></a>
</td>
<!--END PROJECT_LOGO-->
<td id="projectalign" style="padding-left: 0.5em">
<div id="projectname">$projectname</div>
<div id="projectbrief">$projectbrief</div>
</td>
<!--BEGIN DISABLE_INDEX-->
<!--BEGIN SEARCHENGINE-->
<td>$searchbox</td>
<!--END SEARCHENGINE-->
<!--END DISABLE_INDEX-->
</tr>
</tbody>
</table>
</div>
<!--END TITLEAREA-->
<!-- end header part -->
</div>
</body>
</html>

View File

@@ -1,4 +0,0 @@
#projectlogo img {
border: 0px none;
max-height: 70px;
}

View File

@@ -1,49 +0,0 @@
{
"$schema": "https://cli.sasjs.io/sasjsconfig-schema.json",
"serviceConfig": {
"serviceFolders": ["sasjs/common"]
},
"defaultTarget": "4gl",
"targets": [
{
"name": "4gl",
"serverUrl": "https://sas9.4gl.io",
"serverType": "SASJS",
"httpsAgentOptions": {
"allowInsecureRequests": false
},
"appLoc": "/Public/app/adapter-tests",
"deployConfig": {
"deployServicePack": true,
"deployScripts": []
},
"streamConfig": {
"streamWeb": true,
"streamWebFolder": "webv",
"webSourcePath": "build",
"streamServiceName": "adapter-tests",
"assetPaths": []
}
},
{
"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": []
}
}
]
}

View File

@@ -19,7 +19,7 @@ const App = (): ReactElement<{}> => {
basicTests(adapter, config.userName, config.password),
sendArrTests(adapter, appLoc),
sendObjTests(adapter),
// specialCaseTests(adapter),
specialCaseTests(adapter),
sasjsRequestTests(adapter),
fileUploadTests(adapter)
]

View File

@@ -53,7 +53,9 @@ export const basicTests = (
return await newAdapterIns.checkSession()
},
assertion: (response: any) =>
response?.isLoggedIn && response?.userName === userName
adapter.getSasjsConfig().serverType === ServerType.Sas9
? response?.isLoggedIn
: response?.isLoggedIn && response?.userName === userName
},
{
title: 'Multiple Log in attempts',
@@ -87,20 +89,6 @@ export const basicTests = (
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',
description:
@@ -173,6 +161,20 @@ export const basicTests = (
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
}
}
]
})

View File

@@ -48,7 +48,7 @@ export const computeTests = (adapter: SASjs, appLoc: string): TestSuite => ({
test: () => {
const data: any = { table1: [{ col1: 'first col value' }] }
return adapter.startComputeJob(
'/Public/app/adapter-tests/services/common/sendArr',
'/Public/app/common/sendArr',
data,
{},
undefined,

View File

@@ -20,30 +20,30 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
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
// }
// }
]
})

View File

@@ -134,20 +134,6 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
return adapter.request('common/sendArr', moreSpecialCharData)
},
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 (
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&

View File

@@ -6,8 +6,6 @@
"dom.iterable",
"esnext"
],
"sourceMap": true,
"inlineSources": true,
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -1,6 +1,6 @@
import * as https from 'https'
import { generateTimestamp } from '@sasjs/utils/time'
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import { Sas9RequestClient } from './request/Sas9RequestClient'
import { isUrl } from './utils'

View File

@@ -29,33 +29,11 @@ describe('SASViyaApiClient', () => {
jest
.spyOn(requestClient, 'get')
.mockImplementation(() => Promise.reject('Not Found'))
const error = await sasViyaApiClient
.createFolder('test', '/foo')
.catch((e: any) => e)
expect(error).toBeInstanceOf(RootFolderNotFoundError)
})
it('should throw an error when ', async () => {
const testMessage1 = 'test message 1'
const testMessage2 = 'test message 2.'
jest.spyOn(requestClient, 'post').mockImplementation(() => {
return Promise.reject({
message: testMessage1,
response: { data: { message: testMessage2 }, status: 409 }
})
})
const error = await sasViyaApiClient
.createFolder('test', '/foo')
.catch((e: any) => e)
const expectedError = `${testMessage1}. ${testMessage2} To override, please set "isForced" to "true".`
expect(error).toEqual(expectedError)
})
})
const setupMocks = () => {

View File

@@ -1,5 +1,5 @@
import { isRelativePath, isUri, isUrl } from './utils'
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import {
Job,
Session,
@@ -25,16 +25,9 @@ import { prefixMessage } from '@sasjs/utils/error'
import { pollJobState } from './api/viya/pollJobState'
import { getTokens } from './auth/getTokens'
import { uploadTables } from './api/viya/uploadTables'
import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
import { executeScript } from './api/viya/executeScript'
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
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.
@@ -277,7 +270,7 @@ export class SASViyaApiClient {
* @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/
@@ -294,7 +287,7 @@ export class SASViyaApiClient {
printPid = false,
variables?: MacroVar
): Promise<any> {
return executeOnComputeApi(
return executeScript(
this.requestClient,
this.sessionManager,
this.rootFolderName,
@@ -312,84 +305,6 @@ 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.
* @param folderPath - the absolute path to the folder.
@@ -463,14 +378,12 @@ export class SASViyaApiClient {
isForced?: boolean
): Promise<Folder> {
const logger = process.logger || console
if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.')
}
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
if (!parentFolderUri) {
logger.info(
`Parent folder at path '${parentFolderPath}' is not present.`
@@ -481,7 +394,6 @@ export class SASViyaApiClient {
parentFolderPath.lastIndexOf('/')
)
const newFolderName = `${parentFolderPath.split('/').pop()}`
if (newParentFolderPath === '') {
throw new RootFolderNotFoundError(
parentFolderPath,
@@ -489,24 +401,20 @@ export class SASViyaApiClient {
accessToken
)
}
logger.info(
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
)
const parentFolder = await this.createFolder(
newFolderName,
newParentFolderPath,
undefined,
accessToken
)
logger.info(
`Parent folder '${newFolderName}' has been successfully created.`
)
parentFolderUri = `/folders/folders/${parentFolder.id}`
} else if (isForced) {
} else if (isForced && accessToken) {
const folderPath = parentFolderPath + '/' + folderName
const folderUri = await this.getFolderUri(folderPath, accessToken)
@@ -519,8 +427,8 @@ export class SASViyaApiClient {
}
}
const { result: createFolderResponse } = await this.requestClient
.post<Folder>(
const { result: createFolderResponse } =
await this.requestClient.post<Folder>(
`/folders/folders?parentFolderUri=${parentFolderUri}`,
{
name: folderName,
@@ -528,34 +436,12 @@ export class SASViyaApiClient {
},
accessToken
)
.catch((err) => {
const { message, response } = err
if (message && response && response.data && response.data.message) {
const { status } = response
const { message: responseMessage } = response.data
const messages = [message, responseMessage].map((mes: string) =>
/\.$/.test(mes) ? mes : `${mes}.`
)
if (!isForced && status === 409) {
messages.push(`To override, please set "isForced" to "true".`)
}
const errMessage = messages.join(' ')
throw errMessage
}
throw err
})
// update folder map with newly created folder.
await this.populateFolderMap(
`${parentFolderPath}/${folderName}`,
accessToken
)
return createFolderResponse
}
@@ -662,7 +548,6 @@ export class SASViyaApiClient {
/**
* Exchanges the refresh token for an access token for the given client.
* This method can only be used by Node.
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param refreshToken - the refresh token received from the server.
@@ -706,7 +591,7 @@ export class SASViyaApiClient {
* @param accessToken - an optional access token for an authorized user.
* @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 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 pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/
@@ -817,13 +702,11 @@ export class SASViyaApiClient {
debug: boolean,
data?: any,
authConfig?: AuthConfig
): Promise<JobExecutionResult> {
) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(this.requestClient, authConfig))
}
if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error(
'Relative paths cannot be used without specifying a root folder name.'
@@ -836,7 +719,6 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}`
: folderPath
await this.populateFolderMap(fullFolderPath, access_token)
const jobFolder = this.folderMap.get(fullFolderPath)
@@ -853,8 +735,9 @@ export class SASViyaApiClient {
files = await this.uploadTables(data, access_token)
}
if (!jobToExecute) throw new Error(`Job was not found.`)
if (!jobToExecute) {
throw new Error(`Job was not found.`)
}
const jobDefinitionLink = jobToExecute?.links.find(
(l) => l.rel === 'getResource'
)?.href
@@ -870,14 +753,14 @@ export class SASViyaApiClient {
_webin_file_count: files.length,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_omitSessionResults: false,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
if (debug) {
jobArguments['_OMITTEXTLOG'] = 'false'
jobArguments['_omitSessionResults'] = 'false'
jobArguments['_OMITSESSIONRESULTS'] = 'false'
jobArguments['_DEBUG'] = 131
}
@@ -894,19 +777,16 @@ export class SASViyaApiClient {
jobDefinition,
arguments: jobArguments
}
const { result: postedJob } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody,
access_token
)
const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
(err) => {
throw prefixMessage(err, 'Error while polling job status. ')
}
)
const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token
@@ -917,7 +797,6 @@ export class SASViyaApiClient {
const resultLink = currentJob.results['_webout.json']
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (resultLink) {
jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`,
@@ -925,13 +804,11 @@ export class SASViyaApiClient {
'text/plain'
)
}
if (debug && logLink) {
log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
}
if (jobStatus === 'failed') {
throw new JobExecutionError(
currentJob.error?.errorCode,
@@ -939,16 +816,7 @@ export class SASViyaApiClient {
log
)
}
const executionResult: JobExecutionResult = {
result: jobResult?.result,
log
}
const { error } = currentJob
if (error) executionResult.error = error
return executionResult
return { result: jobResult?.result, log }
}
private async populateFolderMap(folderPath: string, accessToken?: string) {
@@ -1020,7 +888,6 @@ export class SASViyaApiClient {
})
if (!folder) return undefined
return folder
}
@@ -1032,31 +899,7 @@ export class SASViyaApiClient {
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 { result: folder } = await this.requestClient
@@ -1103,19 +946,14 @@ export class SASViyaApiClient {
}
/**
* Lists children folders/files 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 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(
sourceFolder: string,
accessToken?: string,
limit: number = 20,
options?: {
returnDetails?: boolean
}
limit: number = 20
) {
// checks if 'sourceFolder' is already a URI
const sourceFolderUri = isUri(sourceFolder)
@@ -1127,20 +965,11 @@ export class SASViyaApiClient {
accessToken
)
let membersToReturn = []
if (members && members.items) {
// If returnDetails is true, return full member details
if (options?.returnDetails) {
membersToReturn = members.items
} else {
// If returnDetails is false, return only member names
membersToReturn = members.items.map((item: any) => item.name)
}
return members.items.map((item: any) => item.name)
} else {
return []
}
// Return members without Etag
return membersToReturn
}
/**
@@ -1154,7 +983,7 @@ export class SASViyaApiClient {
sourceFolder: string,
targetParentFolder: string,
targetFolderName: string,
accessToken?: string
accessToken: string
) {
// If target path is an existing folder, than keep source folder name, othervise rename it with given target folder name
const sourceFolderName = sourceFolder.split('/').pop() as string
@@ -1221,7 +1050,7 @@ export class SASViyaApiClient {
* @param folderPath - the full path (eg `/Public/example/deleteThis`) of the folder to be deleted.
* @param accessToken - an access token for authorizing the request.
*/
public async deleteFolder(folderPath: string, accessToken?: string) {
public async deleteFolder(folderPath: string, accessToken: string) {
const recycleBinUri = await this.getRecycleBinUri(accessToken)
const folderName = folderPath.split('/').pop() || ''
const date = new Date()

View File

@@ -5,12 +5,7 @@ import {
EditContextInput,
PollOptions,
LoginMechanism,
VerboseMode,
ErrorResponse,
LoginOptions,
LoginResult,
ExecutionQuery,
Tables
ExecutionQuery
} from './types'
import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient'
@@ -22,6 +17,7 @@ import {
AuthConfig,
ExtraResponseAttributes,
SasAuthResponse,
ServicePackSASjs,
AuthConfigSas9
} from '@sasjs/utils/types'
import { RequestClient } from './request/RequestClient'
@@ -35,7 +31,8 @@ import {
Sas9JobExecutor,
FileUploader
} from './job-execution'
import { AxiosResponse, AxiosError } from 'axios'
import { ErrorResponse } from './types/errors'
import { LoginOptions, LoginResult } from './types/Login'
interface ExecuteScriptParams {
linesOfCode: string[]
@@ -162,23 +159,6 @@ 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.
* @param accessToken - an access token for an authorised user.
@@ -359,16 +339,13 @@ export default class SASjs {
sasApiClient?: SASViyaApiClient,
isForced?: boolean
) {
if (sasApiClient) {
if (sasApiClient)
return await sasApiClient.createFolder(
folderName,
parentFolderPath,
parentFolderUri,
accessToken,
isForced
accessToken
)
}
return await this.sasViyaApiClient!.createFolder(
folderName,
parentFolderPath,
@@ -412,51 +389,6 @@ 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.
* @param folderPath - path of the folder to be fetched.
@@ -482,23 +414,18 @@ export default class SASjs {
* 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 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(
sourceFolder: string,
accessToken?: string,
limit?: number,
returnDetails = false
limit?: number
) {
this.isMethodSupported('listFolder', [ServerType.SasViya])
return await this.sasViyaApiClient?.listFolder(
sourceFolder,
accessToken,
limit,
{
returnDetails
}
limit
)
}
@@ -858,11 +785,13 @@ export default class SASjs {
this.isMethodSupported('deployServicePack', [ServerType.SasViya])
let sasApiClient: any = null
if (serverUrl || appLoc) {
if (!serverUrl) serverUrl = this.sasjsConfig.serverUrl
if (!appLoc) appLoc = this.sasjsConfig.appLoc
if (!serverUrl) {
serverUrl = this.sasjsConfig.serverUrl
}
if (!appLoc) {
appLoc = this.sasjsConfig.appLoc
}
if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasApiClient = new SASViyaApiClient(
serverUrl,
@@ -880,13 +809,11 @@ export default class SASjs {
}
} else {
let sasClientConfig: any = null
if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasClientConfig = this.sasViyaApiClient!.getConfig()
} else if (this.sasjsConfig.serverType === ServerType.Sas9) {
sasClientConfig = this.sas9ApiClient!.getConfig()
}
serverUrl = sasClientConfig.serverUrl
appLoc = sasClientConfig.rootFolderName as string
}
@@ -923,10 +850,9 @@ export default class SASjs {
* @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.
* @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: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
*/
public async startComputeJob(
sasJob: string,
@@ -936,8 +862,7 @@ export default class SASjs {
waitForResult?: boolean,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar,
verboseMode?: VerboseMode
variables?: MacroVar
) {
config = {
...this.sasjsConfig,
@@ -951,11 +876,6 @@ export default class SASjs {
)
}
if (verboseMode) {
this.requestClient?.setVerboseMode(verboseMode)
this.requestClient?.enableVerboseMode()
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
return this.sasViyaApiClient?.executeComputeJob(
sasJob,
config.contextName,
@@ -975,7 +895,6 @@ export default class SASjs {
await this.computeJobExecutor?.resendWaitingRequests()
await this.jesJobExecutor?.resendWaitingRequests()
await this.fileUploader?.resendWaitingRequests()
await this.sasjsJobExecutor?.resendWaitingRequests()
}
/**
@@ -1049,8 +968,7 @@ export default class SASjs {
this.requestClient = new RequestClientClass(
this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit,
this.sasjsConfig.verbose
this.sasjsConfig.requestHistoryLimit
)
} else {
this.requestClient.setConfig(
@@ -1214,42 +1132,4 @@ 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)
}
}

View File

@@ -1,24 +1,11 @@
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { ExecutionQuery } from './types'
import { RequestClient } from './request/RequestClient'
import { getAccessTokenForSasjs } from './auth/getAccessTokenForSasjs'
import { refreshTokensForSasjs } from './auth/refreshTokensForSasjs'
import { getTokens } from './auth/getTokens'
// TODO: move to sasjs/utils
export interface SASjsAuthResponse {
access_token: string
refresh_token: string
}
export interface ScriptExecutionResult {
log: string
webout?: string
printOutput?: string
}
export class SASjsApiClient {
constructor(private requestClient: RequestClient) {}
@@ -131,28 +118,18 @@ export class SASjsApiClient {
code: string,
runTime: string = 'sas',
authConfig?: AuthConfig
): Promise<ScriptExecutionResult> {
) {
const access_token = await this.getAccessTokenForRequest(authConfig)
const executionResult: ScriptExecutionResult = { log: '' }
let parsedSasjsServerLog = ''
await this.requestClient
.post('SASjsApi/code/execute', { code, runTime }, access_token)
.then((res: any) => {
const { log, printOutput, result: webout } = res
executionResult.log = log
if (printOutput) executionResult.printOutput = printOutput
if (webout) executionResult.webout = webout
})
.catch((err) => {
throw prefixMessage(
err,
'Error while sending POST request to execute code. '
)
if (res.log) parsedSasjsServerLog = res.log
})
return executionResult
return parsedSasjsServerLog
}
/**
@@ -175,3 +152,9 @@ export class SASjsApiClient {
return refreshTokensForSasjs(this.requestClient, refreshToken)
}
}
// todo move to sasjs/utils
export interface SASjsAuthResponse {
access_token: string
refresh_token: string
}

View File

@@ -1,4 +1,4 @@
import { Session, Context, SessionVariable, SessionState } from './types'
import { Session, Context, SessionVariable } from './types'
import { NoSessionStateError } from './types/errors'
import { asyncForEach, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error'
@@ -6,13 +6,8 @@ import { RequestClient } from './request/RequestClient'
const MAX_SESSION_COUNT = 1
interface ApiErrorResponse {
response: { status: number | string; data: { message: string } }
}
export class SessionManager {
private loggedErrors: NoSessionStateError[] = []
private sessionStateLinkError = 'Error while getting session state link. '
constructor(
private serverUrl: string,
@@ -22,14 +17,12 @@ export class SessionManager {
if (serverUrl) isUrl(serverUrl)
}
// INFO: session pool
private sessions: Session[] = []
private currentContext: Context | null = null
private settingContext: boolean = false
private _debug: boolean = false
private printedSessionState = {
printed: false,
state: SessionState.NoState
state: ''
}
public get debug() {
@@ -40,243 +33,72 @@ export class SessionManager {
this._debug = value
}
/**
* Checks if session is valid. Session is considered valid if time since it's creation is less than 'sessionInactiveTimeout' attribute.
* @param session - session object.
* @returns - boolean indicating if session is valid.
*/
private isSessionValid(session: Session): boolean {
if (!session) return false
async getSession(accessToken?: string) {
await this.createSessions(accessToken)
await this.createAndWaitForSession(accessToken)
const session = this.sessions.pop()
const secondsSinceSessionCreation =
(new Date().getTime() - new Date(session.creationTimeStamp).getTime()) /
(new Date().getTime() - new Date(session!.creationTimeStamp).getTime()) /
1000
if (
!session!.attributes ||
secondsSinceSessionCreation >= session!.attributes.sessionInactiveTimeout
) {
return false
} else {
return true
}
}
await this.createSessions(accessToken)
const freshSession = this.sessions.pop()
/**
* Removes session from pool of hot sessions.
* @param session - session object.
* @returns - void.
*/
private removeSessionFromPool(session: Session): void {
this.sessions = this.sessions.filter((ses) => ses.id !== session.id)
}
/**
* Filters session pool to keep only valid sessions.
* @param session - session object.
* @returns - void.
*/
private removeExpiredSessions(): void {
this.sessions = this.sessions.filter((session) =>
this.isSessionValid(session)
)
}
/**
* Throws set of errors as a single error.
* @param errors - array of errors or string.
* @param prefix - an optional final error prefix.
* @returns - never.
*/
private throwErrors(errors: (Error | string)[], prefix?: string): never {
throw prefix
? prefixMessage(new Error(errors.join('. ')), prefix)
: new Error(
errors
.map((err) =>
(err as Error).message ? (err as Error).message : err
)
.join('. ')
)
}
/**
* Returns session.
* If there is a hot session available, it will be returned immediately and an asynchronous request to create new hot session will be submitted.
* If there is no available session, 2 session creation requests will be submitted. The session is returned once it is created and ready.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session.
*/
async getSession(accessToken?: string) {
const errors: (Error | string)[] = []
let isErrorThrown = false
const throwIfError = () => {
if (errors.length && !isErrorThrown) {
isErrorThrown = true
this.throwErrors(errors)
}
return freshSession
}
this.removeExpiredSessions()
if (this.sessions.length) {
const session = this.sessions[0]
this.removeSessionFromPool(session)
this.createSessions(accessToken).catch((err) => {
errors.push(err)
})
this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
throwIfError()
return session
} else {
this.createSessions(accessToken).catch((err) => {
errors.push(err)
})
await this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
this.removeExpiredSessions()
const session = this.sessions.pop()!
this.removeSessionFromPool(session)
throwIfError()
return session
}
return session
}
/**
* Returns error message based on the response from SAS API.
* @param err - an optional access token.
* @param accessToken - an optional access token.
* @returns - an error message.
*/
private getErrorMessage(
err: ApiErrorResponse,
url: string,
method: 'GET' | 'POST' | 'DELETE'
) {
return (
`${method} request to ${url} failed with status code ${
err.response.status || 'unknown'
}. ` + err.response.data.message || ''
)
}
/**
* Deletes session.
* @param id - a session id.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when session is deleted.
*/
async clearSession(id: string, accessToken?: string): Promise<void> {
const url = `/compute/sessions/${id}`
async clearSession(id: string, accessToken?: string) {
return await this.requestClient
.delete<Session>(url, accessToken)
.delete<Session>(`/compute/sessions/${id}`, accessToken)
.then(() => {
this.sessions = this.sessions.filter((s) => s.id !== id)
})
.catch((err: ApiErrorResponse) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'DELETE'),
'Error while deleting session. '
)
.catch((err) => {
throw prefixMessage(err, 'Error while deleting session. ')
})
}
/**
* Creates sessions in amount equal to MAX_SESSION_COUNT.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when required amount of sessions is created.
*/
private async createSessions(accessToken?: string): Promise<void> {
const errors: (Error | string)[] = []
private async createSessions(accessToken?: string) {
if (!this.sessions.length) {
await asyncForEach(new Array(MAX_SESSION_COUNT), async () => {
await this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
if (!this.currentContext) {
await this.setCurrentContext(accessToken).catch((err) => {
throw err
})
})
}
if (errors.length) {
this.throwErrors(errors, 'Error while creating session. ')
}
}
/**
* Waits for the current context to be set.
* @returns - a promise which resolves when current context is set.
*/
private async waitForCurrentContext(): Promise<void> {
return new Promise((resolve) => {
const timer = setInterval(() => {
if (this.currentContext) {
this.settingContext = false
clearInterval(timer)
resolve()
}
}, 100)
})
}
/**
* Creates and waits for session to be ready.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session.
*/
private async createAndWaitForSession(
accessToken?: string
): Promise<Session> {
if (!this.currentContext) {
if (!this.settingContext) {
await this.setCurrentContext(accessToken)
} else {
await this.waitForCurrentContext()
}
}
const url = `${this.serverUrl}/compute/contexts/${
this.currentContext!.id
}/sessions`
await asyncForEach(new Array(MAX_SESSION_COUNT), async () => {
const createdSession = await this.createAndWaitForSession(
accessToken
).catch((err) => {
throw err
})
const { result: createdSession, etag } = await this.requestClient
.post<Session>(url, {}, accessToken)
.catch((err: ApiErrorResponse) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'POST'),
`Error while creating session. `
)
this.sessions.push(createdSession)
}).catch((err) => {
throw err
})
}
}
// 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
private async createAndWaitForSession(accessToken?: string) {
const { result: createdSession, etag } = await this.requestClient
.post<Session>(
`${this.serverUrl}/compute/contexts/${
this.currentContext!.id
}/sessions`,
{},
accessToken
)
.catch((err) => {
throw err
})
await this.waitForSession(createdSession, etag, accessToken)
@@ -285,26 +107,14 @@ export class SessionManager {
return createdSession
}
/**
* Sets current context.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when current context is set.
*/
private async setCurrentContext(accessToken?: string): Promise<void> {
private async setCurrentContext(accessToken?: string) {
if (!this.currentContext) {
const url = `${this.serverUrl}/compute/contexts?limit=10000`
this.settingContext = true
const { result: contexts } = await this.requestClient
.get<{
items: Context[]
}>(url, accessToken)
.catch((err: ApiErrorResponse) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
`Error while getting list of contexts. `
)
}>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken)
.catch((err) => {
throw err
})
const contextsList =
@@ -328,42 +138,51 @@ export class SessionManager {
}
}
/**
* Waits for session to be ready.
* @param session - a session object.
* @param etag - an etag that can be a string or null.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session state.
*/
private getHeaders(accessToken?: string) {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
return headers
}
private async waitForSession(
session: Session,
etag: string | null,
accessToken?: string
): Promise<string> {
let { state: sessionState } = session
const { stateUrl } = session
const logger = process.logger || console
let sessionState = session.state
const stateLink = session.links.find((l: any) => l.rel === 'state')
if (
sessionState === SessionState.Pending ||
sessionState === SessionState.Running ||
sessionState === SessionState.NoState
sessionState === 'pending' ||
sessionState === 'running' ||
sessionState === ''
) {
if (stateUrl) {
if (stateLink) {
if (this.debug && !this.printedSessionState.printed) {
logger.info(`Polling: ${this.serverUrl + stateUrl}`)
logger.info(`Polling: ${this.serverUrl + stateLink.href}`)
this.printedSessionState.printed = true
}
const url = `${this.serverUrl}${stateUrl}?wait=30`
const { result: state, responseStatus: responseStatus } =
await this.getSessionState(url, etag!, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while waiting for session. ')
await this.getSessionState(
`${this.serverUrl}${stateLink.href}?wait=30`,
etag!,
accessToken
).catch((err) => {
throw prefixMessage(err, 'Error while getting session state.')
})
sessionState = state.trim() as SessionState
sessionState = state.trim()
if (this.debug && this.printedSessionState.state !== sessionState) {
logger.info(`Current session state is '${sessionState}'`)
@@ -375,7 +194,7 @@ export class SessionManager {
if (!sessionState) {
const stateError = new NoSessionStateError(
responseStatus,
this.serverUrl + stateUrl,
this.serverUrl + stateLink.href,
session.links.find((l: any) => l.rel === 'log')?.href as string
)
@@ -397,7 +216,7 @@ export class SessionManager {
return sessionState
} else {
throw this.sessionStateLinkError
throw 'Error while getting session state link.'
}
} else {
this.loggedErrors = []
@@ -406,59 +225,32 @@ export class SessionManager {
}
}
/**
* Gets session state.
* @param url - a URL to get session state.
* @param etag - an etag string.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a result string and response status.
*/
private async getSessionState(
url: string,
etag: string,
accessToken?: string
): Promise<{
result: string
responseStatus: number
}> {
) {
return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => ({
result: res.result as SessionState,
result: res.result as string,
responseStatus: res.status
}))
.catch((err) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
'Error while getting session state. '
)
throw err
})
}
/**
* Gets variable.
* @param sessionId - a session id.
* @param variable - a variable string.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a result that confirms to SessionVariable interface, etag string and status code.
*/
async getVariable(
sessionId: string,
variable: string,
accessToken?: string
): Promise<{
result: SessionVariable
etag: string
status: number
}> {
const url = `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`
async getVariable(sessionId: string, variable: string, accessToken?: string) {
return await this.requestClient
.get<SessionVariable>(url, accessToken)
.get<SessionVariable>(
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
accessToken
)
.catch((err) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
`Error while fetching session variable '${variable}'. `
err,
`Error while fetching session variable '${variable}'.`
)
})
}

View File

@@ -12,15 +12,11 @@ import { RequestClient } from '../../request/RequestClient'
import { SessionManager } from '../../SessionManager'
import { isRelativePath, fetchLogByChunks } from '../../utils'
import { formatDataForRequest } from '../../utils/formatDataForRequest'
import { pollJobState, JobState } from './pollJobState'
import { pollJobState } from './pollJobState'
import { uploadTables } from './uploadTables'
interface JobRequestBody {
[key: string]: number | string | string[]
}
/**
* Executes SAS program on the current SAS Viya server using Compute API.
* Executes code on the current SAS Viya server.
* @param jobPath - the path to the file being submitted for execution.
* @param linesOfCode - an array of code lines to execute.
* @param contextName - the context to execute the code in.
@@ -29,11 +25,11 @@ interface JobRequestBody {
* @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/
export async function executeOnComputeApi(
export async function executeScript(
requestClient: RequestClient,
sessionManager: SessionManager,
rootFolderName: string,
@@ -50,7 +46,6 @@ export async function executeOnComputeApi(
variables?: MacroVar
): Promise<any> {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(requestClient, authConfig))
}
@@ -83,13 +78,27 @@ export async function executeOnComputeApi(
const logger = process.logger || console
logger.info(
`Triggering '${relativeJobPath}' with PID ${
`Triggered '${relativeJobPath}' with PID ${
jobIdVariable.value
} at ${timestampToYYYYMMDDHHMMSS()}`
)
}
}
const jobArguments: { [key: string]: any } = {
_contextName: contextName,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
if (debug) {
jobArguments['_OMITTEXTLOG'] = false
jobArguments['_OMITSESSIONRESULTS'] = false
}
let fileName
if (isRelativePath(jobPath)) {
@@ -98,7 +107,6 @@ export async function executeOnComputeApi(
}`
} else {
const jobPathParts = jobPath.split('/')
fileName = jobPathParts.pop()
}
@@ -110,6 +118,7 @@ export async function executeOnComputeApi(
}
if (variables) jobVariables = { ...jobVariables, ...variables }
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
let files: any[] = []
@@ -136,12 +145,12 @@ export async function executeOnComputeApi(
}
// Execute job in session
const jobRequestBody: JobRequestBody = {
name: fileName || 'Default Job Name',
const jobRequestBody = {
name: fileName,
description: 'Powered by SASjs',
code: linesOfCode,
variables: jobVariables,
version: 2
arguments: jobArguments
}
const { result: postedJob, etag } = await requestClient
@@ -170,21 +179,16 @@ export async function executeOnComputeApi(
postedJob,
debug,
authConfig,
pollOptions,
{
session,
sessionManager
}
pollOptions
).catch(async (err) => {
const error = err?.response?.data
const result = /err=[0-9]*,/.exec(error)
const errorCode = '5113'
const errorCode = '5113'
if (result?.[0]?.slice(4, -1) === errorCode) {
const logCount = 1000000
const sessionLogUrl =
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
const logCount = 1000000
err.log = await fetchLogByChunks(
requestClient,
access_token!,
@@ -192,7 +196,6 @@ export async function executeOnComputeApi(
logCount
)
}
throw prefixMessage(err, 'Error while polling job status. ')
})
@@ -211,12 +214,12 @@ export async function executeOnComputeApi(
let jobResult
let log = ''
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (debug && logLink) {
const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks(
requestClient,
access_token!,
@@ -225,11 +228,13 @@ export async function executeOnComputeApi(
)
}
if (jobStatus === JobState.Failed || jobStatus === JobState.Error) {
if (jobStatus === 'failed' || jobStatus === 'error') {
throw new ComputeJobExecutionError(currentJob, log)
}
if (!expectWebout) return { job: currentJob, log }
if (!expectWebout) {
return { job: currentJob, log }
}
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
@@ -240,7 +245,6 @@ export async function executeOnComputeApi(
if (logLink) {
const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks(
requestClient,
access_token!,
@@ -275,7 +279,7 @@ export async function executeOnComputeApi(
const error = e as HttpError
if (error.status === 404) {
return executeOnComputeApi(
return executeScript(
requestClient,
sessionManager,
rootFolderName,

View File

@@ -1,90 +1,29 @@
import { AuthConfig } from '@sasjs/utils/types'
import { Job, PollOptions, PollStrategy } from '../..'
import { Job, PollOptions } from '../..'
import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors'
import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
import { Link, WriteStream } from '../../types'
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(
requestClient: RequestClient,
postedJob: Job,
debug: boolean,
authConfig?: AuthConfig,
pollOptions?: PollOptions,
jobSessionManager?: JobSessionManager
): Promise<JobState> {
pollOptions?: PollOptions
) {
const logger = process.logger || console
const streamLog = pollOptions?.streamLog || false
let pollInterval = 300
let maxPollCount = 1000
const defaultPollStrategy: PollStrategy = [
{ maxPollCount: 200, pollInterval: 300 },
{ maxPollCount: 300, pollInterval: 3000 },
{ 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
const defaultPollOptions: PollOptions = {
maxPollCount,
pollInterval,
streamLog: false
}
let defaultPollOptions: PollOptions = pollStrategy.splice(0, 1)[0]
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
@@ -92,10 +31,10 @@ export async function pollJobState(
throw new Error(`Job state link was not found.`)
}
let currentState: JobState = await getJobState(
let currentState = await getJobState(
requestClient,
postedJob,
JobState.NoState,
'',
debug,
authConfig
).catch((err) => {
@@ -103,73 +42,73 @@ export async function pollJobState(
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
err
)
return JobState.Unavailable
return 'unavailable'
})
let pollCount = 0
if (currentState === JobState.Completed) {
if (currentState === 'completed') {
return Promise.resolve(currentState)
}
let logFileStream
if (streamLog && isNode()) {
if (pollOptions.streamLog && isNode()) {
const { getFileStream } = require('./getFileStream')
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
}
// Poll up to the first 100 times with the specified poll interval
let result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
pollOptions,
authConfig,
streamLog,
logFileStream,
jobSessionManager
{
...pollOptions,
maxPollCount:
pollOptions.maxPollCount <= 100 ? pollOptions.maxPollCount : 100
},
logFileStream
)
currentState = result.state
pollCount = result.pollCount
if (
!needsRetry(currentState) ||
(pollCount >= pollOptions.maxPollCount && !pollStrategy.length)
) {
if (!needsRetry(currentState) || pollCount >= pollOptions.maxPollCount) {
return currentState
}
// 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 according to the next polling strategy
while (pollStrategy.length && needsRetry(currentState)) {
defaultPollOptions = pollStrategy.splice(0, 1)[0]
if (pollOptions) {
defaultPollOptions.logFolderPath = pollOptions.logFolderPath
}
result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
defaultPollOptions,
authConfig,
streamLog,
logFileStream,
jobSessionManager
)
currentState = result.state
pollCount = result.pollCount
// If we get to this point, this is a long-running job that needs longer polling.
// We will resume polling with a bigger interval of 1 minute
let longJobPollOptions: PollOptions = {
maxPollCount: 24 * 60,
pollInterval: 60000,
streamLog: false
}
if (pollOptions) {
longJobPollOptions.streamLog = pollOptions.streamLog
longJobPollOptions.logFolderPath = pollOptions.logFolderPath
}
if (logFileStream) logFileStream.end()
result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
authConfig,
longJobPollOptions,
logFileStream
)
currentState = result.state
pollCount = result.pollCount
if (logFileStream) {
logFileStream.end()
}
return currentState
}
@@ -180,13 +119,17 @@ const getJobState = async (
currentState: string,
debug: boolean,
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)) {
let tokens
if (authConfig) tokens = await getTokens(requestClient, authConfig)
if (authConfig) {
tokens = await getTokens(requestClient, authConfig)
}
const { result: jobState } = await requestClient
.get<string>(
@@ -200,88 +143,49 @@ const getJobState = async (
throw new JobStatePollError(job.id, err)
})
return jobState.trim() as JobState
return jobState.trim()
} else {
return currentState as JobState
return currentState
}
}
const needsRetry = (state: string) =>
state === JobState.Running ||
state === JobState.NoState ||
state === JobState.Pending ||
state === JobState.Unavailable
state === 'running' ||
state === '' ||
state === 'pending' ||
state === 'unavailable'
/**
* 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 (
const doPoll = async (
requestClient: RequestClient,
postedJob: Job,
currentState: JobState,
currentState: string,
debug: boolean,
pollCount: number,
pollOptions: PollOptions,
authConfig?: AuthConfig,
streamLog?: boolean,
logStream?: WriteStream,
jobSessionManager?: JobSessionManager
): Promise<{ state: JobState; pollCount: number }> => {
const { maxPollCount, pollInterval } = pollOptions
const logger = process.logger || console
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')!
pollOptions?: PollOptions,
logStream?: WriteStream
): Promise<{ state: string; pollCount: number }> => {
let pollInterval = 300
let maxPollCount = 1000
let maxErrorCount = 5
let errorCount = 0
let state = currentState
let printedState = JobState.NoState
let printedState = ''
let startLogLine = 0
const logger = process.logger || console
if (pollOptions) {
pollInterval = pollOptions.pollInterval || pollInterval
maxPollCount = pollOptions.maxPollCount || maxPollCount
}
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
while (needsRetry(state) && pollCount <= maxPollCount) {
// 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(
requestClient,
postedJob,
@@ -290,24 +194,21 @@ export const doPoll = async (
authConfig
).catch((err) => {
errorCount++
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
throw err
}
logger.error(
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
err
)
return JobState.Unavailable
return 'unavailable'
})
pollCount++
const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href
if (streamLog) {
if (pollOptions?.streamLog) {
const { result: job } = await requestClient.get<Job>(
jobHref,
authConfig?.access_token
@@ -337,45 +238,12 @@ export const doPoll = async (
printedState = state
}
if (state !== JobState.Unavailable && errorCount > 0) {
if (state != 'unavailable' && errorCount > 0) {
errorCount = 0
}
if (state !== JobState.Completed) {
await delay(pollInterval)
}
await delay(pollInterval)
}
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)
}
})
}

View File

@@ -1,21 +1,22 @@
import { RequestClient } from '../../../request/RequestClient'
import { SessionManager } from '../../../SessionManager'
import { executeOnComputeApi } from '../executeOnComputeApi'
import { executeScript } from '../executeScript'
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
import * as pollJobStateModule from '../pollJobState'
import * as uploadTablesModule from '../uploadTables'
import * as getTokensModule from '../../../auth/getTokens'
import * as formatDataModule from '../../../utils/formatDataForRequest'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import { PollOptions, JobSessionManager } from '../../../types'
import { PollOptions } from '../../../types'
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
import { Logger, LogLevel } from '@sasjs/utils/logger'
import { Logger, LogLevel } from '@sasjs/utils'
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const defaultPollOptions: PollOptions = {
maxPollCount: 100,
pollInterval: 500
pollInterval: 500,
streamLog: false
}
describe('executeScript', () => {
@@ -25,7 +26,7 @@ describe('executeScript', () => {
})
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -38,7 +39,7 @@ describe('executeScript', () => {
})
it('should try to get fresh tokens if an authConfig is provided', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -55,7 +56,7 @@ describe('executeScript', () => {
})
it('should get a session from the session manager before executing', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -72,7 +73,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'getSession')
.mockImplementation(() => Promise.reject('Test Error'))
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -85,7 +86,7 @@ describe('executeScript', () => {
})
it('should fetch the PID when printPid is true', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -113,7 +114,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'getVariable')
.mockImplementation(() => Promise.reject('Test Error'))
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -139,7 +140,7 @@ describe('executeScript', () => {
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
)
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -163,7 +164,7 @@ describe('executeScript', () => {
})
it('should format data as CSV when it does not contain semicolons', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -189,7 +190,7 @@ describe('executeScript', () => {
.spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -217,7 +218,14 @@ describe('executeScript', () => {
sasjs_tables: 'foo',
sasjs0data: 'bar'
},
version: 2
arguments: {
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
},
mockAuthConfig.access_token
)
@@ -228,7 +236,7 @@ describe('executeScript', () => {
.spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -257,7 +265,14 @@ describe('executeScript', () => {
sasjs0data: 'bar',
_DEBUG: 131
},
version: 2
arguments: {
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: false,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: false
}
},
mockAuthConfig.access_token
)
@@ -268,7 +283,7 @@ describe('executeScript', () => {
.spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Test Error'))
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -288,7 +303,7 @@ describe('executeScript', () => {
})
it('should immediately return the session when waitForResult is false', async () => {
const result = await executeOnComputeApi(
const result = await executeScript(
requestClient,
sessionManager,
'test',
@@ -308,12 +323,7 @@ describe('executeScript', () => {
})
it('should poll for job completion when waitForResult is true', async () => {
const jobSessionManager: JobSessionManager = {
session: mockSession,
sessionManager: sessionManager
}
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -334,8 +344,7 @@ describe('executeScript', () => {
mockJob,
false,
mockAuthConfig,
defaultPollOptions,
jobSessionManager
defaultPollOptions
)
})
@@ -344,7 +353,7 @@ describe('executeScript', () => {
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.reject('Poll Error'))
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -370,7 +379,7 @@ describe('executeScript', () => {
Promise.reject({ response: { data: 'err=5113,' } })
)
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -396,7 +405,7 @@ describe('executeScript', () => {
})
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -421,7 +430,7 @@ describe('executeScript', () => {
})
it('should not fetch the logs for the job if debug is false', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -443,11 +452,9 @@ describe('executeScript', () => {
it('should throw a ComputeJobExecutionError if the job has failed', async () => {
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() =>
Promise.resolve(pollJobStateModule.JobState.Failed)
)
.mockImplementation(() => Promise.resolve('failed'))
const error: ComputeJobExecutionError = await executeOnComputeApi(
const error: ComputeJobExecutionError = await executeScript(
requestClient,
sessionManager,
'test',
@@ -478,11 +485,9 @@ describe('executeScript', () => {
it('should throw a ComputeJobExecutionError if the job has errored out', async () => {
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() =>
Promise.resolve(pollJobStateModule.JobState.Error)
)
.mockImplementation(() => Promise.resolve('error'))
const error: ComputeJobExecutionError = await executeOnComputeApi(
const error: ComputeJobExecutionError = await executeScript(
requestClient,
sessionManager,
'test',
@@ -511,7 +516,7 @@ describe('executeScript', () => {
})
it('should fetch the result if expectWebout is true', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -542,7 +547,7 @@ describe('executeScript', () => {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
})
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -576,7 +581,7 @@ describe('executeScript', () => {
})
it('should clear the session after execution is complete', async () => {
await executeOnComputeApi(
await executeScript(
requestClient,
sessionManager,
'test',
@@ -603,7 +608,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.reject('Clear Session Error'))
const error = await executeOnComputeApi(
const error = await executeScript(
requestClient,
sessionManager,
'test',
@@ -649,9 +654,7 @@ const setupMocks = () => {
.mockImplementation(() => Promise.resolve(mockAuthConfig))
jest
.spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() =>
Promise.resolve(pollJobStateModule.JobState.Completed)
)
.mockImplementation(() => Promise.resolve('completed'))
jest
.spyOn(sessionManager, 'getVariable')
.mockImplementation(() =>

View File

@@ -1,16 +1,14 @@
import { AuthConfig } from '@sasjs/utils/types'
import { Job, Session, SessionState } from '../../../types'
import { Job, Session } from '../../../types'
export const mockSession: Session = {
id: 's35510n',
state: SessionState.Idle,
stateUrl: '',
state: 'idle',
links: [],
attributes: {
sessionInactiveTimeout: 1
},
creationTimeStamp: new Date().valueOf().toString(),
etag: 'etag-string'
creationTimeStamp: new Date().valueOf().toString()
}
export const mockJob: Job = {

View File

@@ -1,31 +1,22 @@
import { Logger, LogLevel } from '@sasjs/utils/logger'
import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient'
import { mockAuthConfig, mockJob } from './mockResponses'
import { pollJobState, doPoll, JobState } from '../pollJobState'
import { pollJobState } from '../pollJobState'
import * as getTokensModule from '../../../auth/getTokens'
import * as saveLogModule from '../saveLog'
import * as getFileStreamModule from '../getFileStream'
import * as isNodeModule from '../../../utils/isNode'
import * as delayModule from '../../../utils/delay'
import {
PollOptions,
PollStrategy,
SessionState,
JobSessionManager
} from '../../../types'
import { PollOptions } from '../../../types'
import { WriteStream } from 'fs'
import { SessionManager } from '../../../SessionManager'
import { JobStatePollError } from '../../../types'
const baseUrl = 'http://localhost'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
requestClient['httpClient'].defaults.baseURL = baseUrl
const defaultStreamLog = false
const defaultPollStrategy: PollOptions = {
const defaultPollOptions: PollOptions = {
maxPollCount: 100,
pollInterval: 500
pollInterval: 500,
streamLog: false
}
describe('pollJobState', () => {
@@ -35,10 +26,13 @@ describe('pollJobState', () => {
})
it('should get valid tokens if the authConfig has been provided', async () => {
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy,
streamLog: defaultStreamLog
})
await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
defaultPollOptions
)
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
requestClient,
@@ -52,7 +46,7 @@ describe('pollJobState', () => {
mockJob,
false,
undefined,
defaultPollStrategy
defaultPollOptions
)
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
@@ -64,7 +58,7 @@ describe('pollJobState', () => {
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
false,
undefined,
defaultPollStrategy
defaultPollOptions
).catch((e: any) => e)
expect((error as Error).message).toContain('Job state link was not found.')
@@ -78,7 +72,7 @@ describe('pollJobState', () => {
mockJob,
false,
mockAuthConfig,
defaultPollStrategy
defaultPollOptions
)
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
@@ -89,7 +83,7 @@ describe('pollJobState', () => {
const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy,
...defaultPollOptions,
streamLog: true
})
@@ -102,7 +96,7 @@ describe('pollJobState', () => {
const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy,
...defaultPollOptions,
streamLog: true
})
@@ -117,7 +111,7 @@ describe('pollJobState', () => {
const { getFileStream } = require('../getFileStream')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy,
...defaultPollOptions,
streamLog: true
})
@@ -133,7 +127,7 @@ describe('pollJobState', () => {
mockJob,
false,
mockAuthConfig,
defaultPollStrategy
defaultPollOptions
)
expect(saveLogModule.saveLog).not.toHaveBeenCalled()
@@ -142,18 +136,15 @@ describe('pollJobState', () => {
it('should return the current status when the max poll count is reached', async () => {
mockRunningPoll()
const pollOptions: PollOptions = {
...defaultPollStrategy,
maxPollCount: 1,
pollStrategy: []
}
const state = await pollJobState(
requestClient,
mockJob,
false,
mockAuthConfig,
pollOptions
{
...defaultPollOptions,
maxPollCount: 1
}
)
expect(state).toEqual('running')
@@ -168,7 +159,7 @@ describe('pollJobState', () => {
false,
mockAuthConfig,
{
...defaultPollStrategy,
...defaultPollOptions,
maxPollCount: 200,
pollInterval: 10
}
@@ -185,7 +176,7 @@ describe('pollJobState', () => {
mockJob,
false,
undefined,
defaultPollStrategy
defaultPollOptions
)
expect(requestClient.get).toHaveBeenCalledTimes(2)
@@ -201,7 +192,7 @@ describe('pollJobState', () => {
mockJob,
true,
undefined,
defaultPollStrategy
defaultPollOptions
)
expect((process as any).logger.info).toHaveBeenCalledTimes(4)
@@ -231,7 +222,7 @@ describe('pollJobState', () => {
mockJob,
false,
undefined,
defaultPollStrategy
defaultPollOptions
)
expect(requestClient.get).toHaveBeenCalledTimes(2)
@@ -246,401 +237,13 @@ describe('pollJobState', () => {
mockJob,
false,
undefined,
defaultPollStrategy
defaultPollOptions
).catch((e: any) => e)
expect(error.message).toEqual(
'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 = () => {
@@ -670,14 +273,11 @@ const setupMocks = () => {
const mockSimplePoll = (runningCount = 2) => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result:
count === 0
@@ -693,14 +293,11 @@ const mockSimplePoll = (runningCount = 2) => {
const mockRunningPoll = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result: count === 0 ? 'pending' : 'running',
etag: '',
@@ -711,14 +308,11 @@ const mockRunningPoll = () => {
const mockLongPoll = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result: count <= 102 ? 'running' : 'completed',
etag: '',
@@ -729,18 +323,14 @@ const mockLongPoll = () => {
const mockPollWithSingleError = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
if (count === 1) {
return Promise.reject('Status Error')
}
return Promise.resolve({
result: count === 0 ? 'pending' : 'completed',
etag: '',
@@ -754,7 +344,6 @@ const mockErroredPoll = () => {
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.reject('Status Error')
})
}

View File

@@ -1,4 +1,4 @@
import { Logger, LogLevel } from '@sasjs/utils/logger'
import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import * as writeStreamModule from '../writeStream'
@@ -69,5 +69,5 @@ const setupMocks = () => {
.mockImplementation(() => Promise.resolve('Test Log'))
jest
.spyOn(writeStreamModule, 'writeStream')
.mockImplementation(() => Promise.resolve(true))
.mockImplementation(() => Promise.resolve())
}

View File

@@ -5,27 +5,17 @@ import {
fileExists,
readFile,
deleteFile
} from '@sasjs/utils/file'
} from '@sasjs/utils'
describe('writeStream', () => {
const filename = 'test.txt'
const content = 'test'
let stream: WriteStream
beforeAll(async () => {
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 () => {
await expect(writeStream(stream, content)).toResolve()
await expect(fileExists(filename)).resolves.toEqual(true)
@@ -35,30 +25,11 @@ describe('writeStream', () => {
})
it('should reject when the write errors out', async () => {
// Mock implementation of the write method
jest
.spyOn(stream, 'write')
.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
.mockImplementation((_, callback) => callback(new Error('Test Error')))
const error = await writeStream(stream, content).catch((e: any) => e)
// Assert that the error is correctly handled
expect(error.message).toEqual('Test Error')
})
})

View File

@@ -3,14 +3,9 @@ import { WriteStream } from '../../types'
export const writeStream = async (
stream: WriteStream,
content: string
): Promise<boolean> => {
return new Promise((resolve, reject) => {
stream.write(content + '\n', (err: Error | null | undefined) => {
if (err) {
reject(err) // Reject on write error
} else {
resolve(true) // Resolve on successful write
}
})
): Promise<void> =>
stream.write(content + '\n', (e: any) => {
if (e) return Promise.reject(e)
return Promise.resolve()
})
}

View File

@@ -1,21 +1,18 @@
import { ServerType } from '@sasjs/utils/types'
import { RequestClient } from '../request/RequestClient'
import { NotFoundError } from '../types/errors'
import { LoginOptions, LoginResult, LoginResultInternal } from '../types/Login'
import { serialize } from '../utils'
import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
import { openWebPage } from './openWebPage'
import { verifySas9Login } from './verifySas9Login'
import { verifySasViyaLogin } from './verifySasViyaLogin'
import { isLogInSuccessHeaderPresent } from './'
export class AuthManager {
public userName = ''
public userLongName = ''
private loginUrl: 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/home`
constructor(
private serverUrl: string,
private serverType: ServerType,
@@ -29,8 +26,6 @@ export class AuthManager {
: this.serverType === ServerType.SasViya
? '/SASLogon/logout.do?'
: '/SASLogon/logout'
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
}
/**
@@ -47,9 +42,6 @@ export class AuthManager {
} = await this.fetchUserName()
if (isLoggedInAlready) {
const logger = process.logger || console
logger.log('login was not attempted as a valid session already exists')
await this.loginCallback()
return {
@@ -117,9 +109,6 @@ export class AuthManager {
} = await this.checkSession()
if (isLoggedInAlready) {
const logger = process.logger || console
logger.log('login was not attempted as a valid session already exists')
await this.loginCallback()
this.userName = loginParams.username
@@ -133,7 +122,7 @@ export class AuthManager {
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse)
let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
if (!isLoggedIn) {
if (isCredentialsVerifyError(loginResponse)) {
@@ -142,10 +131,6 @@ export class AuthManager {
loginResponse = await this.sendLoginRequest(newLoginForm, loginParams)
}
// Sometimes due to redirection on SAS9 and SASViya we don't get the login response that says
// You have signed in. Therefore, we have to make an extra request for checking session to
// ensure either user is logged in or not.
const res = await this.checkSession()
isLoggedIn = res.isLoggedIn
this.userLongName = res.userLongName
@@ -156,7 +141,7 @@ export class AuthManager {
await this.performCASSecurityCheck()
}
this.loginCallback()
await this.loginCallback()
this.userName = loginParams.username
}
@@ -170,12 +155,10 @@ export class AuthManager {
private async performCASSecurityCheck() {
const casAuthenticationUrl = `${this.serverUrl}/SASStoredProcess/j_spring_cas_security_check`
await this.requestClient
.get<string>(`/SASLogon/login?service=${casAuthenticationUrl}`, undefined)
.catch((err) => {
// ignore if resource not found error
if (!(err instanceof NotFoundError)) throw err
})
await this.requestClient.get<string>(
`/SASLogon/login?service=${casAuthenticationUrl}`,
undefined
)
}
private async sendLoginRequest(
@@ -218,7 +201,7 @@ export class AuthManager {
* - a boolean `isLoggedIn`
* - a string `userName`,
* - a string `userFullName` and
* - a form `loginForm` if not loggedIn.
* - a form `loginForm` if not loggedin.
*/
public async checkSession(): Promise<LoginResultInternal> {
const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
@@ -256,7 +239,7 @@ export class AuthManager {
}
const { result: formResponse } = await this.requestClient.get<string>(
this.loginUrl.replace('/SASLogon/login.do', '/SASLogon/login'),
this.loginUrl.replace('.do', ''),
undefined,
'text/plain'
)
@@ -329,13 +312,12 @@ export class AuthManager {
}
private getLoginForm(response: any) {
const pattern: RegExp = /<form.+action="(.*(Logon|login)[^"]*).*>/
const pattern: RegExp = /<form.+action="(.*Logon[^"]*).*>/
const matches = pattern.exec(response)
const formInputs: any = {}
if (matches && matches.length) {
this.setLoginUrl(matches)
response = response.replace(/<input/g, '\n<input')
const inputs = response.match(/<input.*"hidden"[^>]*>/g)
if (inputs) {
@@ -366,7 +348,7 @@ export class AuthManager {
this.loginUrl =
this.serverType === ServerType.SasViya
? tempLoginLink
: loginUrl.replace('/SASLogon/login.do', '/SASLogon/login')
: loginUrl.replace('.do', '')
}
}
@@ -385,3 +367,9 @@ const isCredentialsVerifyError = (response: string): boolean =>
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
response
)
const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedin
return /You have signed in/gm.test(response)
}

View File

@@ -1,7 +1,5 @@
import { prefixMessage } from '@sasjs/utils/error'
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.
@@ -33,16 +31,6 @@ export async function getAccessTokenForSasjs(
}
})
.catch((err) => {
throw prefixMessage(
err,
getTokenRequestErrorPrefix(
'fetching access token',
'getAccessTokenForSasjs',
ServerType.Sasjs,
url,
data,
clientId
)
)
throw prefixMessage(err, 'Error while getting access token. ')
})
}

View File

@@ -1,12 +1,11 @@
import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from '../request/RequestClient'
import { CertificateError } from '../types/errors'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
/**
* Exchange the auth code for access / refresh tokens for the given client / secret pair.
* @param requestClient - the pre-configured HTTP request client.
* Exchanges the auth code for an access token for the given client.
* @param requestClient - the pre-configured HTTP request client
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param authCode - the auth code received from the server.
@@ -17,44 +16,29 @@ export async function getAccessTokenForViya(
clientSecret: string,
authCode: string
): Promise<SasAuthResponse> {
const url = '/SASLogon/oauth/token'
let token
if (typeof Buffer === 'undefined') {
token = btoa(clientId + ':' + clientSecret)
} else {
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
}
const url = '/SASLogon/oauth/token'
const headers = {
Authorization: 'Basic ' + token,
Accept: 'application/json'
}
const dataJson = new URLSearchParams({
const data = new URLSearchParams({
grant_type: 'authorization_code',
code: authCode
})
const data = new URLSearchParams(dataJson)
const authResponse = await requestClient
.post(url, data, undefined, 'application/x-www-form-urlencoded', headers)
.then((res) => res.result as SasAuthResponse)
.catch((err) => {
if (err instanceof CertificateError) throw err
throw prefixMessage(
err,
getTokenRequestErrorPrefix(
'fetching access token',
'getAccessTokenForViya',
ServerType.SasViya,
url,
dataJson,
headers,
clientId,
clientSecret
)
)
throw prefixMessage(err, 'Error while getting access token. ')
})
return authResponse

View File

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

View File

@@ -10,7 +10,6 @@ import { refreshTokensForSasjs } from './refreshTokensForSasjs'
/**
* Returns the auth configuration, refreshing the tokens if necessary.
* This function can only be used by Node, if a server type is SASVIYA.
* @param requestClient - the pre-configured HTTP request client
* @param authConfig - an object containing a client ID, secret, access token and refresh token
* @param serverType - server type for which refreshing the tokens, defaults to SASVIYA
@@ -22,7 +21,6 @@ export async function getTokens(
): Promise<AuthConfig> {
const logger = process.logger || console
let { access_token, refresh_token, client, secret } = authConfig
if (
isAccessTokenExpiring(access_token) ||
isRefreshTokenExpiring(refresh_token)
@@ -30,14 +28,10 @@ export async function getTokens(
if (hasTokenExpired(refresh_token)) {
const error =
'Unable to obtain new access token. Your refresh token has expired.'
logger.error(error)
throw new Error(error)
}
logger.info('Refreshing access and refresh tokens.')
const tokens =
serverType === ServerType.SasViya
? await refreshTokensForViya(
@@ -49,6 +43,5 @@ export async function getTokens(
: await refreshTokensForSasjs(requestClient, refresh_token)
;({ access_token, refresh_token } = tokens)
}
return { access_token, refresh_token, client, secret }
}

View File

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

View File

@@ -1,5 +1,5 @@
export const isLogInRequired = (response: string): boolean => {
const pattern: RegExp = /<form.+action="(.*(Logon)|(login)[^"]*).*>/gm
const pattern: RegExp = /<form.+action="(.*Logon[^"]*).*>/gm
const matches = pattern.test(response)
return matches
}

View File

@@ -1,97 +0,0 @@
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)
}

View File

@@ -1,7 +1,5 @@
import { prefixMessage } from '@sasjs/utils/error'
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.
@@ -30,15 +28,7 @@ export async function refreshTokensForSasjs(
}
})
.catch((err) => {
throw prefixMessage(
err,
getTokenRequestErrorPrefix(
'refreshing tokens',
'refreshTokensForSasjs',
ServerType.Sasjs,
url
)
)
throw prefixMessage(err, 'Error while refreshing tokens: ')
})
return authResponse

View File

@@ -1,13 +1,10 @@
import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import { RequestClient } from '../request/RequestClient'
import { isNode } from '../utils'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
/**
* Exchanges the refresh token for an access token for the given client.
* This function can only be used by Node.
* @param requestClient - the pre-configured HTTP request client
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
@@ -19,12 +16,9 @@ export async function refreshTokensForViya(
clientSecret: string,
refreshToken: string
) {
if (!isNode()) {
throw new Error(`Method 'refreshTokensForViya' can only be used by Node.`)
}
const url = '/SASLogon/oauth/token'
const token =
let token
token =
typeof Buffer === 'undefined'
? btoa(clientId + ':' + clientSecret)
: Buffer.from(clientId + ':' + clientSecret).toString('base64')
@@ -33,7 +27,8 @@ export async function refreshTokensForViya(
Authorization: 'Basic ' + token
}
const formData = new NodeFormData()
const formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)
@@ -47,19 +42,7 @@ export async function refreshTokensForViya(
)
.then((res) => res.result)
.catch((err) => {
throw prefixMessage(
err,
getTokenRequestErrorPrefix(
'refreshing tokens',
'refreshTokensForViya',
ServerType.SasViya,
url,
formData,
headers,
clientId,
clientSecret
)
)
throw prefixMessage(err, 'Error while refreshing tokens: ')
})
return authResponse

View File

@@ -1,22 +1,17 @@
/**
* @jest-environment jsdom
*/
import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types'
import axios from 'axios'
import {
mockedCurrentUserApi,
mockLoginAuthoriseRequiredResponse
mockLoginAuthoriseRequiredResponse,
mockLoginSuccessResponse
} from './mockResponses'
import { serialize } from '../../utils'
import * as openWebPageModule from '../openWebPage'
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
import * as verifySas9LoginModule from '../verifySas9Login'
import { RequestClient } from '../../request/RequestClient'
import { getExpectedLogInSuccessHeader } from '../'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -130,7 +125,6 @@ describe('AuthManager', () => {
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
@@ -139,9 +133,8 @@ describe('AuthManager', () => {
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
Promise.resolve({ data: mockLoginSuccessResponse })
)
const loginResponse = await authManager.logIn(userName, password)
@@ -159,7 +152,7 @@ describe('AuthManager', () => {
`/SASLogon/login`,
loginParams,
{
withXSRFToken: true,
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
@@ -177,7 +170,6 @@ describe('AuthManager', () => {
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
@@ -186,9 +178,8 @@ describe('AuthManager', () => {
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
Promise.resolve({ data: mockLoginSuccessResponse })
)
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
@@ -207,7 +198,7 @@ describe('AuthManager', () => {
`/SASLogon/login`,
loginParams,
{
withXSRFToken: true,
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
@@ -256,7 +247,7 @@ describe('AuthManager', () => {
`/SASLogon/login`,
loginParams,
{
withXSRFToken: true,
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
@@ -374,7 +365,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`,
`/SASLogon/home`,
'SASLogon',
{
width: 500,
@@ -418,7 +409,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`,
`/SASLogon/home`,
'SASLogon',
{
width: 500,
@@ -462,7 +453,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`,
`/SASLogon/home`,
'SASLogon',
{
width: 500,
@@ -506,7 +497,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`,
`/SASLogon/home`,
'SASLogon',
{
width: 500,
@@ -539,7 +530,7 @@ describe('AuthManager', () => {
1,
`http://test-server.com/identities/users/@currentUser`,
{
withXSRFToken: true,
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
@@ -573,7 +564,7 @@ describe('AuthManager', () => {
1,
`http://test-server.com/SASStoredProcess`,
{
withXSRFToken: true,
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
@@ -602,7 +593,7 @@ describe('AuthManager', () => {
1,
`http://test-server.com/identities/users/@currentUser`,
{
withXSRFToken: true,
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
@@ -621,7 +612,7 @@ describe('AuthManager', () => {
})
const getHeadersJson = {
withXSRFToken: true,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'

View File

@@ -1,4 +1,4 @@
import { AuthConfig } from '@sasjs/utils/types'
import { AuthConfig } from '@sasjs/utils'
import { generateToken, mockSasjsAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { getAccessTokenForSasjs } from '../getAccessTokenForSasjs'
@@ -55,7 +55,7 @@ describe('getAccessTokenForSasjs', () => {
authConfig.refresh_token
).catch((e: any) => e)
expect(error).toContain('Error while fetching access token')
expect(error).toContain('Error while getting access token')
})
})

View File

@@ -1,5 +1,5 @@
import { AuthConfig } from '@sasjs/utils/types'
import NodeFormData from 'form-data'
import { AuthConfig } from '@sasjs/utils'
import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { getAccessTokenForViya } from '../getAccessTokenForViya'
@@ -66,7 +66,7 @@ describe('getAccessTokenForViya', () => {
authConfig.refresh_token
).catch((e: any) => e)
expect(error).toContain('Error while fetching access token')
expect(error).toContain('Error while getting access token')
})
})

View File

@@ -1,81 +0,0 @@
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)
})
})

View File

@@ -1,4 +1,4 @@
import { AuthConfig } from '@sasjs/utils/types'
import { AuthConfig } from '@sasjs/utils'
import * as refreshTokensModule from '../refreshTokensForViya'
import { generateToken, mockAuthResponse } from './mockResponses'
import { getTokens } from '../getTokens'

View File

@@ -1,82 +0,0 @@
/**
* @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()
})
})

View File

@@ -1,6 +1,7 @@
import { SasAuthResponse } from '@sasjs/utils/types'
export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
export const mockLoginSuccessResponse = `You have signed in`
export const mockAuthResponse: SasAuthResponse = {
access_token: 'acc355',

View File

@@ -1,8 +1,6 @@
import { ServerType } from '@sasjs/utils/types'
import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { refreshTokensForSasjs } from '../refreshTokensForSasjs'
import { getTokenRequestErrorPrefixResponse } from '../getTokenRequestErrorPrefix'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
@@ -40,9 +38,9 @@ describe('refreshTokensForSasjs', () => {
const error = await refreshTokensForSasjs(
requestClient,
refresh_token
).catch((e: any) => getTokenRequestErrorPrefixResponse(e, ServerType.Sasjs))
).catch((e: any) => e)
expect(error).toEqual(tokenError)
expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
})
})

View File

@@ -1,10 +1,8 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types'
import NodeFormData from 'form-data'
import { AuthConfig } from '@sasjs/utils'
import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { refreshTokensForViya } from '../refreshTokensForViya'
import * as IsNodeModule from '../../utils/isNode'
import { getTokenRequestErrorPrefixResponse } from '../getTokenRequestErrorPrefix'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
@@ -68,23 +66,9 @@ describe('refreshTokensForViya', () => {
authConfig.client,
authConfig.secret,
authConfig.refresh_token
).catch((e: any) =>
getTokenRequestErrorPrefixResponse(e, ServerType.SasViya)
)
).catch((e: any) => e)
expect(error).toEqual(tokenError)
})
it('should throw an error if environment is not Node', async () => {
jest.spyOn(IsNodeModule, 'isNode').mockImplementation(() => false)
const expectedError = new Error(
`Method 'refreshTokensForViya' can only be used by Node.`
)
expect(
refreshTokensForViya(requestClient, 'client', 'secret', 'token')
).rejects.toEqual(expectedError)
expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
})
})

View File

@@ -3,7 +3,6 @@
*/
import { verifySas9Login } from '../verifySas9Login'
import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySas9Login', () => {
const serverUrl = 'http://test-server.com'
@@ -18,10 +17,8 @@ describe('verifySas9Login', () => {
it('should return isLoggedIn true by checking state of popup', async () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon` },
document: {
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
location: { href: serverUrl + `/SASLogon/home` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
}
} as unknown as Window

View File

@@ -3,7 +3,6 @@
*/
import { verifySasViyaLogin } from '../verifySasViyaLogin'
import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySasViyaLogin', () => {
const serverUrl = 'http://test-server.com'
@@ -19,10 +18,8 @@ describe('verifySasViyaLogin', () => {
it('should return isLoggedIn true by checking state of popup', async () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon` },
document: {
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
location: { href: serverUrl + `/SASLogon/home` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
}
} as unknown as Window

View File

@@ -1,5 +1,4 @@
import { delay } from '../utils'
import { getExpectedLogInSuccessHeader } from './'
export async function verifySas9Login(loginPopup: Window): Promise<{
isLoggedIn: boolean
@@ -7,17 +6,13 @@ export async function verifySas9Login(loginPopup: Window): Promise<{
let isLoggedIn = false
let startTime = new Date()
let elapsedSeconds = 0
do {
await delay(1000)
if (loginPopup.closed) break
isLoggedIn =
loginPopup.window.location.href.includes('SASLogon') &&
loginPopup.window.document.body.innerText.includes(
getExpectedLogInSuccessHeader()
)
loginPopup.window.document.body.innerText.includes('You have signed in.')
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)

View File

@@ -1,5 +1,4 @@
import { delay } from '../utils'
import { getExpectedLogInSuccessHeader } from './'
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
isLoggedIn: boolean
@@ -7,32 +6,23 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
let isLoggedIn = false
let startTime = new Date()
let elapsedSeconds = 0
do {
await delay(1000)
if (loginPopup.closed) break
isLoggedIn = isLoggedInSASVIYA()
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
let isAuthorized = false
startTime = new Date()
do {
await delay(1000)
if (loginPopup.closed) break
isAuthorized =
loginPopup.window.location.href.includes('SASLogon') ||
loginPopup.window.document.body?.innerText?.includes(
getExpectedLogInSuccessHeader()
'You have signed in.'
)
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isAuthorized && elapsedSeconds < 5 * 60)

View File

@@ -1,6 +1,5 @@
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import { convertToCSV } from '../utils/convertToCsv'
import { isNode } from '../utils'
/**
* One of the approaches SASjs takes to send tables-formatted JSON (see README)
@@ -27,15 +26,12 @@ export const generateFileUploadForm = (
)
}
// INFO: unfortunately it is not possible to check if formData is instance of NodeFormData or FormData because it will return true for both
if (isNode()) {
// INFO: environment is Node and formData is instance of NodeFormData
;(formData as NodeFormData).append(name, csv, {
if (typeof FormData === 'undefined' && formData instanceof NodeFormData) {
formData.append(name, csv, {
filename: `${name}.csv`,
contentType: 'application/csv'
})
} else {
// INFO: environment is Browser and formData is instance of FormData
const file = new Blob([csv], {
type: 'application/csv'
})

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
import { splitChunks } from '../utils/splitChunks'

View File

@@ -1,7 +1,4 @@
import { generateFileUploadForm } from '../generateFileUploadForm'
import { convertToCSV } from '../../utils/convertToCsv'
import NodeFormData from 'form-data'
import * as isNodeModule from '../../utils/isNode'
describe('generateFileUploadForm', () => {
beforeAll(() => {
@@ -14,94 +11,44 @@ describe('generateFileUploadForm', () => {
;(global as any).Blob = BlobMock
})
describe('browser', () => {
afterAll(() => {
jest.restoreAllMocks()
})
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]
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]
jest.spyOn(formData, 'append').mockImplementation(() => {})
jest.spyOn(formData, 'append').mockImplementation(() => {})
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
generateFileUploadForm(formData, testTableWithNullVars)
generateFileUploadForm(formData, testTableWithNullVars)
expect(formData.append).toHaveBeenCalledOnce()
expect(formData.append).toHaveBeenCalledWith(
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.'
)
)
})
expect(formData.append).toHaveBeenCalledOnce()
expect(formData.append).toHaveBeenCalledWith(
tableName,
{},
`${tableName}.csv`
)
})
describe('node', () => {
it('should generate file upload form from data', () => {
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)
it('should throw an error if too large string was provided', () => {
const formData = new FormData()
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
jest.spyOn(formData, 'append').mockImplementation(() => {})
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.'
)
expect(() => generateFileUploadForm(formData, data)).toThrow(
new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
})
)
})
})

View File

@@ -1,7 +1,8 @@
import {
getValidJson,
parseSasViyaDebugResponse,
parseWeboutResponse
parseWeboutResponse,
SASJS_LOGS_SEPARATOR
} from '../utils'
import { UploadFile } from '../types/UploadFile'
import {
@@ -92,24 +93,15 @@ export class FileUploader extends BaseJobExecutor {
this.requestClient,
config.serverUrl
)
break
case ServerType.Sas9:
jsonResponse =
typeof res.result === 'string'
? parseWeboutResponse(res.result, uploadUrl)
: res.result
break
case ServerType.Sasjs:
jsonResponse =
typeof res.result === 'string'
? getValidJson(res.result)
: res.result
break
}
} else {
} else if (this.serverType !== ServerType.Sasjs) {
jsonResponse =
typeof res.result === 'string'
? getValidJson(res.result)

View File

@@ -51,7 +51,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
if (config.debug) {
requestParams['_omittextlog'] = 'false'
requestParams['_omitSessionResults'] = 'false'
requestParams['_omitsessionresults'] = 'false'
requestParams['_debug'] = 131
}

View File

@@ -1,6 +1,6 @@
import * as https from 'https'
import { ServerType } from '@sasjs/utils/types'
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import { ErrorResponse } from '../types/errors'
import { convertToCSV, isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor'

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import {
AuthConfig,
ExtraResponseAttributes,
@@ -10,14 +10,10 @@ import {
LoginRequiredError
} from '../types/errors'
import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { RequestClient } from '../request/RequestClient'
import { getFormData } from '../utils'
import {
isRelativePath,
appendExtraResponseAttributes,
getValidJson
} from '../utils'
import { RequestClient } from '../request/RequestClient'
import { isRelativePath, appendExtraResponseAttributes } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
export class SasjsJobExecutor extends BaseJobExecutor {
@@ -53,7 +49,8 @@ export class SasjsJobExecutor extends BaseJobExecutor {
* Use the available form data object (FormData in Browser, NodeFormData in
* Node)
*/
let formData = getFormData()
let formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) {
// file upload approach
@@ -73,10 +70,8 @@ export class SasjsJobExecutor extends BaseJobExecutor {
/* The NodeFormData object does not set the request header - so, set it */
const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${
formData.getHeaders()['content-type']
}`
: 'multipart/form-data'
? `multipart/form-data; boundary=${formData.getBoundary()}`
: undefined
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(
@@ -94,18 +89,12 @@ export class SasjsJobExecutor extends BaseJobExecutor {
)
}
const { result } = res
if (result && typeof result === 'string' && result.trim())
res.result = getValidJson(result)
this.requestClient!.appendRequest(res, sasJob, config.debug)
const responseObject = appendExtraResponseAttributes(
res,
extraResponseAttributes
)
resolve(responseObject)
})
.catch(async (e: Error) => {

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data'
import * as NodeFormData from 'form-data'
import {
AuthConfig,
ExtraResponseAttributes,
@@ -17,10 +17,10 @@ import {
isRelativePath,
parseSasViyaDebugResponse,
appendExtraResponseAttributes,
parseWeboutResponse,
getFormData
getValidJson
} from '../utils'
import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
export interface WaitingRequstPromise {
promise: Promise<any> | null
@@ -113,7 +113,8 @@ export class WebJobExecutor extends BaseJobExecutor {
* Use the available form data object (FormData in Browser, NodeFormData in
* Node)
*/
let formData = getFormData()
let formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) {
const stringifiedData = JSON.stringify(data)
@@ -150,10 +151,8 @@ export class WebJobExecutor extends BaseJobExecutor {
/* The NodeFormData object does not set the request header - so, set it */
const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${
formData.getHeaders()['content-type']
}`
: 'multipart/form-data'
? `multipart/form-data; boundary=${formData.getBoundary()}`
: undefined
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(
@@ -185,6 +184,10 @@ export class WebJobExecutor extends BaseJobExecutor {
}
}
if (typeof jsonResponse === 'string') {
jsonResponse = getValidJson(jsonResponse)
}
const responseObject = appendExtraResponseAttributes(
{ result: jsonResponse, log: res.log },
extraResponseAttributes

View File

@@ -1,274 +0,0 @@
import { validateInput, compareTimestamps } from '../../utils'
import { SASjsConfig, UploadFile, LoginMechanism } from '../../types'
import { AuthManager } from '../../auth'
import {
ServerType,
AuthConfig,
ExtraResponseAttributes
} from '@sasjs/utils/types'
import { RequestClient } from '../../request/RequestClient'
import { FileUploader } from '../../job-execution/FileUploader'
import { WebJobExecutor } from './WebJobExecutor'
import { ErrorResponse } from '../../types/errors/ErrorResponse'
import { LoginOptions, LoginResult } from '../../types/Login'
const defaultConfig: SASjsConfig = {
serverUrl: '',
pathSASJS: '/SASjsApi/stp/execute',
pathSAS9: '/SASStoredProcess/do',
pathSASViya: '/SASJobExecution',
appLoc: '/Public/seedapp',
serverType: ServerType.Sas9,
debug: false,
contextName: 'SAS Job Execution compute context',
useComputeApi: null,
loginMechanism: LoginMechanism.Default
}
/**
* SASjs is a JavaScript adapter for SAS.
*
*/
export default class SASjs {
private sasjsConfig: SASjsConfig = new SASjsConfig()
private jobsPath: string = ''
private fileUploader: FileUploader | null = null
private authManager: AuthManager | null = null
private requestClient: RequestClient | null = null
private webJobExecutor: WebJobExecutor | null = null
constructor(config?: Partial<SASjsConfig>) {
this.sasjsConfig = {
...defaultConfig,
...config
}
this.setupConfiguration()
}
/**
* Logs into the SAS server with the supplied credentials.
* @param username - a string representing the username.
* @param password - a string representing the password.
* @param clientId - a string representing the client ID.
*/
public async logIn(
username?: string,
password?: string,
clientId?: string,
options: LoginOptions = {}
): Promise<LoginResult> {
if (this.sasjsConfig.loginMechanism === LoginMechanism.Default) {
if (!username || !password)
throw new Error(
'A username and password are required when using the default login mechanism.'
)
return this.authManager!.logIn(username, password)
}
if (typeof window === typeof undefined) {
throw new Error(
'The redirected login mechanism is only available for use in the browser.'
)
}
return this.authManager!.redirectedLogIn(options)
}
/**
* Logs out of the configured SAS server.
*/
public logOut() {
return this.authManager!.logOut()
}
/**
* Returns the current SASjs configuration.
*
*/
public getSasjsConfig() {
return this.sasjsConfig
}
/**
* this method returns an array of SASjsRequest
* @returns SASjsRequest[]
*/
public getSasRequests() {
const requests = [...this.requestClient!.getRequests()]
const sortedRequests = requests.sort(compareTimestamps)
return sortedRequests
}
/**
* Sets the debug state. Turning this on will enable additional logging in the adapter.
* @param value - boolean indicating debug state (on/off).
*/
public setDebugState(value: boolean) {
this.sasjsConfig.debug = value
}
/**
* Uploads a file to the given service.
* @param sasJob - the path to the SAS program (ultimately resolves to
* the SAS `_program` parameter to run a Job Definition or SAS 9 Stored
* Process). Is prepended at runtime with the value of `appLoc`.
* @param files - array of files to be uploaded, including File object and file name.
* @param params - request URL parameters.
* @param config - provide any changes to the config here, for instance to
* enable/disable `debug`. Any change provided will override the global config,
* for that particular function call.
* @param loginRequiredCallback - a function that is called if the
* user is not logged in (eg to display a login form). The request will be
* resubmitted after successful login.
*/
public async uploadFile(
sasJob: string,
files: UploadFile[],
params: { [key: string]: any } | null,
config: { [key: string]: any } = {},
loginRequiredCallback?: () => any
) {
config = {
...this.sasjsConfig,
...config
}
const data = { files, params }
return await this.fileUploader!.execute(
sasJob,
data,
config,
loginRequiredCallback
)
}
/**
* Makes a request to program specified in `SASjob` (could be a Viya Job, a
* SAS 9 Stored Process, or a SASjs Server Stored Program). The response
* object will always contain table names in lowercase, and column names in
* uppercase. Values are returned formatted by default, unformatted
* values can be configured as an option in the `%webout` macro.
*
* @param sasJob - the path to the SAS program (ultimately resolves to
* the SAS `_program` parameter to run a Job Definition or SAS 9 Stored
* Process). Is prepended at runtime with the value of `appLoc`.
* @param data - a JSON object containing one or more tables to be sent to
* SAS. For an example of the table structure, see the project README. This
* value can be `null` if no inputs are required.
* @param config - provide any changes to the config here, for instance to
* enable/disable `debug`. Any change provided will override the global config,
* for that particular function call.
* @param loginRequiredCallback - a function that is called if the
* user is not logged in (eg to display a login form). The request will be
* resubmitted after successful login.
* When using a `loginRequiredCallback`, the call to the request will look, for example, like so:
* `await request(sasJobPath, data, config, () => setIsLoggedIn(false))`
* If you are not passing in any data and configuration, it will look like so:
* `await request(sasJobPath, {}, {}, () => setIsLoggedIn(false))`
* @param extraResponseAttributes - a array of predefined values that are used
* to provide extra attributes (same names as those values) to be added in response
* Supported values are declared in ExtraResponseAttributes type.
*/
public async request(
sasJob: string,
data: { [key: string]: any } | null,
config: { [key: string]: any } = {},
loginRequiredCallback?: () => any,
authConfig?: AuthConfig,
extraResponseAttributes: ExtraResponseAttributes[] = []
) {
config = {
...this.sasjsConfig,
...config
}
const validationResult = validateInput(data)
// status is true if the data passes validation checks above
if (validationResult.status) {
return await this.webJobExecutor!.execute(
sasJob,
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
)
} else {
return Promise.reject(new ErrorResponse(validationResult.msg))
}
}
/**
* Checks whether a session is active, or login is required.
* @returns - a promise which resolves with an object containing two values - a boolean `isLoggedIn`, and a string `userName`.
*/
public async checkSession() {
return this.authManager!.checkSession()
}
private setupConfiguration() {
if (
this.sasjsConfig.serverUrl === undefined ||
this.sasjsConfig.serverUrl === ''
) {
if (typeof location !== 'undefined') {
let url = `${location.protocol}//${location.hostname}`
if (location.port) url = `${url}:${location.port}`
this.sasjsConfig.serverUrl = url
} else {
this.sasjsConfig.serverUrl = ''
}
}
if (this.sasjsConfig.serverUrl.slice(-1) === '/') {
this.sasjsConfig.serverUrl = this.sasjsConfig.serverUrl.slice(0, -1)
}
if (!this.requestClient) {
this.requestClient = new RequestClient(
this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit,
this.sasjsConfig.verbose
)
} else {
this.requestClient.setConfig(
this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions
)
}
this.jobsPath = this.sasjsConfig.pathSAS9
this.authManager = new AuthManager(
this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!,
this.requestClient,
this.resendWaitingRequests
)
this.fileUploader = new FileUploader(
this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!,
this.jobsPath,
this.requestClient
)
this.webJobExecutor = new WebJobExecutor(
this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!,
this.jobsPath,
this.requestClient
)
}
private resendWaitingRequests = async () => {
await this.webJobExecutor?.resendWaitingRequests()
await this.fileUploader?.resendWaitingRequests()
}
}

View File

@@ -1,156 +0,0 @@
import {
AuthConfig,
ExtraResponseAttributes,
ServerType
} from '@sasjs/utils/types'
import {
ErrorResponse,
JobExecutionError,
LoginRequiredError
} from '../../types/errors'
import { RequestClient } from '../../request/RequestClient'
import {
isRelativePath,
appendExtraResponseAttributes,
convertToCSV
} from '../../utils'
import { BaseJobExecutor } from '../../job-execution/JobExecutor'
import { parseWeboutResponse } from '../../utils/parseWeboutResponse'
export interface WaitingRequstPromise {
promise: Promise<any> | null
resolve: any
reject: any
}
export class WebJobExecutor extends BaseJobExecutor {
constructor(
serverUrl: string,
serverType: ServerType,
private jobsPath: string,
private requestClient: RequestClient
) {
super(serverUrl, serverType)
}
async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any,
authConfig?: AuthConfig,
extraResponseAttributes: ExtraResponseAttributes[] = []
) {
const loginCallback = loginRequiredCallback
const program = isRelativePath(sasJob)
? config.appLoc
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
: sasJob
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
let requestParams = {
...this.getRequestParams(config)
}
let formData = new FormData()
if (data) {
try {
formData = generateFileUploadForm(formData, data)
} catch (e: any) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}
for (const key in requestParams) {
if (requestParams.hasOwnProperty(key)) {
formData.append(key, requestParams[key])
}
}
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(apiUrl, formData, authConfig?.access_token)
.then(async (res: any) => {
this.requestClient!.appendRequest(res, sasJob, config.debug)
const jsonResponse =
config.debug && typeof res.result === 'string'
? parseWeboutResponse(res.result, apiUrl)
: res.result
const responseObject = appendExtraResponseAttributes(
{ result: jsonResponse, log: res.log },
extraResponseAttributes
)
resolve(responseObject)
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
this.requestClient!.appendRequest(e, sasJob, config.debug)
reject(new ErrorResponse(e?.message, e))
}
if (e instanceof LoginRequiredError) {
if (!loginRequiredCallback) {
reject(
new ErrorResponse(
'Request is not authenticated. Make sure .env file exists with valid credentials.',
e
)
)
}
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
).then(
(res: any) => {
resolve(res)
},
(err: any) => {
reject(err)
}
)
})
if (loginCallback) await loginCallback()
} else reject(new ErrorResponse(e?.message, e))
})
})
return requestPromise
}
}
/**
* One of the approaches SASjs takes to send tables-formatted JSON (see README)
* to SAS is as multipart form data, where each table is provided as a specially
* formatted CSV file.
*/
const generateFileUploadForm = (formData: FormData, data: any): FormData => {
for (const tableName in data) {
if (!Array.isArray(data[tableName])) continue
const name = tableName
const csv = convertToCSV(data, tableName)
if (csv === 'ERROR: LARGE STRING LENGTH') {
throw new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
}
const file = new Blob([csv], {
type: 'application/csv'
})
formData.append(name, file, `${name}.csv`)
}
return formData
}

Some files were not shown because too many files have changed in this diff Show More