1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-15 18:54:36 +00:00

Compare commits

..

191 Commits

Author SHA1 Message Date
Allan Bowe
ffd6bc5a5c Merge pull request #836 from sasjs/issue-835
feat(auth): added multi-language support to logIn method
2024-06-21 13:40:20 +01:00
Yury
c2e64d9ba6 chore: cleanup 2024-06-21 11:32:58 +03:00
Yury
a90f699abd feat(auth): added utils to get and check login header 2024-06-21 11:24:06 +03:00
Yury
2cca192f88 chore(auth-manager): added comment 2024-06-20 17:38:56 +03:00
Yury
053b07769a feat(auth): added multi-language support to logIn method 2024-06-20 17:15:05 +03:00
Yury Shkoda
4c4511913c Merge pull request #834 from sasjs/redirectLogin-fix
fix(auth-manager): fixed redirectedLoginUrl
2024-02-19 15:59:04 +03:00
Yury
8c64c24f3c chore: left a comment 2024-02-19 15:26:26 +03:00
Yury
1f2f445002 chore: fixed SASLogon URL in AuthManager test 2024-02-19 11:06:09 +03:00
Yury
6afa056a86 chore: testing 2024-02-19 10:58:23 +03:00
Yury
fe47ca1152 fix(auth-manager): fixed redirectedLoginUrl 2024-02-19 08:50:19 +03:00
Yury Shkoda
10da691f0f Merge pull request #833 from sasjs/parent-session-state-check
fix(poll-job-state): fixed checking session state
2023-09-15 12:12:24 +03:00
Yury Shkoda
318f9694cb test: updated unit tests threshold 2023-09-15 08:58:46 +03:00
Yury Shkoda
56e6131e5c fix(poll-job-state): fixed checking session state 2023-09-15 08:51:39 +03:00
5dfee30875 Merge pull request #832 from sasjs/parent-session-state-check
feat(job-state): added session state check to doPoll func
2023-09-11 13:03:24 +02:00
Yury Shkoda
3a186bc55c feat(job-state): added session state check to doPoll func 2023-09-11 11:17:05 +03:00
Yury Shkoda
0359fcb6be Merge pull request #830 from sasjs/issue-829
fix(execute-script): fixed executing jobs on viya using compute api
2023-09-05 15:20:38 +03:00
Yury Shkoda
f2997169cb chore: renamed executeScript to executeOnComputeApi 2023-09-05 13:09:28 +03:00
Yury Shkoda
451f2dfaca fix(execute-script): fixed executing jobs on viya using compute api 2023-09-04 18:55:59 +03:00
Yury Shkoda
38e11f1771 Merge pull request #828 from sasjs/verbose-mode-issue
Fixed verbose mode for all response statuses
2023-08-17 15:20:49 +03:00
Yury Shkoda
259b6b3ff2 test(request-client): revert changed port 2023-08-17 13:44:08 +03:00
Yury Shkoda
5b2d9e675f test: set coverage threshold 2023-08-17 13:29:57 +03:00
Yury Shkoda
8dd4ab8cec fix(verbose-mode): fixed handling axios errors 2023-08-17 13:29:27 +03:00
Allan Bowe
34135b889f Merge pull request #827 from sasjs/cli-issue-1367
Bleached Verbose Mode and unit testing pollJobState
2023-08-14 18:36:20 +01:00
Yury Shkoda
62f4577b64 fix(request-client): fixed setVerboseMode method 2023-08-14 17:01:47 +03:00
Yury Shkoda
7a4feddd82 feat: added public method executeJob 2023-08-14 17:01:15 +03:00
Yury Shkoda
681abf5b3b chore(ci-cd): added sleep step before running cypress 2023-08-14 11:25:57 +03:00
Yury Shkoda
46c6d3e7f4 test(poll-job-state): covered changing polling strategies 2023-08-14 10:55:45 +03:00
Yury Shkoda
5731b0f9b1 refactor(request-client): put related types in one file 2023-08-14 10:55:09 +03:00
Yury Shkoda
f18a523087 feat(request-client): added bleached verbose mode 2023-08-14 10:50:11 +03:00
Yury Shkoda
8cbd292f13 Merge pull request #826 from sasjs/gh-action-fix
chore(gh-action): fixed access token
2023-08-09 15:24:43 +03:00
Yury Shkoda
4851f25753 chore(gh-action): fixed access token 2023-08-08 15:46:56 +03:00
Yury Shkoda
5756638dc2 Merge pull request #825 from sasjs/verboseMode
feat(request-client): made verbose mode easier to configure
2023-08-01 09:52:59 +03:00
Yury Shkoda
e511cd613c docs(verbose): fixed docs 2023-07-31 17:41:04 +03:00
Yury Shkoda
2119c81ebb feat(sasjs-config): added verbose option 2023-07-31 17:37:39 +03:00
Yury Shkoda
ea4b30d6ef feat(request-client): made verbose mode easier to configure 2023-07-31 16:34:09 +03:00
Yury Shkoda
f1e1b33571 Merge pull request #824 from sasjs/startComputeJob-issue
feat(request-client): implemented verbose mode
2023-07-31 10:51:02 +03:00
Yury Shkoda
ccb8599f00 docs(request-client): added comments 2023-07-28 11:55:52 +03:00
Yury Shkoda
5bcd17096b feat(request-client): implemented verbose mode 2023-07-27 19:29:51 +03:00
Allan Bowe
d744ee12a3 Merge pull request #823 from sasjs/@sasjs/server-response-fix
feat(sasjs-request-client): improved parseResponse method
2023-07-26 11:45:04 +01:00
Yury Shkoda
5f15226cd9 test(sasjs-request-client): removed unnecessary part of the log 2023-07-25 17:31:39 +03:00
Yury Shkoda
f31ea28b9c refactor(sasjs-request-client): used SASJS_LOGS_SEPARATOR const 2023-07-25 16:08:16 +03:00
Yury Shkoda
e315e4a619 feat(sasjs-request-client): improved parseResponse method 2023-07-25 16:01:35 +03:00
Yury Shkoda
76bf5b88e9 Merge pull request #818 from sasjs/deps-bump
Dependencies bump
2023-07-12 09:50:50 +03:00
Yury Shkoda
a97ac4eaa6 chore: commiting changes 2023-07-11 15:36:13 +03:00
Yury Shkoda
37cfea6ca7 chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/loader-utils-2.0.4' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:53:19 +03:00
Yury Shkoda
f74c8aca57 chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/json5-1.0.2' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:52:28 +03:00
Yury Shkoda
77baaabfcd chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/http-cache-semantics-4.1.1' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:51:32 +03:00
Yury Shkoda
510ba771f0 chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/webpack-5.76.3' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:50:38 +03:00
Allan Bowe
6fce65f4c8 Merge pull request #817 from sasjs/request-sasjs-fix
fix(file-upload-form): fixed form data for node env
2023-07-11 09:58:57 +01:00
Yury Shkoda
fe03faa59f chore(file-upload-form): left comments 2023-07-11 09:26:36 +03:00
Yury Shkoda
6272eeda23 fix(form-data): fixed formData type check 2023-07-10 19:14:47 +03:00
Yury Shkoda
104d1b88b3 chore(deps): bimped tough-cookie and @types/tough-cookie 2023-07-10 17:07:39 +03:00
Yury Shkoda
0d9ba36de8 fix(file-upload-form): fixed form data for node env 2023-07-06 15:49:24 +03:00
Yury Shkoda
4e7a845d99 Merge pull request #816 from sasjs/ci/cd-workwlows-node-version
chore(ci-cd): used Node lts/hydrogen version
2023-07-06 12:42:12 +03:00
Yury Shkoda
716cc513ff chore(ci-cd): used Node lts/hydrogen version 2023-07-05 16:10:45 +03:00
Yury Shkoda
22edcb0a8e Merge pull request #810 from sasjs/pollJobState-improvements
Poll job state improvements
2023-07-05 11:15:42 +03:00
Yury Shkoda
aedf5c1734 chore: Merge branch 'master' of github.com:sasjs/adapter into pollJobState-improvements 2023-07-05 10:49:12 +03:00
Yury Shkoda
784bd20ee0 Merge pull request #814 from sasjs/issue-811-fixed
Issue 811 fixed
2023-07-05 10:27:04 +03:00
Yury Shkoda
61db1e0609 test: fixed unit tests 2023-06-23 18:04:48 +03:00
Yury Shkoda
5c589a6af3 chore: reverted dev changes to build.yml 2023-06-23 17:52:46 +03:00
Yury Shkoda
275cd6dbd3 chore: debugging 2023-06-23 17:20:16 +03:00
Yury Shkoda
d874e07889 fix(file-uploader): fixed parsing response for SASJS 2023-06-23 16:37:25 +03:00
Yury Shkoda
1648cf28d5 chore: Merge branch 'master' of github.com:sasjs/adapter into issue-811-fixed 2023-06-23 15:26:01 +03:00
a4aaeba31c fix: file upload with sasjs server type, json not parsed 2023-06-22 12:48:40 +02:00
Yury Shkoda
6bf68a315c fix(sasjs-utils): fixed imports 2023-06-22 13:37:07 +03:00
Allan Bowe
c0f78d0c1e fix: updating example.html to use v4 of the adapter 2023-06-22 08:56:32 +01:00
Yury Shkoda
e0aebc169f chore: debugging 2023-06-21 18:28:39 +03:00
Yury Shkoda
9a50e5cb63 chore: debugging 2023-06-21 18:16:20 +03:00
Yury Shkoda
a51923dad7 chore: debugging 2023-06-21 18:01:21 +03:00
Yury Shkoda
9aee77f0e3 chore: debugging 2023-06-21 17:44:24 +03:00
Yury Shkoda
c32d037063 chore: debugging 2023-06-21 17:34:16 +03:00
Yury Shkoda
94f7492c31 chore: debugging 2023-06-21 17:19:13 +03:00
Yury Shkoda
d29e0a0f57 chore(sasjs-tests): bumped node-sass 2023-06-21 16:36:22 +03:00
Yury Shkoda
8d7cc11db5 chore(sasjs-tests): bumped node-sass 2023-06-21 16:35:33 +03:00
Yury Shkoda
28e9d1cc6b test(get-token): covered getTokenRequestErrorPrefix 2023-06-21 16:34:26 +03:00
Yury Shkoda
375cec48ca feat(get-token): improved error prefix 2023-06-21 16:33:45 +03:00
7d826685f7 Merge pull request #813 from sasjs/sasjs-job-execute
fix: issue with parsing json in sasjs job executor
2023-06-15 16:08:08 +02:00
f42f6bca00 fix: issue with parsing json in sasjs job executor 2023-06-14 15:08:11 +02:00
Yury Shkoda
4440e5d1f9 fix(types): fixed PollOptions exports 2023-05-17 14:10:17 +03:00
Yury Shkoda
f484a5a6a1 refactor(poll-job-state): updated types and func attributes 2023-05-17 11:16:35 +03:00
Yury Shkoda
5c74186bab feat(poll-strategy): added subsequentStrategies to PollStrategy 2023-05-16 17:48:04 +03:00
Yury Shkoda
ea68c3dff3 docs(poll-job-state): updated docs 2023-05-16 17:42:27 +03:00
Yury Shkoda
153b285670 chore(poll-job-status): renamed PollOptions to PollStrategy and added docs 2023-05-15 16:32:07 +03:00
Yury Shkoda
f9f4aa5aa6 chore(reviewer-lottery): removed QA group 2023-05-15 14:53:55 +03:00
Yury Shkoda
bd02656b3c docs(poll-job-state): added comments 2023-05-15 14:36:18 +03:00
Yury Shkoda
991519a13d fix(execute-job): added error object if it present 2023-05-15 14:26:24 +03:00
Yury Shkoda
615c9d012e feat(poll-job-state): implemented polling strategies 2023-05-15 14:24:11 +03:00
Yury Shkoda
bd872e0e75 Merge pull request #809 from sasjs/webout-issue
fix(sasjs-request-client): fixed response parsing
2023-05-10 16:26:13 +03:00
Yury Shkoda
a14a1663fc fix(sasjs-request-client): fixed response parsing 2023-05-10 16:07:25 +03:00
Yury Shkoda
2482a0c674 Merge pull request #807 from sasjs/issue-245
fix(deploy-service-pack): fixed redeployment
2023-05-09 17:12:11 +03:00
Yury Shkoda
a19de50e67 fix(create-folder): improved error message 2023-05-09 16:59:18 +03:00
Yury Shkoda
860c9f907c fix(deploy-service-pack): fixed redeployment 2023-05-09 10:43:23 +03:00
Allan Bowe
d7126a6878 Merge pull request #802 from sasjs/fix-ci
chore(ci): install vpn and run sasjs tests
2023-05-04 10:51:34 +01:00
Yury Shkoda
a69ebd0fd3 Merge pull request #806 from sasjs/sasjs-job-execution
fix(job-execution): fixed webout for SASJS server type
2023-05-04 12:42:11 +03:00
Yury Shkoda
0f47326bb6 chore(execute-script): improved code style 2023-05-04 12:21:22 +03:00
Yury Shkoda
2d6efa2437 fix(job-execution): fixed webout for SASJS server type 2023-05-04 10:57:24 +03:00
Yury Shkoda
da7579a2bb Merge pull request #805 from sasjs/webpack-fix
fix(packaging): fixed output file for Node
2023-04-21 17:28:52 +03:00
Yury Shkoda
657e415c0c fix(packaging): fixed output file for Node 2023-04-21 16:02:47 +03:00
Allan Bowe
8fa908a201 Merge pull request #804 from sasjs/refrerefreshTokensForViya-fix
fix(refreshTokensForViya): fixed FormData logic
2023-04-21 09:53:55 +01:00
Yury Shkoda
a29b7f3b92 docs(refreshTokensForViya): added notes about limitation 2023-04-21 11:06:48 +03:00
Yury Shkoda
8360519408 fix(refreshTokensForViya): added throw error if not Node 2023-04-21 11:03:41 +03:00
Yury Shkoda
a71d422528 fix(refreshTokensForViya): fixed FormData logic 2023-04-20 16:13:22 +03:00
Allan Bowe
7d37e4d79d Merge pull request #793 from sasjs/session-manager-improvements
Session manager improvements
2023-04-19 15:08:42 +01:00
Yury Shkoda
7ae21c160a docs(session manager): added info about hot sessions 2023-04-19 16:01:49 +03:00
bfefdb65a3 chore: addressed comments 2023-04-18 00:45:03 +05:00
bbe52562da ci: sasjs tests 2023-04-10 13:15:41 +02:00
48917bb4d9 ci: sasjs tests 2023-04-10 12:22:28 +02:00
81c3d2a3dc style: lint 2023-04-10 12:10:41 +02:00
0052d5d340 ci: sasjs tests logged in fix 2023-04-10 12:08:01 +02:00
a633bda24d ci: sasjs tests 2023-04-10 10:05:10 +02:00
e951ea0ab2 chore: quick fix 2023-04-08 01:13:11 +05:00
b75ab13eb7 chore: update the command for running cypress 2023-04-07 23:50:11 +05:00
4de34cc8b0 chore: add streaming config and fix apploc 2023-04-07 17:06:08 +05:00
23a789b383 chore: add refresh token to .env.4gl 2023-04-06 23:13:23 +05:00
156f1b1180 chore: create .env.4gl at proper place 2023-04-06 22:43:32 +05:00
491e36d703 chore: install sasjs cli 2023-04-06 22:31:51 +05:00
06b6e48a16 chore: deploy the streaming app to sas9.4gl.io 2023-04-06 22:07:18 +05:00
9bebd356ca chore: deamonize npm start process 2023-04-06 16:28:33 +05:00
171f9bc7b9 chore(ci): install vpn and run sasjs tests locally 2023-04-06 16:03:16 +05:00
Allan Bowe
7cbc2bda08 Merge pull request #801 from sasjs/issue-680
ErrorBody interface made public
2023-04-06 12:02:15 +01:00
Allan Bowe
5bc2afba8c Merge pull request #800 from sasjs/sasjstestservices
chore: adding services in seperate (deployable) files as alternative …
2023-04-06 11:57:52 +01:00
dd3a7fe393 fix: ErrorBody interface made public 2023-04-06 12:45:22 +02:00
allan
ba1b1e6e80 Merge branch 'sasjstestservices' of github.com:sasjs/adapter into sasjstestservices 2023-04-06 11:42:04 +01:00
allan
3daa85a74a chore: updating appLoc to /Public/app/adapter-tests in sasjs-tests 2023-04-06 11:41:54 +01:00
Allan Bowe
d1139857a4 Update invalidJSON.sas 2023-04-06 11:33:31 +01:00
allan
cb106c76cb chore: adding services in seperate (deployable) files as alternative to manually running the code from the README in sasjs-test 2023-04-06 11:31:37 +01:00
Allan Bowe
ab8da28de1 Merge pull request #798 from sasjs/redirected-login-m8
fix: sas9 m8 redirected login
2023-04-05 15:14:16 +01:00
a729d67d3e chore: remove /home leftovers 2023-04-05 15:39:31 +02:00
548a44d665 chore: auth manager test fix 2023-04-04 14:08:47 +02:00
Allan Bowe
afda43fc7f Merge pull request #799 from sasjs/issue-470
fix: regex for extracting login url fixed
2023-04-03 22:21:13 +01:00
5291e7f01c fix: regex for extracting login url fixed 2023-04-03 23:10:27 +05:00
39abdad518 fix: sas9 m8 redirected login 2023-04-03 12:48:32 +02:00
Allan Bowe
6aa12ee950 Merge pull request #797 from sasjs/all-contributors/add-saramartinelli1992
docs: add saramartinelli1992 as a contributor for userTesting, and platform
2023-04-03 11:41:34 +01:00
allcontributors[bot]
b5b5093295 docs: update .all-contributorsrc [skip ci] 2023-04-03 10:41:06 +00:00
allcontributors[bot]
114ca21c17 docs: update README.md [skip ci] 2023-04-03 10:41:05 +00:00
Yury Shkoda
88a1f3eac6 docs(session-manager): improved grammar 2023-03-28 13:00:07 +03:00
Yury Shkoda
b00db86811 chore(session-manager): renamed ErrorResponse interface to ApiErrorResponse 2023-03-27 18:34:44 +03:00
Yury Shkoda
96caadc6b1 test(request-client): fixing self-signed certificate error 2023-03-27 17:08:06 +03:00
Yury Shkoda
074a21c4bb test(session-manager): fixed server url 2023-03-27 16:53:45 +03:00
Yury Shkoda
588a4169bb docs(session-manager): added method descriptions 2023-03-27 16:43:33 +03:00
Yury Shkoda
3d4c01622e chore(session-manager): improved error handling 2023-03-27 16:40:57 +03:00
dependabot[bot]
d166231c12 chore(deps): bump webpack from 5.73.0 to 5.76.3 in /sasjs-tests
Bumps [webpack](https://github.com/webpack/webpack) from 5.73.0 to 5.76.3.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.73.0...v5.76.3)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-27 12:12:06 +00:00
Allan Bowe
6aee95b21d Merge pull request #794 from sasjs/issue-303
fix: modify the regex to handle the loginForm response on latest sas9
2023-03-27 13:09:11 +01:00
3d281abbf8 chore: downgrade pem dev dependency by one minor version 2023-03-27 15:09:10 +05:00
99d783e174 fix: write a message to the log indicating that a login was not attempted as a valid session already exists 2023-03-24 15:11:10 +05:00
Yury Shkoda
33bc8f92cb test(request-client): fixed typo 2023-03-24 12:15:10 +03:00
Yury Shkoda
c1704fff78 feat(session-manager): refactored and covered with tests 2023-03-24 11:39:59 +03:00
17a3d1b8a9 fix: modify the reqgex to handle the loginForm response on latest sas9 2023-03-22 23:33:42 +05:00
Allan Bowe
01af5eb634 Merge pull request #791 from sasjs/sas9-mini
feat: create a minified version for sas9  and web based appications
2023-03-16 13:36:08 +00:00
0c3797e2de chore: deleted package-lock.json and recreated by npm install 2023-03-16 16:22:26 +05:00
c33c509207 chore: merge master 2023-03-16 13:40:22 +05:00
af351d7375 chore: bump webpack 2023-03-16 12:39:39 +05:00
2b53406cac chore: no need for forked version of tough cookie 2023-03-16 00:28:06 +05:00
99cfb8b2af feat: created a minified version of adapter for executing web jobs on sas9 2023-03-16 00:26:08 +05:00
Yury Shkoda
891cc13362 feat(session-manager): improving performance 2023-03-14 10:48:18 +03:00
Yury Shkoda
22fa185715 feat: minified adapter for SAS9 2023-03-14 10:45:03 +03:00
Allan Bowe
dad99557a7 Merge pull request #788 from sasjs/quick-fix
fix: throw error as it is when its an instance of JobExecutionError
2023-03-06 10:15:36 +00:00
c7cc2e5fa4 fix: throw error as it is in sas9RequestClient when its an instance of JobExecutionError 2023-03-06 14:53:42 +05:00
Allan Bowe
2bd7544051 Merge pull request #787 from sasjs/issue-786
fix: removing .do from loginUrl mechanism
2023-02-21 18:56:12 +00:00
1fb972d88a chore: improved url match 2023-02-21 16:10:14 +01:00
64f8f8c893 fix: removing .do from loginUrl mechanism 2023-02-21 16:04:50 +01:00
Sabir Hassan
ddb4a51c55 Merge pull request #785 from sasjs/issue-783
Sasjs executor not re-sending waiting requests
2023-02-15 21:45:16 +05:00
921d6ef364 fix: sasjs executor not re-sending waiting requests 2023-02-15 17:26:23 +01:00
Allan Bowe
105675d46a Merge pull request #784 from sasjs/medjedovicm-issue-template
Create issue_template.md
2023-02-15 16:18:27 +00:00
Allan Bowe
e4addba762 Update issue_template.md 2023-02-15 16:17:02 +00:00
8203e918fd Create issue_template.md 2023-02-15 17:15:24 +01:00
Allan Bowe
2210e43880 chore: update README
Thanks @saramartinelli1992

https://github.com/sasjs/adapter/issues/781#issuecomment-1420943632
2023-02-07 15:20:10 +00:00
dependabot[bot]
4cb150e951 chore(deps): bump http-cache-semantics in /sasjs-tests
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-04 13:53:04 +00:00
Allan Bowe
b04df0bc6d Merge pull request #779 from sasjs/issue-771
fix: bump axios version
2023-02-01 10:23:29 +00:00
98e851b4d8 chore: npm audit fix 2023-01-31 23:08:56 +05:00
84306bea3d fix: bump axios version to 0.27.2 2023-01-31 23:05:54 +05:00
89d32262f8 chore: form-data provides its own type definitions, so you do not need @types/form-data installed 2023-01-31 15:49:40 +05:00
257010f57d chore: axios provides its own type definitions, so you don't need @types/axios installed 2023-01-31 15:48:08 +05:00
eb9991015b chore: fix npm install 2023-01-31 15:45:49 +05:00
Allan Bowe
9d17e87a09 Merge pull request #778 from sasjs/issue-777
fix: improve error message when sasjs runner is not found
2023-01-30 19:12:48 +00:00
55f309e998 chore: quick fix 2023-01-30 23:50:40 +05:00
3d9b40398c fix: improve error message when sasjs runner is not found 2023-01-30 23:22:43 +05:00
Allan Bowe
e0badae973 fix: bumping semantic release package 2023-01-26 13:51:22 +00:00
Allan Bowe
524c561390 Merge pull request #775 from sasjs/import-node-env
fix: createReadStream import where needed
2023-01-26 13:46:39 +00:00
e7ceac1b78 fix: createReadStream import where needed
It's node specific util and othervise adapter was failing on browser environment
2023-01-09 13:07:51 +01:00
dependabot[bot]
fc8598473f chore(deps): bump json5 from 1.0.1 to 1.0.2 in /sasjs-tests
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-08 11:36:38 +00:00
Allan Bowe
72ddd424a5 Merge pull request #772 from sasjs/add-deployZipFile
feat: add a new method deployZipFile in sasjsAPiClient class
2022-12-06 11:25:48 +00:00
85f771d1ed feat: add a new method deployZipFile in sasjsAPiClient class 2022-12-06 14:51:34 +05:00
dependabot[bot]
367e0ae25a chore(deps): bump loader-utils from 2.0.2 to 2.0.4 in /sasjs-tests
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-16 06:56:29 +00:00
Allan Bowe
1a781c3a56 chore: adding matrix room link in action 2022-10-24 21:25:52 +00:00
Allan Bowe
296d4efdfb Merge pull request #768 from sasjs/update-sasjs-tests-deps
fix: vulnerabilities in sasjs-tests
2022-10-24 21:48:22 +01:00
8df09d01de fix: bump deps versions
@sasjs/test-framework
node-sass
2022-10-25 01:41:09 +05:00
Allan Bowe
2d4a9d6dee chore: updating matrix room id for matrixbot 2022-10-24 10:24:46 +00:00
Allan Bowe
38c30f6342 Merge pull request #767 from sasjs/removevpn
fix: removing old vpn files
2022-10-24 11:12:01 +01:00
Allan Bowe
dd72304bc7 fix: removing old vpn files
BREAKING CHANGE: The _previous_ commit introduced some breaking changes, due to method re-organisation, namely:
* merged executableScriptSAS9, executableScriptSASViya and executableScriptSASjs methods to executableScript
* removed deployToSASjs and executeJobSASjs from the main SASjs moodule, as we can use the SASjsApiClient directly for these operations
2022-10-24 10:10:39 +00:00
dependabot[bot]
85dde61baf chore(deps): bump semver-regex from 3.1.3 to 3.1.4
Bumps [semver-regex](https://github.com/sindresorhus/semver-regex) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/sindresorhus/semver-regex/releases)
- [Commits](https://github.com/sindresorhus/semver-regex/commits/v3.1.4)

---
updated-dependencies:
- dependency-name: semver-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-03 23:43:27 +00:00
107 changed files with 13359 additions and 7457 deletions

View File

@@ -106,6 +106,16 @@
"userTesting", "userTesting",
"doc" "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, "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. No PR (that involves a non-trivial code change) should be merged, unless all items below are confirmed! If an urgent fix is needed - use a tar file.
- [ ] Unit tests coverage has been increased and a new threshold is set.
- [ ] All `sasjs-cli` unit tests are passing (`npm test`). - [ ] All `sasjs-cli` unit tests are passing (`npm test`).
- (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)). - (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)).
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya - [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya

12
.github/issue_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## 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,7 +5,3 @@ groups:
- YuryShkoda - YuryShkoda
- medjedovicm - medjedovicm
- sabhas - sabhas
- name: SASjs QA
reviewers: 1
usernames:
- VladislavParhomchik

View File

@@ -1,30 +1,25 @@
cipher AES-256-CBC # Client
setenv FORWARD_COMPATIBLE 1
client client
server-poll-timeout 4 tls-client
nobind
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 443 tcp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
dev tun dev tun
dev-type tun # this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
ns-cert-type server proto tcp
setenv opt tls-version-min 1.0 or-highest remote vpn.4gl.io 7494
reneg-sec 604800 resolv-retry infinite
sndbuf 0 cipher AES-256-CBC
rcvbuf 0 auth SHA256
# NOTE: LZO commands are pushed by the Access Server at connect time. script-security 2
# NOTE: The below line doesn't disable LZO. keepalive 10 120
comp-lzo no remote-cert-tls server
verb 3
setenv PUSH_PEER_INFO
# Keys
ca ca.crt ca ca.crt
cert user.crt cert user.crt
key user.key key user.key
tls-auth tls.key 1 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: actions/checkout@v2
- uses: uesteibar/reviewer-lottery@v1 - uses: uesteibar/reviewer-lottery@v1
with: with:
repo-token: ${{ secrets.GH_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,7 +12,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [lts/fermium] node-version: [lts/hydrogen]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -22,8 +22,9 @@ jobs:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm cache: npm
- name: Check npm audit # FIXME: uncomment 'Check npm audit' step after axios version bump
run: npm audit --production --audit-level=low # - name: Check npm audit
# run: npm audit --production --audit-level=low
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
@@ -39,12 +40,6 @@ jobs:
env: env:
CI: true CI: true
- name: Install SSH Key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.DCGITLAB_KEY }}
known_hosts: 'placeholder'
- name: Write VPN Files - name: Write VPN Files
run: | run: |
echo "$CA_CRT" > .github/vpn/ca.crt echo "$CA_CRT" > .github/vpn/ca.crt
@@ -63,13 +58,16 @@ jobs:
sudo apt install apt-transport-https sudo apt install apt-transport-https
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
sudo apt-key add 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-focal.list 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 update
sudo apt install openvpn3=16~beta+focal sudo apt install openvpn3=17~betaUb22042+jammy
- name: Start Open VPN 3 - name: Start Open VPN 3
run: openvpn3 session-start --config .github/vpn/config.ovpn run: openvpn3 session-start --config .github/vpn/config.ovpn
- name: install pm2
run: npm i -g pm2
- name: Deploy sasjs-tests - name: Deploy sasjs-tests
run: | run: |
npm install -g replace-in-files-cli npm install -g replace-in-files-cli
@@ -80,12 +78,16 @@ jobs:
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
npm run update:adapter && npm run build npm run update:adapter
scp -o stricthostkeychecking=no -r ./build/* ${{ secrets.DCGITLAB_DEPLOY_PATH_VIYA }} pm2 start --name sasjs-test npm -- start
- name: Sleep for 10 seconds
run: sleep 10s
shell: bash
- name: Run cypress on sasjs - name: Run cypress on sasjs
run: | run: |
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}

View File

@@ -11,7 +11,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [lts/fermium] node-version: [lts/hydrogen]
steps: steps:
- name: Checkout - name: Checkout
@@ -37,8 +37,8 @@ jobs:
- name: Push generated docs - name: Push generated docs
uses: peaceiris/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v3
with: with:
github_token: ${{ secrets.GH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages publish_branch: gh-pages
publish_dir: ./docs publish_dir: ./docs
cname: adapter.sasjs.io cname: adapter.sasjs.io

View File

@@ -14,7 +14,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [lts/fermium] node-version: [lts/hydrogen]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -34,10 +34,10 @@ jobs:
run: npm run build run: npm run build
- name: Semantic Release - name: Semantic Release
uses: cycjimmy/semantic-release-action@v2 uses: cycjimmy/semantic-release-action@v3
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Matrix message - name: Send Matrix message
run: curl -XPOST -d "{\"msgtype\":\"m.text\", \"body\":\"New version of @sasjs/adapter has been released! \n Please deploy and run 'dctests' with new adapter to make sure everything is still in place.\"}" https://matrix.4gl.io/_matrix/client/r0/rooms/!BDUPBPEGVvRLKLQUxY:4gl.io/send/m.room.message?access_token=${{ secrets.MATRIX_TOKEN }} run: curl -XPOST -d "{\"msgtype\":\"m.text\", \"body\":\"New version of @sasjs/adapter has been released! \n Please deploy and run 'dctests' with new adapter to make sure everything is still in place.\"}" https://matrix.4gl.io/_matrix/client/r0/rooms/!jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=${{ secrets.MATRIX_TOKEN }}

3
.vscode/settings.json vendored Normal file
View File

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

View File

@@ -20,7 +20,7 @@ SASjs is a open-source framework for building Web Apps on SAS® platforms. You c
1 - `npm install @sasjs/adapter` - for use in a nodeJS project (recommended) 1 - `npm install @sasjs/adapter` - for use in a nodeJS project (recommended)
2 - [Download](https://cdn.jsdelivr.net/npm/@sasjs/adapter@3/index.min.js) and use a copy of the latest JS file 2 - [Download](https://cdn.jsdelivr.net/npm/@sasjs/adapter@4/index.min.js) and use a copy of the latest JS file
3 - Reference directly from the CDN - in which case click [here](https://www.jsdelivr.com/package/npm/@sasjs/adapter?tab=collection) and select "SRI" to get the script tag with the integrity hash. 3 - Reference directly from the CDN - in which case click [here](https://www.jsdelivr.com/package/npm/@sasjs/adapter?tab=collection) and select "SRI" to get the script tag with the integrity hash.
@@ -151,7 +151,19 @@ The `request()` method also has optional parameters such as a config object and
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase. The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off. The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
### Verbose Mode
Set `verbose` to `true` to enable verbose mode that logs a summary of every HTTP response. Verbose mode can be disabled by calling `disableVerboseMode` method or enabled by `enableVerboseMode` method. Verbose mode also supports `bleached` mode that disables extra colors in req/res summary. To enable `bleached` verbose mode, pass `verbose` equal to `bleached` while instantiating an instance of `RequestClient` or to `setVerboseMode` method. Verbose mode can also be enabled/disabled by `startComputeJob` method.
### Session Manager
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.
### Variable Types ### Variable Types
@@ -265,6 +277,7 @@ Configuration on the client side involves passing an object on startup, which ca
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server). * `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode. * `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned. * `debug` - if `true` then SAS Logs and extra debug information is returned.
* `verbose` - optional, if `true` then a summary of every HTTP response is logged.
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section. * `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used. * `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`. * `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
@@ -332,7 +345,7 @@ If you find this library useful, help us grow our star graph!
## Contributors ✨ ## Contributors ✨
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-) [![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@@ -341,18 +354,21 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-start --> <!-- prettier-ignore-start -->
<!-- markdownlint-disable --> <!-- markdownlint-disable -->
<table> <table>
<tr> <tbody>
<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> <tr>
<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" 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"><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" 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"><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" 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"><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" 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"><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" 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"><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> <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>
</tr> <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"><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>
</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>
</table> </table>
<!-- markdownlint-restore --> <!-- markdownlint-restore -->

View File

@@ -14,65 +14,83 @@ context('sasjs-tests', function () {
it('Should have all tests successfull', (done) => { it('Should have all tests successfull', (done) => {
cy.get('body').then(($body) => { cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) { cy.wait(1000).then(() => {
cy.get('input[placeholder="User Name"]').type(username) const startButton = $body.find(
cy.get('input[placeholder="Password"]').type(password) '.ui.massive.icon.primary.left.labeled.button'
cy.get('.submit-button').click() )[0]
}
cy.get('input[placeholder="User Name"]', { timeout: 40000 }) if (
.should('not.exist') !startButton ||
.then(() => { (startButton && !Cypress.dom.isVisible(startButton))
cy.get('.ui.massive.icon.primary.left.labeled.button') ) {
.click() cy.get('input[placeholder="User Name"]').type(username)
.then(() => { cy.get('input[placeholder="Password"]').type(password)
cy.get('.ui.massive.loading.primary.button', { cy.get('.submit-button').click()
timeout: testingFinishTimeout }
})
.should('not.exist') cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.then(() => { .should('not.exist')
cy.get('span.icon.failed') .then(() => {
.should('not.exist') cy.get('.ui.massive.icon.primary.left.labeled.button')
.then(() => { .click()
done() .then(() => {
}) cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
}) })
}) .should('not.exist')
}) .then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
})
})
})
})
}) })
}) })
it('Should have all tests successfull with debug on', (done) => { it('Should have all tests successfull with debug on', (done) => {
cy.get('body').then(($body) => { cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) { cy.wait(1000).then(() => {
cy.get('input[placeholder="User Name"]').type(username) const startButton = $body.find(
cy.get('input[placeholder="Password"]').type(password) '.ui.massive.icon.primary.left.labeled.button'
cy.get('.submit-button').click() )[0]
}
cy.get('.ui.fitted.toggle.checkbox label') if (
.click() !startButton ||
.then(() => { (startButton && !Cypress.dom.isVisible(startButton))
cy.get('input[placeholder="User Name"]', { timeout: 40000 }) ) {
.should('not.exist') cy.get('input[placeholder="User Name"]').type(username)
.then(() => { cy.get('input[placeholder="Password"]').type(password)
cy.get('.ui.massive.icon.primary.left.labeled.button') cy.get('.submit-button').click()
.click() }
.then(() => {
cy.get('.ui.massive.loading.primary.button', { cy.get('.ui.fitted.toggle.checkbox label')
timeout: testingFinishTimeout .click()
}) .then(() => {
.should('not.exist') cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.then(() => { .should('not.exist')
cy.get('span.icon.failed') .then(() => {
.should('not.exist') cy.get('.ui.massive.icon.primary.left.labeled.button')
.then(() => { .click()
done() .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

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

20
cypress/support/index.js Normal file
View File

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

View File

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

View File

@@ -41,7 +41,14 @@ module.exports = {
// ], // ],
// An object that configures minimum threshold enforcement for coverage results // An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined, coverageThreshold: {
global: {
statements: 64.03,
branches: 45.11,
functions: 54.18,
lines: 64.53
}
},
// A path to a custom dependency extractor // A path to a custom dependency extractor
// dependencyExtractor: undefined, // dependencyExtractor: undefined,

7420
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,13 +45,11 @@
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@cypress/webpack-preprocessor": "5.9.1", "@cypress/webpack-preprocessor": "5.9.1",
"@types/axios": "0.14.0",
"@types/express": "4.17.13", "@types/express": "4.17.13",
"@types/form-data": "2.5.0",
"@types/jest": "27.4.0", "@types/jest": "27.4.0",
"@types/mime": "2.0.3", "@types/mime": "2.0.3",
"@types/pem": "1.9.6", "@types/pem": "1.9.6",
"@types/tough-cookie": "4.0.1", "@types/tough-cookie": "4.0.2",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cp": "0.2.0", "cp": "0.2.0",
"cypress": "7.7.0", "cypress": "7.7.0",
@@ -61,29 +59,29 @@
"jest-extended": "2.0.0", "jest-extended": "2.0.0",
"node-polyfill-webpack-plugin": "1.1.4", "node-polyfill-webpack-plugin": "1.1.4",
"path": "0.12.7", "path": "0.12.7",
"pem": "1.14.6", "pem": "1.14.5",
"prettier": "2.7.1", "prettier": "2.8.7",
"process": "0.11.10", "process": "0.11.10",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"semantic-release": "19.0.3", "semantic-release": "19.0.3",
"terser-webpack-plugin": "5.3.1", "terser-webpack-plugin": "5.3.6",
"ts-jest": "27.1.3", "ts-jest": "27.1.3",
"ts-loader": "9.4.0", "ts-loader": "9.4.0",
"tslint": "6.1.3", "tslint": "6.1.3",
"tslint-config-prettier": "1.18.0", "tslint-config-prettier": "1.18.0",
"typedoc": "0.23.15", "typedoc": "0.23.24",
"typedoc-plugin-rename-defaults": "0.4.0", "typedoc-plugin-rename-defaults": "0.6.4",
"typescript": "4.8.3", "typescript": "4.8.3",
"webpack": "5.69.0", "webpack": "5.76.2",
"webpack-cli": "4.9.2" "webpack-cli": "4.9.2"
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@sasjs/utils": "2.48.2", "@sasjs/utils": "2.52.0",
"axios": "0.26.0", "axios": "0.27.2",
"axios-cookiejar-support": "1.0.1", "axios-cookiejar-support": "1.0.1",
"form-data": "4.0.0", "form-data": "4.0.0",
"https": "1.0.0", "https": "1.0.0",
"tough-cookie": "4.0.0" "tough-cookie": "4.1.3"
} }
} }

View File

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

19
sasjs-tests/.sasjslint Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,14 @@
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz", "@sasjs/test-framework": "1.5.7",
"@sasjs/test-framework": "^1.5.6",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/node": "^14.14.41", "@types/node": "^14.14.41",
"@types/react": "^17.0.1", "@types/react": "^16.0.1",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^16.0.0",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"react": "^17.0.1", "react": "^16.0.1",
"react-dom": "^17.0.1", "react-dom": "^16.0.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"typescript": "^4.1.3" "typescript": "^4.1.3"
@@ -22,7 +21,7 @@
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz --legacy-peer-deps", "update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
"deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win", "deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*", "deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests" "deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
@@ -43,6 +42,6 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"node-sass": "^7.0.1" "node-sass": "9.0.0"
} }
} }

View File

@@ -3,8 +3,8 @@
"password": "", "password": "",
"sasJsConfig": { "sasJsConfig": {
"serverUrl": "", "serverUrl": "",
"appLoc": "/Public/app", "appLoc": "/Public/app/adapter-tests/services",
"serverType": "SASVIYA", "serverType": "SASJS",
"debug": false, "debug": false,
"contextName": "sasjs adapter compute context", "contextName": "sasjs adapter compute context",
"useComputeApi": true "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!" echo "Cypress sasjs testing passed!"
else else
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21BDUPBPEGVvRLKLQUxY: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!" echo "Cypress sasjs testing failed!"
exit 1 exit 1
fi fi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

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

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -29,11 +29,33 @@ describe('SASViyaApiClient', () => {
jest jest
.spyOn(requestClient, 'get') .spyOn(requestClient, 'get')
.mockImplementation(() => Promise.reject('Not Found')) .mockImplementation(() => Promise.reject('Not Found'))
const error = await sasViyaApiClient const error = await sasViyaApiClient
.createFolder('test', '/foo') .createFolder('test', '/foo')
.catch((e: any) => e) .catch((e: any) => e)
expect(error).toBeInstanceOf(RootFolderNotFoundError) 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 = () => { const setupMocks = () => {

View File

@@ -25,10 +25,16 @@ import { prefixMessage } from '@sasjs/utils/error'
import { pollJobState } from './api/viya/pollJobState' import { pollJobState } from './api/viya/pollJobState'
import { getTokens } from './auth/getTokens' import { getTokens } from './auth/getTokens'
import { uploadTables } from './api/viya/uploadTables' import { uploadTables } from './api/viya/uploadTables'
import { executeScript } from './api/viya/executeScript' import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
import { getAccessTokenForViya } from './auth/getAccessTokenForViya' import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
import { refreshTokensForViya } from './auth/refreshTokensForViya' import { refreshTokensForViya } from './auth/refreshTokensForViya'
interface JobExecutionResult {
result?: { result: object }
log?: string
error?: object
}
/** /**
* A client for interfacing with the SAS Viya REST API. * A client for interfacing with the SAS Viya REST API.
* *
@@ -270,7 +276,7 @@ export class SASViyaApiClient {
* @param debug - when set to true, the log will be returned. * @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code). * @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session * @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
*/ */
@@ -287,7 +293,7 @@ export class SASViyaApiClient {
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar
): Promise<any> { ): Promise<any> {
return executeScript( return executeOnComputeApi(
this.requestClient, this.requestClient,
this.sessionManager, this.sessionManager,
this.rootFolderName, this.rootFolderName,
@@ -378,12 +384,14 @@ export class SASViyaApiClient {
isForced?: boolean isForced?: boolean
): Promise<Folder> { ): Promise<Folder> {
const logger = process.logger || console const logger = process.logger || console
if (!parentFolderPath && !parentFolderUri) { if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.') throw new Error('Path or URI of the parent folder is required.')
} }
if (!parentFolderUri && parentFolderPath) { if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken) parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
if (!parentFolderUri) { if (!parentFolderUri) {
logger.info( logger.info(
`Parent folder at path '${parentFolderPath}' is not present.` `Parent folder at path '${parentFolderPath}' is not present.`
@@ -394,6 +402,7 @@ export class SASViyaApiClient {
parentFolderPath.lastIndexOf('/') parentFolderPath.lastIndexOf('/')
) )
const newFolderName = `${parentFolderPath.split('/').pop()}` const newFolderName = `${parentFolderPath.split('/').pop()}`
if (newParentFolderPath === '') { if (newParentFolderPath === '') {
throw new RootFolderNotFoundError( throw new RootFolderNotFoundError(
parentFolderPath, parentFolderPath,
@@ -401,20 +410,24 @@ export class SASViyaApiClient {
accessToken accessToken
) )
} }
logger.info( logger.info(
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'` `Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
) )
const parentFolder = await this.createFolder( const parentFolder = await this.createFolder(
newFolderName, newFolderName,
newParentFolderPath, newParentFolderPath,
undefined, undefined,
accessToken accessToken
) )
logger.info( logger.info(
`Parent folder '${newFolderName}' has been successfully created.` `Parent folder '${newFolderName}' has been successfully created.`
) )
parentFolderUri = `/folders/folders/${parentFolder.id}` parentFolderUri = `/folders/folders/${parentFolder.id}`
} else if (isForced && accessToken) { } else if (isForced) {
const folderPath = parentFolderPath + '/' + folderName const folderPath = parentFolderPath + '/' + folderName
const folderUri = await this.getFolderUri(folderPath, accessToken) const folderUri = await this.getFolderUri(folderPath, accessToken)
@@ -427,8 +440,8 @@ export class SASViyaApiClient {
} }
} }
const { result: createFolderResponse } = const { result: createFolderResponse } = await this.requestClient
await this.requestClient.post<Folder>( .post<Folder>(
`/folders/folders?parentFolderUri=${parentFolderUri}`, `/folders/folders?parentFolderUri=${parentFolderUri}`,
{ {
name: folderName, name: folderName,
@@ -436,12 +449,34 @@ export class SASViyaApiClient {
}, },
accessToken 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. // update folder map with newly created folder.
await this.populateFolderMap( await this.populateFolderMap(
`${parentFolderPath}/${folderName}`, `${parentFolderPath}/${folderName}`,
accessToken accessToken
) )
return createFolderResponse return createFolderResponse
} }
@@ -548,6 +583,7 @@ export class SASViyaApiClient {
/** /**
* Exchanges the refresh token for an access token for the given client. * 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 clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with. * @param clientSecret - the client secret to authenticate with.
* @param refreshToken - the refresh token received from the server. * @param refreshToken - the refresh token received from the server.
@@ -591,7 +627,7 @@ export class SASViyaApiClient {
* @param accessToken - an optional access token for an authorized user. * @param accessToken - an optional access token for an authorized user.
* @param waitForResult - a boolean indicating if the function should wait for a result. * @param waitForResult - a boolean indicating if the function should wait for a result.
* @param expectWebout - a boolean indicating whether to expect a _webout response. * @param expectWebout - a boolean indicating whether to expect a _webout response.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
*/ */
@@ -702,11 +738,13 @@ export class SASViyaApiClient {
debug: boolean, debug: boolean,
data?: any, data?: any,
authConfig?: AuthConfig authConfig?: AuthConfig
) { ): Promise<JobExecutionResult> {
let access_token = (authConfig || {}).access_token let access_token = (authConfig || {}).access_token
if (authConfig) { if (authConfig) {
;({ access_token } = await getTokens(this.requestClient, authConfig)) ;({ access_token } = await getTokens(this.requestClient, authConfig))
} }
if (isRelativePath(sasJob) && !this.rootFolderName) { if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error( throw new Error(
'Relative paths cannot be used without specifying a root folder name.' 'Relative paths cannot be used without specifying a root folder name.'
@@ -719,6 +757,7 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob) const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}` ? `${this.rootFolderName}/${folderPath}`
: folderPath : folderPath
await this.populateFolderMap(fullFolderPath, access_token) await this.populateFolderMap(fullFolderPath, access_token)
const jobFolder = this.folderMap.get(fullFolderPath) const jobFolder = this.folderMap.get(fullFolderPath)
@@ -735,9 +774,8 @@ export class SASViyaApiClient {
files = await this.uploadTables(data, access_token) files = await this.uploadTables(data, access_token)
} }
if (!jobToExecute) { if (!jobToExecute) throw new Error(`Job was not found.`)
throw new Error(`Job was not found.`)
}
const jobDefinitionLink = jobToExecute?.links.find( const jobDefinitionLink = jobToExecute?.links.find(
(l) => l.rel === 'getResource' (l) => l.rel === 'getResource'
)?.href )?.href
@@ -777,16 +815,19 @@ export class SASViyaApiClient {
jobDefinition, jobDefinition,
arguments: jobArguments arguments: jobArguments
} }
const { result: postedJob } = await this.requestClient.post<Job>( const { result: postedJob } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`, `${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody, postJobRequestBody,
access_token access_token
) )
const jobStatus = await this.pollJobState(postedJob, authConfig).catch( const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
(err) => { (err) => {
throw prefixMessage(err, 'Error while polling job status. ') throw prefixMessage(err, 'Error while polling job status. ')
} }
) )
const { result: currentJob } = await this.requestClient.get<Job>( const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token access_token
@@ -797,6 +838,7 @@ export class SASViyaApiClient {
const resultLink = currentJob.results['_webout.json'] const resultLink = currentJob.results['_webout.json']
const logLink = currentJob.links.find((l) => l.rel === 'log') const logLink = currentJob.links.find((l) => l.rel === 'log')
if (resultLink) { if (resultLink) {
jobResult = await this.requestClient.get<any>( jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`, `${this.serverUrl}${resultLink}/content`,
@@ -804,11 +846,13 @@ export class SASViyaApiClient {
'text/plain' 'text/plain'
) )
} }
if (debug && logLink) { if (debug && logLink) {
log = await this.requestClient log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token) .get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) .then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
} }
if (jobStatus === 'failed') { if (jobStatus === 'failed') {
throw new JobExecutionError( throw new JobExecutionError(
currentJob.error?.errorCode, currentJob.error?.errorCode,
@@ -816,7 +860,16 @@ export class SASViyaApiClient {
log log
) )
} }
return { result: jobResult?.result, log }
const executionResult: JobExecutionResult = {
result: jobResult?.result,
log
}
const { error } = currentJob
if (error) executionResult.error = error
return executionResult
} }
private async populateFolderMap(folderPath: string, accessToken?: string) { private async populateFolderMap(folderPath: string, accessToken?: string) {
@@ -899,7 +952,7 @@ export class SASViyaApiClient {
return `/folders/folders/${folderDetails.id}` return `/folders/folders/${folderDetails.id}`
} }
private async getRecycleBinUri(accessToken: string) { private async getRecycleBinUri(accessToken?: string) {
const url = '/folders/folders/@myRecycleBin' const url = '/folders/folders/@myRecycleBin'
const { result: folder } = await this.requestClient const { result: folder } = await this.requestClient
@@ -983,7 +1036,7 @@ export class SASViyaApiClient {
sourceFolder: string, sourceFolder: string,
targetParentFolder: string, targetParentFolder: string,
targetFolderName: 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 // 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 const sourceFolderName = sourceFolder.split('/').pop() as string
@@ -1050,7 +1103,7 @@ export class SASViyaApiClient {
* @param folderPath - the full path (eg `/Public/example/deleteThis`) of the folder to be deleted. * @param folderPath - the full path (eg `/Public/example/deleteThis`) of the folder to be deleted.
* @param accessToken - an access token for authorizing the request. * @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 recycleBinUri = await this.getRecycleBinUri(accessToken)
const folderName = folderPath.split('/').pop() || '' const folderName = folderPath.split('/').pop() || ''
const date = new Date() const date = new Date()

View File

@@ -5,6 +5,10 @@ import {
EditContextInput, EditContextInput,
PollOptions, PollOptions,
LoginMechanism, LoginMechanism,
VerboseMode,
ErrorResponse,
LoginOptions,
LoginResult,
ExecutionQuery ExecutionQuery
} from './types' } from './types'
import { SASViyaApiClient } from './SASViyaApiClient' import { SASViyaApiClient } from './SASViyaApiClient'
@@ -17,7 +21,6 @@ import {
AuthConfig, AuthConfig,
ExtraResponseAttributes, ExtraResponseAttributes,
SasAuthResponse, SasAuthResponse,
ServicePackSASjs,
AuthConfigSas9 AuthConfigSas9
} from '@sasjs/utils/types' } from '@sasjs/utils/types'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
@@ -31,8 +34,7 @@ import {
Sas9JobExecutor, Sas9JobExecutor,
FileUploader FileUploader
} from './job-execution' } from './job-execution'
import { ErrorResponse } from './types/errors' import { AxiosResponse, AxiosError } from 'axios'
import { LoginOptions, LoginResult } from './types/Login'
interface ExecuteScriptParams { interface ExecuteScriptParams {
linesOfCode: string[] linesOfCode: string[]
@@ -159,6 +161,23 @@ export default class SASjs {
} }
} }
/**
* Executes job on SASJS server.
* @param query - an object containing job path and debug level.
* @param appLoc - an application path.
* @param authConfig - an object for authentication.
* @returns a promise that resolves into job execution result and log.
*/
public async executeJob(
query: ExecutionQuery,
appLoc: string,
authConfig?: AuthConfig
) {
this.isMethodSupported('executeScript', [ServerType.Sasjs])
return await this.sasJSApiClient?.executeJob(query, appLoc, authConfig)
}
/** /**
* Gets compute contexts. * Gets compute contexts.
* @param accessToken - an access token for an authorised user. * @param accessToken - an access token for an authorised user.
@@ -339,13 +358,16 @@ export default class SASjs {
sasApiClient?: SASViyaApiClient, sasApiClient?: SASViyaApiClient,
isForced?: boolean isForced?: boolean
) { ) {
if (sasApiClient) if (sasApiClient) {
return await sasApiClient.createFolder( return await sasApiClient.createFolder(
folderName, folderName,
parentFolderPath, parentFolderPath,
parentFolderUri, parentFolderUri,
accessToken accessToken,
isForced
) )
}
return await this.sasViyaApiClient!.createFolder( return await this.sasViyaApiClient!.createFolder(
folderName, folderName,
parentFolderPath, parentFolderPath,
@@ -785,13 +807,11 @@ export default class SASjs {
this.isMethodSupported('deployServicePack', [ServerType.SasViya]) this.isMethodSupported('deployServicePack', [ServerType.SasViya])
let sasApiClient: any = null let sasApiClient: any = null
if (serverUrl || appLoc) { if (serverUrl || appLoc) {
if (!serverUrl) { if (!serverUrl) serverUrl = this.sasjsConfig.serverUrl
serverUrl = this.sasjsConfig.serverUrl if (!appLoc) appLoc = this.sasjsConfig.appLoc
}
if (!appLoc) {
appLoc = this.sasjsConfig.appLoc
}
if (this.sasjsConfig.serverType === ServerType.SasViya) { if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasApiClient = new SASViyaApiClient( sasApiClient = new SASViyaApiClient(
serverUrl, serverUrl,
@@ -809,11 +829,13 @@ export default class SASjs {
} }
} else { } else {
let sasClientConfig: any = null let sasClientConfig: any = null
if (this.sasjsConfig.serverType === ServerType.SasViya) { if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasClientConfig = this.sasViyaApiClient!.getConfig() sasClientConfig = this.sasViyaApiClient!.getConfig()
} else if (this.sasjsConfig.serverType === ServerType.Sas9) { } else if (this.sasjsConfig.serverType === ServerType.Sas9) {
sasClientConfig = this.sas9ApiClient!.getConfig() sasClientConfig = this.sas9ApiClient!.getConfig()
} }
serverUrl = sasClientConfig.serverUrl serverUrl = sasClientConfig.serverUrl
appLoc = sasClientConfig.rootFolderName as string appLoc = sasClientConfig.rootFolderName as string
} }
@@ -850,9 +872,10 @@ export default class SASjs {
* @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs. * @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
* The access token is not required when the user is authenticated via the browser. * The access token is not required when the user is authenticated via the browser.
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete. * @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
*/ */
public async startComputeJob( public async startComputeJob(
sasJob: string, sasJob: string,
@@ -862,7 +885,8 @@ export default class SASjs {
waitForResult?: boolean, waitForResult?: boolean,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar,
verboseMode?: VerboseMode
) { ) {
config = { config = {
...this.sasjsConfig, ...this.sasjsConfig,
@@ -876,6 +900,11 @@ export default class SASjs {
) )
} }
if (verboseMode) {
this.requestClient?.setVerboseMode(verboseMode)
this.requestClient?.enableVerboseMode()
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
return this.sasViyaApiClient?.executeComputeJob( return this.sasViyaApiClient?.executeComputeJob(
sasJob, sasJob,
config.contextName, config.contextName,
@@ -895,6 +924,7 @@ export default class SASjs {
await this.computeJobExecutor?.resendWaitingRequests() await this.computeJobExecutor?.resendWaitingRequests()
await this.jesJobExecutor?.resendWaitingRequests() await this.jesJobExecutor?.resendWaitingRequests()
await this.fileUploader?.resendWaitingRequests() await this.fileUploader?.resendWaitingRequests()
await this.sasjsJobExecutor?.resendWaitingRequests()
} }
/** /**
@@ -968,7 +998,8 @@ export default class SASjs {
this.requestClient = new RequestClientClass( this.requestClient = new RequestClientClass(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions, this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit this.sasjsConfig.requestHistoryLimit,
this.sasjsConfig.verbose
) )
} else { } else {
this.requestClient.setConfig( this.requestClient.setConfig(
@@ -1132,4 +1163,31 @@ 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 | AxiosError) => AxiosResponse,
errorCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse
) {
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)
}
} }

View File

@@ -1,13 +1,39 @@
import * as NodeFormData from 'form-data'
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types' import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { ExecutionQuery } from './types' import { ExecutionQuery } from './types'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { getAccessTokenForSasjs } from './auth/getAccessTokenForSasjs' import { getAccessTokenForSasjs } from './auth/getAccessTokenForSasjs'
import { refreshTokensForSasjs } from './auth/refreshTokensForSasjs' import { refreshTokensForSasjs } from './auth/refreshTokensForSasjs'
import { getTokens } from './auth/getTokens' 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 { export class SASjsApiClient {
constructor(private requestClient: RequestClient) {} constructor(private requestClient: RequestClient) {}
private async getAccessTokenForRequest(authConfig?: AuthConfig) {
if (authConfig) {
const { access_token } = await getTokens(
this.requestClient,
authConfig,
ServerType.Sasjs
)
return access_token
}
}
/** /**
* Creates the folders and services at the given location `appLoc` on the given server `serverUrl`. * Creates the folders and services at the given location `appLoc` on the given server `serverUrl`.
* @param dataJson - the JSON specifying the folders and files to be created, can also includes * @param dataJson - the JSON specifying the folders and files to be created, can also includes
@@ -20,15 +46,7 @@ export class SASjsApiClient {
appLoc: string, appLoc: string,
authConfig?: AuthConfig authConfig?: AuthConfig
) { ) {
let access_token = (authConfig || {}).access_token const access_token = await this.getAccessTokenForRequest(authConfig)
if (authConfig) {
;({ access_token } = await getTokens(
this.requestClient,
authConfig,
ServerType.Sasjs
))
}
dataJson.appLoc = dataJson.appLoc || appLoc dataJson.appLoc = dataJson.appLoc || appLoc
const { result } = await this.requestClient.post<{ const { result } = await this.requestClient.post<{
@@ -48,6 +66,40 @@ export class SASjsApiClient {
return Promise.resolve(result) return Promise.resolve(result)
} }
/**
* Creates/updates files within SASjs drive using uploaded json compressed file.
* @param zipFilePath - Compressed file path; file should only contain one JSON file and
* should have same name as of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip
* Any other file or JSON file in zipped will be ignored!
* @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
*/
public async deployZipFile(zipFilePath: string, authConfig?: AuthConfig) {
const { createReadStream } = require('@sasjs/utils/file')
const access_token = await this.getAccessTokenForRequest(authConfig)
const file = await createReadStream(zipFilePath)
const formData = new NodeFormData()
formData.append('file', file)
const contentType = `multipart/form-data; boundary=${formData.getBoundary()}`
const { result } = await this.requestClient.post<{
status: string
message: string
streamServiceName?: string
example?: {}
}>(
'SASjsApi/drive/deploy/upload',
formData,
access_token,
contentType,
{},
{ maxContentLength: Infinity, maxBodyLength: Infinity }
)
return Promise.resolve(result)
}
public async executeJob( public async executeJob(
query: ExecutionQuery, query: ExecutionQuery,
appLoc: string, appLoc: string,
@@ -79,25 +131,28 @@ export class SASjsApiClient {
code: string, code: string,
runTime: string = 'sas', runTime: string = 'sas',
authConfig?: AuthConfig authConfig?: AuthConfig
) { ): Promise<ScriptExecutionResult> {
let access_token = (authConfig || {}).access_token const access_token = await this.getAccessTokenForRequest(authConfig)
if (authConfig) { const executionResult: ScriptExecutionResult = { log: '' }
;({ access_token } = await getTokens(
this.requestClient,
authConfig,
ServerType.Sasjs
))
}
let parsedSasjsServerLog = ''
await this.requestClient await this.requestClient
.post('SASjsApi/code/execute', { code, runTime }, access_token) .post('SASjsApi/code/execute', { code, runTime }, access_token)
.then((res: any) => { .then((res: any) => {
if (res.log) parsedSasjsServerLog = res.log const { log, printOutput, result: webout } = res
executionResult.log = log
if (printOutput) executionResult.printOutput = printOutput
if (webout) executionResult.webout = webout
})
.catch((err) => {
throw prefixMessage(
err,
'Error while sending POST request to execute code. '
)
}) })
return parsedSasjsServerLog return executionResult
} }
/** /**
@@ -120,9 +175,3 @@ export class SASjsApiClient {
return refreshTokensForSasjs(this.requestClient, refreshToken) 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 } from './types' import { Session, Context, SessionVariable, SessionState } from './types'
import { NoSessionStateError } from './types/errors' import { NoSessionStateError } from './types/errors'
import { asyncForEach, isUrl } from './utils' import { asyncForEach, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
@@ -6,8 +6,13 @@ import { RequestClient } from './request/RequestClient'
const MAX_SESSION_COUNT = 1 const MAX_SESSION_COUNT = 1
interface ApiErrorResponse {
response: { status: number | string; data: { message: string } }
}
export class SessionManager { export class SessionManager {
private loggedErrors: NoSessionStateError[] = [] private loggedErrors: NoSessionStateError[] = []
private sessionStateLinkError = 'Error while getting session state link. '
constructor( constructor(
private serverUrl: string, private serverUrl: string,
@@ -17,12 +22,14 @@ export class SessionManager {
if (serverUrl) isUrl(serverUrl) if (serverUrl) isUrl(serverUrl)
} }
// INFO: session pool
private sessions: Session[] = [] private sessions: Session[] = []
private currentContext: Context | null = null private currentContext: Context | null = null
private settingContext: boolean = false
private _debug: boolean = false private _debug: boolean = false
private printedSessionState = { private printedSessionState = {
printed: false, printed: false,
state: '' state: SessionState.NoState
} }
public get debug() { public get debug() {
@@ -33,73 +40,244 @@ export class SessionManager {
this._debug = value this._debug = value
} }
async getSession(accessToken?: string) { /**
await this.createSessions(accessToken) * Checks if session is valid. Session is considered valid if time since it's creation is less than 'sessionInactiveTimeout' attribute.
await this.createAndWaitForSession(accessToken) * @param session - session object.
const session = this.sessions.pop() * @returns - boolean indicating if session is valid.
*/
private isSessionValid(session: Session): boolean {
if (!session) return false
const secondsSinceSessionCreation = const secondsSinceSessionCreation =
(new Date().getTime() - new Date(session!.creationTimeStamp).getTime()) / (new Date().getTime() - new Date(session.creationTimeStamp).getTime()) /
1000 1000
if ( if (
!session!.attributes || !session!.attributes ||
secondsSinceSessionCreation >= session!.attributes.sessionInactiveTimeout secondsSinceSessionCreation >= session!.attributes.sessionInactiveTimeout
) { ) {
await this.createSessions(accessToken) return false
const freshSession = this.sessions.pop() } else {
return true
return freshSession
} }
return session
} }
async clearSession(id: string, accessToken?: string) { /**
* Removes session from pool of hot sessions.
* @param session - session object.
* @returns - void.
*/
private removeSessionFromPool(session: Session): void {
this.sessions = this.sessions.filter((ses) => ses.id !== session.id)
}
/**
* Filters session pool to keep only valid sessions.
* @param session - session object.
* @returns - void.
*/
private removeExpiredSessions(): void {
this.sessions = this.sessions.filter((session) =>
this.isSessionValid(session)
)
}
/**
* Throws set of errors as a single error.
* @param errors - array of errors or string.
* @param prefix - an optional final error prefix.
* @returns - never.
*/
private throwErrors(errors: (Error | string)[], prefix?: string): never {
throw prefix
? prefixMessage(new Error(errors.join('. ')), prefix)
: new Error(
errors
.map((err) =>
(err as Error).message ? (err as Error).message : err
)
.join('. ')
)
}
/**
* Returns session.
* If there is a hot session available, it will be returned immediately and an asynchronous request to create new hot session will be submitted.
* If there is no available session, 2 session creation requests will be submitted. The session is returned once it is created and ready.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session.
*/
async getSession(accessToken?: string) {
const errors: (Error | string)[] = []
let isErrorThrown = false
const throwIfError = () => {
if (errors.length && !isErrorThrown) {
isErrorThrown = true
this.throwErrors(errors)
}
}
this.removeExpiredSessions()
if (this.sessions.length) {
const session = this.sessions[0]
this.removeSessionFromPool(session)
this.createSessions(accessToken).catch((err) => {
errors.push(err)
})
this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
throwIfError()
return session
} else {
this.createSessions(accessToken).catch((err) => {
errors.push(err)
})
await this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
this.removeExpiredSessions()
const session = this.sessions.pop()!
this.removeSessionFromPool(session)
throwIfError()
return session
}
}
/**
* Returns error message based on the response from SAS API.
* @param err - an optional access token.
* @param accessToken - an optional access token.
* @returns - an error message.
*/
private getErrorMessage(
err: ApiErrorResponse,
url: string,
method: 'GET' | 'POST' | 'DELETE'
) {
return (
`${method} request to ${url} failed with status code ${
err.response.status || 'unknown'
}. ` + err.response.data.message || ''
)
}
/**
* Deletes session.
* @param id - a session id.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when session is deleted.
*/
async clearSession(id: string, accessToken?: string): Promise<void> {
const url = `/compute/sessions/${id}`
return await this.requestClient return await this.requestClient
.delete<Session>(`/compute/sessions/${id}`, accessToken) .delete<Session>(url, accessToken)
.then(() => { .then(() => {
this.sessions = this.sessions.filter((s) => s.id !== id) this.sessions = this.sessions.filter((s) => s.id !== id)
}) })
.catch((err) => { .catch((err: ApiErrorResponse) => {
throw prefixMessage(err, 'Error while deleting session. ') throw prefixMessage(
this.getErrorMessage(err, url, 'DELETE'),
'Error while deleting session. '
)
}) })
} }
private async createSessions(accessToken?: string) { /**
* Creates sessions in amount equal to MAX_SESSION_COUNT.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when required amount of sessions is created.
*/
private async createSessions(accessToken?: string): Promise<void> {
const errors: (Error | string)[] = []
if (!this.sessions.length) { if (!this.sessions.length) {
if (!this.currentContext) {
await this.setCurrentContext(accessToken).catch((err) => {
throw err
})
}
await asyncForEach(new Array(MAX_SESSION_COUNT), async () => { await asyncForEach(new Array(MAX_SESSION_COUNT), async () => {
const createdSession = await this.createAndWaitForSession( await this.createAndWaitForSession(accessToken).catch((err) => {
accessToken errors.push(err)
).catch((err) => {
throw err
}) })
this.sessions.push(createdSession)
}).catch((err) => {
throw err
}) })
} }
if (errors.length) {
this.throwErrors(errors, 'Error while creating session. ')
}
} }
private async createAndWaitForSession(accessToken?: string) { /**
* Waits for the current context to be set.
* @returns - a promise which resolves when current context is set.
*/
private async waitForCurrentContext(): Promise<void> {
return new Promise((resolve) => {
const timer = setInterval(() => {
if (this.currentContext) {
this.settingContext = false
clearInterval(timer)
resolve()
}
}, 100)
})
}
/**
* Creates and waits for session to be ready.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session.
*/
private async createAndWaitForSession(
accessToken?: string
): Promise<Session> {
if (!this.currentContext) {
if (!this.settingContext) {
await this.setCurrentContext(accessToken)
} else {
await this.waitForCurrentContext()
}
}
const url = `${this.serverUrl}/compute/contexts/${
this.currentContext!.id
}/sessions`
const { result: createdSession, etag } = await this.requestClient const { result: createdSession, etag } = await this.requestClient
.post<Session>( .post<Session>(url, {}, accessToken)
`${this.serverUrl}/compute/contexts/${ .catch((err: ApiErrorResponse) => {
this.currentContext!.id throw prefixMessage(
}/sessions`, this.getErrorMessage(err, url, 'POST'),
{}, `Error while creating session. `
accessToken )
)
.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
await this.waitForSession(createdSession, etag, accessToken) await this.waitForSession(createdSession, etag, accessToken)
this.sessions.push(createdSession) this.sessions.push(createdSession)
@@ -107,14 +285,26 @@ export class SessionManager {
return createdSession return createdSession
} }
private async setCurrentContext(accessToken?: string) { /**
* Sets current context.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when current context is set.
*/
private async setCurrentContext(accessToken?: string): Promise<void> {
if (!this.currentContext) { if (!this.currentContext) {
const url = `${this.serverUrl}/compute/contexts?limit=10000`
this.settingContext = true
const { result: contexts } = await this.requestClient const { result: contexts } = await this.requestClient
.get<{ .get<{
items: Context[] items: Context[]
}>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken) }>(url, accessToken)
.catch((err) => { .catch((err: ApiErrorResponse) => {
throw err throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
`Error while getting list of contexts. `
)
}) })
const contextsList = const contextsList =
@@ -138,51 +328,42 @@ export class SessionManager {
} }
} }
private getHeaders(accessToken?: string) { /**
const headers: any = { * Waits for session to be ready.
'Content-Type': 'application/json' * @param session - a session object.
} * @param etag - an etag that can be a string or null.
* @param accessToken - an optional access token.
if (accessToken) { * @returns - a promise which resolves with a session state.
headers.Authorization = `Bearer ${accessToken}` */
}
return headers
}
private async waitForSession( private async waitForSession(
session: Session, session: Session,
etag: string | null, etag: string | null,
accessToken?: string accessToken?: string
): Promise<string> { ): Promise<string> {
let { state: sessionState } = session
const { stateUrl } = session
const logger = process.logger || console const logger = process.logger || console
let sessionState = session.state
const stateLink = session.links.find((l: any) => l.rel === 'state')
if ( if (
sessionState === 'pending' || sessionState === SessionState.Pending ||
sessionState === 'running' || sessionState === SessionState.Running ||
sessionState === '' sessionState === SessionState.NoState
) { ) {
if (stateLink) { if (stateUrl) {
if (this.debug && !this.printedSessionState.printed) { if (this.debug && !this.printedSessionState.printed) {
logger.info(`Polling: ${this.serverUrl + stateLink.href}`) logger.info(`Polling: ${this.serverUrl + stateUrl}`)
this.printedSessionState.printed = true this.printedSessionState.printed = true
} }
const url = `${this.serverUrl}${stateUrl}?wait=30`
const { result: state, responseStatus: responseStatus } = const { result: state, responseStatus: responseStatus } =
await this.getSessionState( await this.getSessionState(url, etag!, accessToken).catch((err) => {
`${this.serverUrl}${stateLink.href}?wait=30`, throw prefixMessage(err, 'Error while waiting for session. ')
etag!,
accessToken
).catch((err) => {
throw prefixMessage(err, 'Error while getting session state.')
}) })
sessionState = state.trim() sessionState = state.trim() as SessionState
if (this.debug && this.printedSessionState.state !== sessionState) { if (this.debug && this.printedSessionState.state !== sessionState) {
logger.info(`Current session state is '${sessionState}'`) logger.info(`Current session state is '${sessionState}'`)
@@ -194,7 +375,7 @@ export class SessionManager {
if (!sessionState) { if (!sessionState) {
const stateError = new NoSessionStateError( const stateError = new NoSessionStateError(
responseStatus, responseStatus,
this.serverUrl + stateLink.href, this.serverUrl + stateUrl,
session.links.find((l: any) => l.rel === 'log')?.href as string session.links.find((l: any) => l.rel === 'log')?.href as string
) )
@@ -216,7 +397,7 @@ export class SessionManager {
return sessionState return sessionState
} else { } else {
throw 'Error while getting session state link.' throw this.sessionStateLinkError
} }
} else { } else {
this.loggedErrors = [] this.loggedErrors = []
@@ -225,32 +406,59 @@ 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( private async getSessionState(
url: string, url: string,
etag: string, etag: string,
accessToken?: string accessToken?: string
) { ): Promise<{
result: string
responseStatus: number
}> {
return await this.requestClient return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag }) .get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => ({ .then((res) => ({
result: res.result as string, result: res.result as SessionState,
responseStatus: res.status responseStatus: res.status
})) }))
.catch((err) => { .catch((err) => {
throw err throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
'Error while getting session state. '
)
}) })
} }
async getVariable(sessionId: string, variable: string, accessToken?: string) { /**
* Gets variable.
* @param sessionId - a session id.
* @param variable - a variable string.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a result that confirms to SessionVariable interface, etag string and status code.
*/
async getVariable(
sessionId: string,
variable: string,
accessToken?: string
): Promise<{
result: SessionVariable
etag: string
status: number
}> {
const url = `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`
return await this.requestClient return await this.requestClient
.get<SessionVariable>( .get<SessionVariable>(url, accessToken)
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
accessToken
)
.catch((err) => { .catch((err) => {
throw prefixMessage( throw prefixMessage(
err, this.getErrorMessage(err, url, 'GET'),
`Error while fetching session variable '${variable}'.` `Error while fetching session variable '${variable}'. `
) )
}) })
} }

View File

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

View File

@@ -1,29 +1,90 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils/types'
import { Job, PollOptions } from '../..' import { Job, PollOptions, PollStrategy } from '../..'
import { getTokens } from '../../auth/getTokens' import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors' import { JobStatePollError } from '../../types/errors'
import { Link, WriteStream } from '../../types' import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
import { delay, isNode } from '../../utils' import { delay, isNode } from '../../utils'
export enum JobState {
Completed = 'completed',
Running = 'running',
Pending = 'pending',
Unavailable = 'unavailable',
NoState = '',
Failed = 'failed',
Error = 'error'
}
/**
* Polls job status using default or provided poll options.
* @param requestClient - the pre-configured HTTP request client.
* @param postedJob - the relative or absolute path to the job.
* @param debug - sets the _debug flag in the job arguments.
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath. It will override the first default poll options in poll strategy if provided.
* Example pollOptions:
* {
* maxPollCount: 200,
* pollInterval: 300,
* streamLog: true, // optional, equals to false by default.
* pollStrategy?: // optional array of poll options that should be applied after 'maxPollCount' of the provided poll options is reached. If not provided the default (see example below) poll strategy will be used.
* }
* Example pollStrategy (values used from default poll strategy):
* [
* { maxPollCount: 200, pollInterval: 300 }, // approximately ~2 mins (including time to get response (~300ms))
* { maxPollCount: 300, pollInterval: 3000 }, // approximately ~5.5 mins (including time to get response (~300ms))
* { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms))
* { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms))
* ]
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state.
* @returns - a promise which resolves with a job state
*/
export async function pollJobState( export async function pollJobState(
requestClient: RequestClient, requestClient: RequestClient,
postedJob: Job, postedJob: Job,
debug: boolean, debug: boolean,
authConfig?: AuthConfig, authConfig?: AuthConfig,
pollOptions?: PollOptions pollOptions?: PollOptions,
) { jobSessionManager?: JobSessionManager
): Promise<JobState> {
const logger = process.logger || console const logger = process.logger || console
let pollInterval = 300 const streamLog = pollOptions?.streamLog || false
let maxPollCount = 1000
const defaultPollOptions: PollOptions = { const defaultPollStrategy: PollStrategy = [
maxPollCount, { maxPollCount: 200, pollInterval: 300 },
pollInterval, { maxPollCount: 300, pollInterval: 3000 },
streamLog: false { maxPollCount: 500, pollInterval: 30000 },
{ maxPollCount: 3400, pollInterval: 60000 }
]
let pollStrategy: PollStrategy
if (pollOptions !== undefined) {
pollStrategy = [pollOptions]
let { pollStrategy: providedPollStrategy } = pollOptions
if (providedPollStrategy !== undefined) {
validatePollStrategies(providedPollStrategy)
// INFO: sort by 'maxPollCount'
providedPollStrategy = providedPollStrategy.sort(
(strategyA: PollOptions, strategyB: PollOptions) =>
strategyA.maxPollCount - strategyB.maxPollCount
)
pollStrategy = [...pollStrategy, ...providedPollStrategy]
} else {
pollStrategy = [...pollStrategy, ...defaultPollStrategy]
}
} else {
pollStrategy = defaultPollStrategy
} }
let defaultPollOptions: PollOptions = pollStrategy.splice(0, 1)[0]
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) } pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
const stateLink = postedJob.links.find((l: any) => l.rel === 'state') const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
@@ -31,10 +92,10 @@ export async function pollJobState(
throw new Error(`Job state link was not found.`) throw new Error(`Job state link was not found.`)
} }
let currentState = await getJobState( let currentState: JobState = await getJobState(
requestClient, requestClient,
postedJob, postedJob,
'', JobState.NoState,
debug, debug,
authConfig authConfig
).catch((err) => { ).catch((err) => {
@@ -42,73 +103,73 @@ export async function pollJobState(
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`, `Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
err err
) )
return 'unavailable'
return JobState.Unavailable
}) })
let pollCount = 0 let pollCount = 0
if (currentState === 'completed') { if (currentState === JobState.Completed) {
return Promise.resolve(currentState) return Promise.resolve(currentState)
} }
let logFileStream let logFileStream
if (pollOptions.streamLog && isNode()) { if (streamLog && isNode()) {
const { getFileStream } = require('./getFileStream') const { getFileStream } = require('./getFileStream')
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath) logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
} }
// Poll up to the first 100 times with the specified poll interval
let result = await doPoll( let result = await doPoll(
requestClient, requestClient,
postedJob, postedJob,
currentState, currentState,
debug, debug,
pollCount, pollCount,
pollOptions,
authConfig, authConfig,
{ streamLog,
...pollOptions, logFileStream,
maxPollCount: jobSessionManager
pollOptions.maxPollCount <= 100 ? pollOptions.maxPollCount : 100
},
logFileStream
) )
currentState = result.state currentState = result.state
pollCount = result.pollCount pollCount = result.pollCount
if (!needsRetry(currentState) || pollCount >= pollOptions.maxPollCount) { if (
!needsRetry(currentState) ||
(pollCount >= pollOptions.maxPollCount && !pollStrategy.length)
) {
return currentState return currentState
} }
// If we get to this point, this is a long-running job that needs longer polling. // INFO: If we get to this point, this is a long-running job that needs longer polling.
// We will resume polling with a bigger interval of 1 minute // We will resume polling with a bigger interval according to the next polling strategy
let longJobPollOptions: PollOptions = { while (pollStrategy.length && needsRetry(currentState)) {
maxPollCount: 24 * 60, defaultPollOptions = pollStrategy.splice(0, 1)[0]
pollInterval: 60000,
streamLog: false if (pollOptions) {
} defaultPollOptions.logFolderPath = pollOptions.logFolderPath
if (pollOptions) { }
longJobPollOptions.streamLog = pollOptions.streamLog
longJobPollOptions.logFolderPath = pollOptions.logFolderPath result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
defaultPollOptions,
authConfig,
streamLog,
logFileStream,
jobSessionManager
)
currentState = result.state
pollCount = result.pollCount
} }
result = await doPoll( if (logFileStream) logFileStream.end()
requestClient,
postedJob,
currentState,
debug,
pollCount,
authConfig,
longJobPollOptions,
logFileStream
)
currentState = result.state
pollCount = result.pollCount
if (logFileStream) {
logFileStream.end()
}
return currentState return currentState
} }
@@ -119,17 +180,13 @@ const getJobState = async (
currentState: string, currentState: string,
debug: boolean, debug: boolean,
authConfig?: AuthConfig authConfig?: AuthConfig
) => { ): Promise<JobState> => {
const stateLink = job.links.find((l: any) => l.rel === 'state') const stateLink = job.links.find((l: any) => l.rel === 'state')!
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
if (needsRetry(currentState)) { if (needsRetry(currentState)) {
let tokens let tokens
if (authConfig) {
tokens = await getTokens(requestClient, authConfig) if (authConfig) tokens = await getTokens(requestClient, authConfig)
}
const { result: jobState } = await requestClient const { result: jobState } = await requestClient
.get<string>( .get<string>(
@@ -143,49 +200,88 @@ const getJobState = async (
throw new JobStatePollError(job.id, err) throw new JobStatePollError(job.id, err)
}) })
return jobState.trim() return jobState.trim() as JobState
} else { } else {
return currentState return currentState as JobState
} }
} }
const needsRetry = (state: string) => const needsRetry = (state: string) =>
state === 'running' || state === JobState.Running ||
state === '' || state === JobState.NoState ||
state === 'pending' || state === JobState.Pending ||
state === 'unavailable' state === JobState.Unavailable
const doPoll = async ( /**
* Polls job state.
* @param requestClient - the pre-configured HTTP request client.
* @param postedJob - the relative or absolute path to the job.
* @param currentState - current job state.
* @param debug - sets the _debug flag in the job arguments.
* @param pollCount - current poll count.
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath.
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
* @param streamLog - indicates if job log should be streamed.
* @param logStream - job log stream.
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state. Session state is considered healthy if it is equal to 'running' or 'idle'.
* @returns - a promise which resolves with a job state
*/
export const doPoll = async (
requestClient: RequestClient, requestClient: RequestClient,
postedJob: Job, postedJob: Job,
currentState: string, currentState: JobState,
debug: boolean, debug: boolean,
pollCount: number, pollCount: number,
pollOptions: PollOptions,
authConfig?: AuthConfig, authConfig?: AuthConfig,
pollOptions?: PollOptions, streamLog?: boolean,
logStream?: WriteStream logStream?: WriteStream,
): Promise<{ state: string; pollCount: number }> => { jobSessionManager?: JobSessionManager
let pollInterval = 300 ): Promise<{ state: JobState; pollCount: number }> => {
let maxPollCount = 1000 const { maxPollCount, pollInterval } = pollOptions
const logger = process.logger || console
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')!
let maxErrorCount = 5 let maxErrorCount = 5
let errorCount = 0 let errorCount = 0
let state = currentState let state = currentState
let printedState = '' let printedState = JobState.NoState
let startLogLine = 0 let startLogLine = 0
const logger = process.logger || console
if (pollOptions) {
pollInterval = pollOptions.pollInterval || pollInterval
maxPollCount = pollOptions.maxPollCount || maxPollCount
}
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
while (needsRetry(state) && pollCount <= maxPollCount) { while (needsRetry(state) && pollCount <= maxPollCount) {
// Check parent session state on every 10th job state poll.
if (jobSessionManager && pollCount && pollCount % 10 === 0 && authConfig) {
const { session, sessionManager } = jobSessionManager
const { stateUrl, etag, id: sessionId } = session
const { access_token } = authConfig
const { id: jobId } = postedJob
// Get session state.
const { result: sessionState, responseStatus } = await sessionManager[
'getSessionState'
](stateUrl, etag, access_token).catch((err) => {
// Handle error while getting session state.
throw new JobStatePollError(jobId, err)
})
// Checks if session state is equal to 'running' or 'idle'.
const isSessionStatesHealthy = (state: string) =>
[SessionState.Running, SessionState.Idle].includes(
state as SessionState
)
// Clear parent session and throw an error if session state is not
// 'running', 'idle' or response status is not 200.
if (!isSessionStatesHealthy(sessionState) || responseStatus !== 200) {
sessionManager.clearSession(sessionId, access_token)
const sessionError = isSessionStatesHealthy(sessionState)
? `Session response status is not 200. Session response status is ${responseStatus}.`
: `Session state of the job is not 'running' or 'idle'. Session state is '${sessionState}'`
throw new JobStatePollError(jobId, new Error(sessionError))
}
}
state = await getJobState( state = await getJobState(
requestClient, requestClient,
postedJob, postedJob,
@@ -194,21 +290,24 @@ const doPoll = async (
authConfig authConfig
).catch((err) => { ).catch((err) => {
errorCount++ errorCount++
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) { if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
throw err throw err
} }
logger.error( logger.error(
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`, `Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
err err
) )
return 'unavailable'
return JobState.Unavailable
}) })
pollCount++ pollCount++
const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href
if (pollOptions?.streamLog) { if (streamLog) {
const { result: job } = await requestClient.get<Job>( const { result: job } = await requestClient.get<Job>(
jobHref, jobHref,
authConfig?.access_token authConfig?.access_token
@@ -238,12 +337,45 @@ const doPoll = async (
printedState = state printedState = state
} }
if (state != 'unavailable' && errorCount > 0) { if (state !== JobState.Unavailable && errorCount > 0) {
errorCount = 0 errorCount = 0
} }
await delay(pollInterval) if (state !== JobState.Completed) {
await delay(pollInterval)
}
} }
return { state, pollCount } return { state, pollCount }
} }
const validatePollStrategies = (strategy: PollStrategy) => {
const throwError = (message?: string, pollOptions?: PollOptions) => {
throw new Error(
`Poll strategies are not valid.${message ? ` ${message}` : ''}${
pollOptions
? ` Invalid poll strategy: \n${JSON.stringify(pollOptions, null, 2)}`
: ''
}`
)
}
strategy.forEach((pollOptions: PollOptions, i: number) => {
const { maxPollCount, pollInterval } = pollOptions
if (maxPollCount < 1) {
throwError(`'maxPollCount' has to be greater than 0.`, pollOptions)
} else if (i !== 0) {
const previousPollOptions = strategy[i - 1]
if (maxPollCount <= previousPollOptions.maxPollCount) {
throwError(
`'maxPollCount' has to be greater than 'maxPollCount' in previous poll strategy.`,
pollOptions
)
}
} else if (pollInterval < 1) {
throwError(`'pollInterval' has to be greater than 0.`, pollOptions)
}
})
}

View File

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

View File

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

View File

@@ -1,22 +1,31 @@
import { Logger, LogLevel } from '@sasjs/utils' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import { mockAuthConfig, mockJob } from './mockResponses' import { mockAuthConfig, mockJob } from './mockResponses'
import { pollJobState } from '../pollJobState' import { pollJobState, doPoll, JobState } from '../pollJobState'
import * as getTokensModule from '../../../auth/getTokens' import * as getTokensModule from '../../../auth/getTokens'
import * as saveLogModule from '../saveLog' import * as saveLogModule from '../saveLog'
import * as getFileStreamModule from '../getFileStream' import * as getFileStreamModule from '../getFileStream'
import * as isNodeModule from '../../../utils/isNode' import * as isNodeModule from '../../../utils/isNode'
import { PollOptions } from '../../../types' import * as delayModule from '../../../utils/delay'
import {
PollOptions,
PollStrategy,
SessionState,
JobSessionManager
} from '../../../types'
import { WriteStream } from 'fs' import { WriteStream } from 'fs'
import { SessionManager } from '../../../SessionManager'
import { JobStatePollError } from '../../../types'
const baseUrl = 'http://localhost' const baseUrl = 'http://localhost'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)() const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
requestClient['httpClient'].defaults.baseURL = baseUrl requestClient['httpClient'].defaults.baseURL = baseUrl
const defaultPollOptions: PollOptions = { const defaultStreamLog = false
const defaultPollStrategy: PollOptions = {
maxPollCount: 100, maxPollCount: 100,
pollInterval: 500, pollInterval: 500
streamLog: false
} }
describe('pollJobState', () => { describe('pollJobState', () => {
@@ -26,13 +35,10 @@ describe('pollJobState', () => {
}) })
it('should get valid tokens if the authConfig has been provided', async () => { it('should get valid tokens if the authConfig has been provided', async () => {
await pollJobState( await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
requestClient, ...defaultPollStrategy,
mockJob, streamLog: defaultStreamLog
false, })
mockAuthConfig,
defaultPollOptions
)
expect(getTokensModule.getTokens).toHaveBeenCalledWith( expect(getTokensModule.getTokens).toHaveBeenCalledWith(
requestClient, requestClient,
@@ -46,7 +52,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollOptions defaultPollStrategy
) )
expect(getTokensModule.getTokens).not.toHaveBeenCalled() expect(getTokensModule.getTokens).not.toHaveBeenCalled()
@@ -58,7 +64,7 @@ describe('pollJobState', () => {
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') }, { ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
false, false,
undefined, undefined,
defaultPollOptions defaultPollStrategy
).catch((e: any) => e) ).catch((e: any) => e)
expect((error as Error).message).toContain('Job state link was not found.') expect((error as Error).message).toContain('Job state link was not found.')
@@ -72,7 +78,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollStrategy
) )
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3) expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
@@ -83,7 +89,7 @@ describe('pollJobState', () => {
const { saveLog } = require('../saveLog') const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollOptions, ...defaultPollStrategy,
streamLog: true streamLog: true
}) })
@@ -96,7 +102,7 @@ describe('pollJobState', () => {
const { saveLog } = require('../saveLog') const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollOptions, ...defaultPollStrategy,
streamLog: true streamLog: true
}) })
@@ -111,7 +117,7 @@ describe('pollJobState', () => {
const { getFileStream } = require('../getFileStream') const { getFileStream } = require('../getFileStream')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollOptions, ...defaultPollStrategy,
streamLog: true streamLog: true
}) })
@@ -127,7 +133,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollStrategy
) )
expect(saveLogModule.saveLog).not.toHaveBeenCalled() expect(saveLogModule.saveLog).not.toHaveBeenCalled()
@@ -136,15 +142,18 @@ describe('pollJobState', () => {
it('should return the current status when the max poll count is reached', async () => { it('should return the current status when the max poll count is reached', async () => {
mockRunningPoll() mockRunningPoll()
const pollOptions: PollOptions = {
...defaultPollStrategy,
maxPollCount: 1,
pollStrategy: []
}
const state = await pollJobState( const state = await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
{ pollOptions
...defaultPollOptions,
maxPollCount: 1
}
) )
expect(state).toEqual('running') expect(state).toEqual('running')
@@ -159,7 +168,7 @@ describe('pollJobState', () => {
false, false,
mockAuthConfig, mockAuthConfig,
{ {
...defaultPollOptions, ...defaultPollStrategy,
maxPollCount: 200, maxPollCount: 200,
pollInterval: 10 pollInterval: 10
} }
@@ -176,7 +185,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollOptions defaultPollStrategy
) )
expect(requestClient.get).toHaveBeenCalledTimes(2) expect(requestClient.get).toHaveBeenCalledTimes(2)
@@ -192,7 +201,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
true, true,
undefined, undefined,
defaultPollOptions defaultPollStrategy
) )
expect((process as any).logger.info).toHaveBeenCalledTimes(4) expect((process as any).logger.info).toHaveBeenCalledTimes(4)
@@ -222,7 +231,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollOptions defaultPollStrategy
) )
expect(requestClient.get).toHaveBeenCalledTimes(2) expect(requestClient.get).toHaveBeenCalledTimes(2)
@@ -237,13 +246,401 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollOptions defaultPollStrategy
).catch((e: any) => e) ).catch((e: any) => e)
expect(error.message).toEqual( expect(error.message).toEqual(
'Error while polling job state for job j0b: Status Error' 'Error while polling job state for job j0b: Status Error'
) )
}) })
it('should change poll strategies', async () => {
mockSimplePoll(6)
const delays: number[] = []
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
delays.push(ms)
return Promise.resolve()
})
const pollIntervals = [3, 4, 5, 6]
const pollStrategy = [
{ maxPollCount: 2, pollInterval: pollIntervals[1] },
{ maxPollCount: 3, pollInterval: pollIntervals[2] },
{ maxPollCount: 4, pollInterval: pollIntervals[3] }
]
const pollOptions: PollOptions = {
maxPollCount: 1,
pollInterval: pollIntervals[0],
pollStrategy: pollStrategy
}
await pollJobState(requestClient, mockJob, false, undefined, pollOptions)
expect(delays).toEqual([pollIntervals[0], ...pollIntervals])
})
it('should change default poll strategies after completing provided poll options', async () => {
const delays: number[] = []
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
delays.push(ms)
return Promise.resolve()
})
const customPollOptions: PollOptions = {
maxPollCount: 0,
pollInterval: 0
}
const requests = [
{ maxPollCount: 202, pollInterval: 300 },
{ maxPollCount: 300, pollInterval: 3000 },
{ maxPollCount: 500, pollInterval: 30000 },
{ maxPollCount: 3400, pollInterval: 60000 }
]
// ~200 requests with delay 300ms
let request = requests.splice(0, 1)[0]
let { maxPollCount, pollInterval } = request
// should be only one interval because maxPollCount is equal to 0
const pollIntervals = [customPollOptions.pollInterval]
pollIntervals.push(...Array(maxPollCount - 2).fill(pollInterval))
// ~300 requests with delay 3000
request = requests.splice(0, 1)[0]
let newAmount = request.maxPollCount
pollInterval = request.pollInterval
pollIntervals.push(...Array(newAmount - maxPollCount).fill(pollInterval))
pollIntervals.push(...Array(2).fill(pollInterval))
// ~500 requests with delay 30000
request = requests.splice(0, 1)[0]
let oldAmount = newAmount
newAmount = request.maxPollCount
pollInterval = request.pollInterval
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
pollIntervals.push(...Array(2).fill(pollInterval))
// ~3400 requests with delay 60000
request = requests.splice(0, 1)[0]
oldAmount = newAmount
newAmount = request.maxPollCount
pollInterval = request.pollInterval
mockSimplePoll(newAmount)
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
await pollJobState(
requestClient,
mockJob,
false,
undefined,
customPollOptions
)
expect(delays).toEqual(pollIntervals)
})
it('should throw an error if not valid poll strategies provided', async () => {
// INFO: 'maxPollCount' has to be > 0
let invalidPollStrategy = {
maxPollCount: 0,
pollInterval: 3
}
let pollStrategy: PollStrategy = [invalidPollStrategy]
let expectedError = new Error(
`Poll strategies are not valid. 'maxPollCount' has to be greater than 0. Invalid poll strategy: \n${JSON.stringify(
invalidPollStrategy,
null,
2
)}`
)
await expect(
pollJobState(requestClient, mockJob, false, undefined, {
...defaultPollStrategy,
pollStrategy: pollStrategy
})
).rejects.toThrow(expectedError)
// INFO: 'maxPollCount' has to be > than 'maxPollCount' of the previous strategy
const validPollStrategy = {
maxPollCount: 5,
pollInterval: 2
}
invalidPollStrategy = {
maxPollCount: validPollStrategy.maxPollCount,
pollInterval: 3
}
pollStrategy = [validPollStrategy, invalidPollStrategy]
expectedError = new Error(
`Poll strategies are not valid. 'maxPollCount' has to be greater than 'maxPollCount' in previous poll strategy. Invalid poll strategy: \n${JSON.stringify(
invalidPollStrategy,
null,
2
)}`
)
await expect(
pollJobState(requestClient, mockJob, false, undefined, {
...defaultPollStrategy,
pollStrategy: pollStrategy
})
).rejects.toThrow(expectedError)
// INFO: invalid 'pollInterval'
invalidPollStrategy = {
maxPollCount: 1,
pollInterval: 0
}
pollStrategy = [invalidPollStrategy]
expectedError = new Error(
`Poll strategies are not valid. 'pollInterval' has to be greater than 0. Invalid poll strategy: \n${JSON.stringify(
invalidPollStrategy,
null,
2
)}`
)
await expect(
pollJobState(requestClient, mockJob, false, undefined, {
...defaultPollStrategy,
pollStrategy: pollStrategy
})
).rejects.toThrow(expectedError)
})
})
describe('doPoll', () => {
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
const jobSessionManager: JobSessionManager = {
sessionManager,
session: {
id: ['id', new Date().getTime(), Math.random()].join('-'),
state: SessionState.NoState,
links: [
{
href: sessionStateLink,
method: 'GET',
rel: 'state',
type: 'text/plain',
uri: sessionStateLink
}
],
attributes: {
sessionInactiveTimeout: 900
},
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
stateUrl: '',
etag: ''
}
}
beforeEach(() => {
setupMocks()
})
it('should check session state on every 10th job state poll', async () => {
const mockedGetSessionState = jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Idle,
responseStatus: 200
})
})
let getSessionStateCount = 0
jest.spyOn(requestClient, 'get').mockImplementation(() => {
getSessionStateCount++
return Promise.resolve({
result:
getSessionStateCount < 20 ? JobState.Running : JobState.Completed,
etag: 'etag-string',
status: 200
})
})
await doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
expect(mockedGetSessionState).toHaveBeenCalledTimes(2)
})
it('should handle error while checking session state', async () => {
const sessionStateError = 'Error while getting session state.'
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.reject(sessionStateError)
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(mockJob.id, new Error(sessionStateError))
)
})
it('should throw an error if session state is not healthy', async () => {
const filteredSessionStates = Object.values(SessionState).filter(
(state) => state !== SessionState.Running && state !== SessionState.Idle
)
const randomSessionState =
filteredSessionStates[
Math.floor(Math.random() * filteredSessionStates.length)
]
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: randomSessionState,
responseStatus: 200
})
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
const mockedClearSession = jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(
mockJob.id,
new Error(
`Session state of the job is not 'running' or 'idle'. Session state is '${randomSessionState}'`
)
)
)
expect(mockedClearSession).toHaveBeenCalledWith(
jobSessionManager.session.id,
mockAuthConfig.access_token
)
})
it('should handle throw an error if response status of session state is not 200', async () => {
const sessionStateResponseStatus = 500
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Running,
responseStatus: sessionStateResponseStatus
})
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
const mockedClearSession = jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(
mockJob.id,
new Error(
`Session response status is not 200. Session response status is ${sessionStateResponseStatus}.`
)
)
)
expect(mockedClearSession).toHaveBeenCalledWith(
jobSessionManager.session.id,
mockAuthConfig.access_token
)
})
}) })
const setupMocks = () => { const setupMocks = () => {
@@ -273,11 +670,14 @@ const setupMocks = () => {
const mockSimplePoll = (runningCount = 2) => { const mockSimplePoll = (runningCount = 2) => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.resolve({ return Promise.resolve({
result: result:
count === 0 count === 0
@@ -293,11 +693,14 @@ const mockSimplePoll = (runningCount = 2) => {
const mockRunningPoll = () => { const mockRunningPoll = () => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.resolve({ return Promise.resolve({
result: count === 0 ? 'pending' : 'running', result: count === 0 ? 'pending' : 'running',
etag: '', etag: '',
@@ -308,11 +711,14 @@ const mockRunningPoll = () => {
const mockLongPoll = () => { const mockLongPoll = () => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.resolve({ return Promise.resolve({
result: count <= 102 ? 'running' : 'completed', result: count <= 102 ? 'running' : 'completed',
etag: '', etag: '',
@@ -323,14 +729,18 @@ const mockLongPoll = () => {
const mockPollWithSingleError = () => { const mockPollWithSingleError = () => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
if (count === 1) { if (count === 1) {
return Promise.reject('Status Error') return Promise.reject('Status Error')
} }
return Promise.resolve({ return Promise.resolve({
result: count === 0 ? 'pending' : 'completed', result: count === 0 ? 'pending' : 'completed',
etag: '', etag: '',
@@ -344,6 +754,7 @@ const mockErroredPoll = () => {
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.reject('Status Error') return Promise.reject('Status Error')
}) })
} }

View File

@@ -1,4 +1,4 @@
import { Logger, LogLevel } from '@sasjs/utils' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks' import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import * as writeStreamModule from '../writeStream' import * as writeStreamModule from '../writeStream'

View File

@@ -5,7 +5,7 @@ import {
fileExists, fileExists,
readFile, readFile,
deleteFile deleteFile
} from '@sasjs/utils' } from '@sasjs/utils/file'
describe('writeStream', () => { describe('writeStream', () => {
const filename = 'test.txt' const filename = 'test.txt'

View File

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

View File

@@ -1,5 +1,7 @@
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
import { ServerType } from '@sasjs/utils/types'
/** /**
* Exchanges the auth code for an access token for the given client. * Exchanges the auth code for an access token for the given client.
@@ -31,6 +33,16 @@ export async function getAccessTokenForSasjs(
} }
}) })
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting access token. ') throw prefixMessage(
err,
getTokenRequestErrorPrefix(
'fetching access token',
'getAccessTokenForSasjs',
ServerType.Sasjs,
url,
data,
clientId
)
)
}) })
} }

View File

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

View File

@@ -0,0 +1,88 @@
import { ServerType } from '@sasjs/utils/types'
type Server = ServerType.SasViya | ServerType.Sasjs
type Operation = 'fetching access token' | 'refreshing tokens'
const getServerName = (server: Server) =>
server === ServerType.SasViya ? 'Viya' : 'Sasjs'
const getResponseTitle = (server: Server) =>
`Response from ${getServerName(server)} is below.`
/**
* Forms error prefix for requests related to token operations.
* @param operation - string describing operation ('fetching access token' or 'refreshing tokens').
* @param funcName - name of the function sent the request.
* @param server - server type (SASVIYA or SASJS).
* @param url - endpoint used to send the request.
* @param data - request payload.
* @param headers - request headers.
* @param clientId - client ID to authenticate with.
* @param clientSecret - client secret to authenticate with.
* @returns - string containing request information. Example:
* Error while fetching access token from /SASLogon/oauth/token
* Thrown by the @sasjs/adapter getAccessTokenForViya function.
* Payload:
* {
* "grant_type": "authorization_code",
* "code": "example_code"
* }
* Headers:
* {
* "Authorization": "Basic NEdMQXBwOjRHTEFwcDE=",
* "Accept": "application/json"
* }
* ClientId: exampleClientId
* ClientSecret: exampleClientSecret
*
* Response from Viya is below.
* Auth error: {
* "error": "invalid_token",
* "error_description": "No scopes were granted"
* }
*/
export const getTokenRequestErrorPrefix = (
operation: Operation,
funcName: string,
server: Server,
url: string,
data?: {},
headers?: {},
clientId?: string,
clientSecret?: string
) => {
const stringify = (obj: {}) => JSON.stringify(obj, null, 2)
const lines = [
`Error while ${operation} from ${url}`,
`Thrown by the @sasjs/adapter ${funcName} function.`
]
if (data) {
lines.push('Payload:')
lines.push(stringify(data))
}
if (headers) {
lines.push('Headers:')
lines.push(stringify(headers))
}
if (clientId) lines.push(`ClientId: ${clientId}`)
if (clientSecret) lines.push(`ClientSecret: ${clientSecret}`)
lines.push('')
lines.push(`${getResponseTitle(server)}`)
lines.push('')
return lines.join(`\n`)
}
/**
* Parse error prefix to get response payload.
* @param prefix - error prefix generated by getTokenRequestErrorPrefix function.
* @param server - server type (SASVIYA or SASJS).
* @returns - response payload.
*/
export const getTokenRequestErrorPrefixResponse = (
prefix: string,
server: ServerType.SasViya | ServerType.Sasjs
) => prefix.split(`${getResponseTitle(server)}\n`).pop() as string

View File

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

View File

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

View File

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

97
src/auth/loginHeader.ts Normal file
View File

@@ -0,0 +1,97 @@
import { ServerType } from '@sasjs/utils/types'
import { getUserLanguage } from '../utils'
const enLoginSuccessHeader = 'You have signed in.'
export const defaultSuccessHeaderKey = 'default'
// The following headers provided by https://github.com/sasjs/adapter/issues/835#issuecomment-2177818601
export const loginSuccessHeaders: { [key: string]: string } = {
es: `Ya se ha iniciado la sesi\u00f3n.`,
th: `\u0e04\u0e38\u0e13\u0e25\u0e07\u0e0a\u0e37\u0e48\u0e2d\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e41\u0e25\u0e49\u0e27`,
ja: `\u30b5\u30a4\u30f3\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002`,
nb: `Du har logget deg p\u00e5.`,
sl: `Prijavili ste se.`,
ar: `\u0644\u0642\u062f \u0642\u0645\u062a `,
sk: `Prihl\u00e1sili ste sa.`,
zh_HK: `\u60a8\u5df2\u767b\u5165\u3002`,
zh_CN: `\u60a8\u5df2\u767b\u5f55\u3002`,
it: `L'utente si \u00e8 connesso.`,
sv: `Du har loggat in.`,
he: `\u05e0\u05db\u05e0\u05e1\u05ea `,
nl: `U hebt zich aangemeld.`,
pl: `Zosta\u0142e\u015b zalogowany.`,
ko: `\ub85c\uadf8\uc778\ud588\uc2b5\ub2c8\ub2e4.`,
zh_TW: `\u60a8\u5df2\u767b\u5165\u3002`,
tr: `Oturum a\u00e7t\u0131n\u0131z.`,
iw: `\u05e0\u05db\u05e0\u05e1\u05ea `,
fr: `Vous \u00eates connect\u00e9.`,
uk: `\u0412\u0438 \u0432\u0432\u0456\u0439\u0448\u043b\u0438 \u0432 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441.`,
pt_BR: `Voc\u00ea se conectou.`,
no: `Du har logget deg p\u00e5.`,
cs: `Jste p\u0159ihl\u00e1\u0161eni.`,
fi: `Olet kirjautunut sis\u00e4\u00e4n.`,
ru: `\u0412\u044b \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u043b\u0438 \u0432\u0445\u043e\u0434 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.`,
el: `\u0388\u03c7\u03b5\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af.`,
hr: `Prijavili ste se.`,
da: `Du er logget p\u00e5.`,
de: `Sie sind jetzt angemeldet.`,
sh: `Prijavljeni ste.`,
pt: `Iniciou sess\u00e3o.`,
hu: `Bejelentkezett.`,
sr: `Prijavljeni ste.`,
en: enLoginSuccessHeader,
[defaultSuccessHeaderKey]: enLoginSuccessHeader
}
/**
* Provides expected login header based on language settings of the browser.
* @returns - expected header as a string.
*/
export const getExpectedLogInSuccessHeader = (): string => {
// get default success header
let successHeader = loginSuccessHeaders[defaultSuccessHeaderKey]
// get user language based on language settings of the browser
const userLang = getUserLanguage()
if (userLang) {
// get success header on exact match of the language code
let userLangSuccessHeader = loginSuccessHeaders[userLang]
// handle case when there is no exact match of the language code
if (!userLangSuccessHeader) {
// get all supported language codes
const headerLanguages = Object.keys(loginSuccessHeaders)
// find language code on partial match
const headerLanguage = headerLanguages.find((language) =>
new RegExp(language, 'i').test(userLang)
)
// reassign success header if partial match was found
if (headerLanguage) {
successHeader = loginSuccessHeaders[headerLanguage]
}
} else {
successHeader = userLangSuccessHeader
}
}
return successHeader
}
/**
* Checks if Login success header is present in the response based on language settings of the browser.
* @param serverType - server type.
* @param response - response object.
* @returns - boolean indicating if Login success header is present.
*/
export const isLogInSuccessHeaderPresent = (
serverType: ServerType,
response: any
): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedIn
return new RegExp(getExpectedLogInSuccessHeader(), 'gm').test(response)
}

View File

@@ -1,5 +1,7 @@
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
import { ServerType } from '@sasjs/utils/types'
/** /**
* Exchanges the refresh token for an access token for the given client. * Exchanges the refresh token for an access token for the given client.
@@ -28,7 +30,15 @@ export async function refreshTokensForSasjs(
} }
}) })
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while refreshing tokens: ') throw prefixMessage(
err,
getTokenRequestErrorPrefix(
'refreshing tokens',
'refreshTokensForSasjs',
ServerType.Sasjs,
url
)
)
}) })
return authResponse return authResponse

View File

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

View File

@@ -1,17 +1,22 @@
/**
* @jest-environment jsdom
*/
import { AuthManager } from '../AuthManager' import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import axios from 'axios' import axios from 'axios'
import { import {
mockedCurrentUserApi, mockedCurrentUserApi,
mockLoginAuthoriseRequiredResponse, mockLoginAuthoriseRequiredResponse
mockLoginSuccessResponse
} from './mockResponses' } from './mockResponses'
import { serialize } from '../../utils' import { serialize } from '../../utils'
import * as openWebPageModule from '../openWebPage' import * as openWebPageModule from '../openWebPage'
import * as verifySasViyaLoginModule from '../verifySasViyaLogin' import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
import * as verifySas9LoginModule from '../verifySas9Login' import * as verifySas9LoginModule from '../verifySas9Login'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { getExpectedLogInSuccessHeader } from '../'
jest.mock('axios') jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -125,6 +130,7 @@ describe('AuthManager', () => {
requestClient, requestClient,
authCallback authCallback
) )
jest.spyOn(authManager, 'checkSession').mockImplementation(() => jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({ Promise.resolve({
isLoggedIn: false, isLoggedIn: false,
@@ -133,8 +139,9 @@ describe('AuthManager', () => {
loginForm: { name: 'test' } loginForm: { name: 'test' }
}) })
) )
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse }) Promise.resolve({ data: getExpectedLogInSuccessHeader() })
) )
const loginResponse = await authManager.logIn(userName, password) const loginResponse = await authManager.logIn(userName, password)
@@ -170,6 +177,7 @@ describe('AuthManager', () => {
requestClient, requestClient,
authCallback authCallback
) )
jest.spyOn(authManager, 'checkSession').mockImplementation(() => jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({ Promise.resolve({
isLoggedIn: false, isLoggedIn: false,
@@ -178,8 +186,9 @@ describe('AuthManager', () => {
loginForm: { name: 'test' } loginForm: { name: 'test' }
}) })
) )
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse }) Promise.resolve({ data: getExpectedLogInSuccessHeader() })
) )
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 })) mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
@@ -365,7 +374,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName) expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`, `${serverUrl}/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -409,7 +418,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName) expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`, `${serverUrl}/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -453,7 +462,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('') expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`, `${serverUrl}/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -497,7 +506,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('') expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`, `${serverUrl}/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
import { ServerType } from '@sasjs/utils/types'
import { getTokenRequestErrorPrefix } from '../getTokenRequestErrorPrefix'
describe('getTokenRequestErrorPrefix', () => {
it('should return error prefix', () => {
// INFO: Viya with only required attributes
let operation: 'fetching access token' = 'fetching access token'
const funcName = 'testFunc'
const url = '/SASjsApi/auth/token'
let expectedPrefix = `Error while ${operation} from ${url}
Thrown by the @sasjs/adapter ${funcName} function.
Response from Viya is below.
`
expect(
getTokenRequestErrorPrefix(operation, funcName, ServerType.SasViya, url)
).toEqual(expectedPrefix)
// INFO: Sasjs with data and headers
const data = {
grant_type: 'authorization_code',
code: 'testCode'
}
const headers = {
Authorization: 'Basic test=',
Accept: 'application/json'
}
expectedPrefix = `Error while ${operation} from ${url}
Thrown by the @sasjs/adapter ${funcName} function.
Payload:
${JSON.stringify(data, null, 2)}
Headers:
${JSON.stringify(headers, null, 2)}
Response from Sasjs is below.
`
expect(
getTokenRequestErrorPrefix(
operation,
funcName,
ServerType.Sasjs,
url,
data,
headers
)
).toEqual(expectedPrefix)
// INFO: Viya with all attributes
const clientId = 'testId'
const clientSecret = 'testSecret'
expectedPrefix = `Error while ${operation} from ${url}
Thrown by the @sasjs/adapter ${funcName} function.
Payload:
${JSON.stringify(data, null, 2)}
Headers:
${JSON.stringify(headers, null, 2)}
ClientId: ${clientId}
ClientSecret: ${clientSecret}
Response from Viya is below.
`
expect(
getTokenRequestErrorPrefix(
operation,
funcName,
ServerType.SasViya,
url,
data,
headers,
clientId,
clientSecret
)
).toEqual(expectedPrefix)
})
})

View File

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

View File

@@ -0,0 +1,82 @@
/**
* @jest-environment jsdom
*/
import { ServerType } from '@sasjs/utils/types'
import {
loginSuccessHeaders,
isLogInSuccessHeaderPresent,
defaultSuccessHeaderKey
} from '../'
describe('isLogInSuccessHeaderPresent', () => {
let languageGetter: any
beforeEach(() => {
languageGetter = jest.spyOn(window.navigator, 'language', 'get')
})
it('should check SASVIYA and SAS9 login success header based on language preferences of the browser', () => {
// test SASVIYA server type
Object.keys(loginSuccessHeaders).forEach((key) => {
languageGetter.mockReturnValue(key)
expect(
isLogInSuccessHeaderPresent(
ServerType.SasViya,
loginSuccessHeaders[key]
)
).toBeTruthy()
})
// test SAS9 server type
Object.keys(loginSuccessHeaders).forEach((key) => {
languageGetter.mockReturnValue(key)
expect(
isLogInSuccessHeaderPresent(ServerType.Sas9, loginSuccessHeaders[key])
).toBeTruthy()
})
// test possible longer language codes
const possibleLanguageCodes = [
{ short: 'en', long: 'en-US' },
{ short: 'fr', long: 'fr-FR' },
{ short: 'es', long: 'es-ES' }
]
possibleLanguageCodes.forEach((key) => {
const { short, long } = key
languageGetter.mockReturnValue(long)
expect(
isLogInSuccessHeaderPresent(
ServerType.SasViya,
loginSuccessHeaders[short]
)
).toBeTruthy()
})
// test falling back to default language code
languageGetter.mockReturnValue('WRONG-LANGUAGE')
expect(
isLogInSuccessHeaderPresent(
ServerType.Sas9,
loginSuccessHeaders[defaultSuccessHeaderKey]
)
).toBeTruthy()
})
it('should check SASVJS login success header', () => {
expect(
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: true })
).toBeTruthy()
expect(
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: false })
).toBeFalsy()
expect(isLogInSuccessHeaderPresent(ServerType.Sasjs, undefined)).toBeFalsy()
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
import { generateFileUploadForm } from '../generateFileUploadForm' import { generateFileUploadForm } from '../generateFileUploadForm'
import { convertToCSV } from '../../utils/convertToCsv'
import * as NodeFormData from 'form-data'
import * as isNodeModule from '../../utils/isNode'
describe('generateFileUploadForm', () => { describe('generateFileUploadForm', () => {
beforeAll(() => { beforeAll(() => {
@@ -11,44 +14,94 @@ describe('generateFileUploadForm', () => {
;(global as any).Blob = BlobMock ;(global as any).Blob = BlobMock
}) })
it('should generate file upload form from data', () => { describe('browser', () => {
const formData = new FormData() afterAll(() => {
const testTable = 'sometable' jest.restoreAllMocks()
const testTableWithNullVars: { [key: string]: any } = { })
[testTable]: [
{ var1: 'string', var2: 232, nullvar: 'A' },
{ var1: 'string', var2: 232, nullvar: 'B' },
{ var1: 'string', var2: 232, nullvar: '_' },
{ var1: 'string', var2: 232, nullvar: 0 },
{ var1: 'string', var2: 232, nullvar: 'z' },
{ var1: 'string', var2: 232, nullvar: null }
],
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
}
const tableName = Object.keys(testTableWithNullVars).filter((key: string) =>
Array.isArray(testTableWithNullVars[key])
)[0]
jest.spyOn(formData, 'append').mockImplementation(() => {}) it('should generate file upload form from data', () => {
const formData = new FormData()
const testTable = 'sometable'
const testTableWithNullVars: { [key: string]: any } = {
[testTable]: [
{ var1: 'string', var2: 232, nullvar: 'A' },
{ var1: 'string', var2: 232, nullvar: 'B' },
{ var1: 'string', var2: 232, nullvar: '_' },
{ var1: 'string', var2: 232, nullvar: 0 },
{ var1: 'string', var2: 232, nullvar: 'z' },
{ var1: 'string', var2: 232, nullvar: null }
],
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
}
const tableName = Object.keys(testTableWithNullVars).filter(
(key: string) => Array.isArray(testTableWithNullVars[key])
)[0]
generateFileUploadForm(formData, testTableWithNullVars) jest.spyOn(formData, 'append').mockImplementation(() => {})
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
expect(formData.append).toHaveBeenCalledOnce() generateFileUploadForm(formData, testTableWithNullVars)
expect(formData.append).toHaveBeenCalledWith(
tableName, expect(formData.append).toHaveBeenCalledOnce()
{}, expect(formData.append).toHaveBeenCalledWith(
`${tableName}.csv` tableName,
) {},
`${tableName}.csv`
)
})
it('should throw an error if too large string was provided', () => {
const formData = new FormData()
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
expect(() => generateFileUploadForm(formData, data)).toThrow(
new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
)
})
}) })
it('should throw an error if too large string was provided', () => { describe('node', () => {
const formData = new FormData() it('should generate file upload form from data', () => {
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] } const formData = new NodeFormData()
const testTable = 'sometable'
const testTableWithNullVars: { [key: string]: any } = {
[testTable]: [
{ var1: 'string', var2: 232, nullvar: 'A' },
{ var1: 'string', var2: 232, nullvar: 'B' },
{ var1: 'string', var2: 232, nullvar: '_' },
{ var1: 'string', var2: 232, nullvar: 0 },
{ var1: 'string', var2: 232, nullvar: 'z' },
{ var1: 'string', var2: 232, nullvar: null }
],
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
}
const tableName = Object.keys(testTableWithNullVars).filter(
(key: string) => Array.isArray(testTableWithNullVars[key])
)[0]
const csv = convertToCSV(testTableWithNullVars, tableName)
expect(() => generateFileUploadForm(formData, data)).toThrow( jest.spyOn(formData, 'append').mockImplementation(() => {})
new Error(
'The max length of a string value in SASjs is 32765 characters.' generateFileUploadForm(formData, testTableWithNullVars)
expect(formData.append).toHaveBeenCalledOnce()
expect(formData.append).toHaveBeenCalledWith(tableName, csv, {
contentType: 'application/csv',
filename: `${tableName}.csv`
})
})
it('should throw an error if too large string was provided', () => {
const formData = new NodeFormData()
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
expect(() => generateFileUploadForm(formData, data)).toThrow(
new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
) )
) })
}) })
}) })

View File

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

View File

@@ -10,10 +10,14 @@ import {
LoginRequiredError LoginRequiredError
} from '../types/errors' } from '../types/errors'
import { generateFileUploadForm } from '../file/generateFileUploadForm' import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getFormData } from '../utils'
import { isRelativePath, appendExtraResponseAttributes } from '../utils' import {
isRelativePath,
appendExtraResponseAttributes,
getValidJson
} from '../utils'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'
export class SasjsJobExecutor extends BaseJobExecutor { export class SasjsJobExecutor extends BaseJobExecutor {
@@ -49,8 +53,7 @@ export class SasjsJobExecutor extends BaseJobExecutor {
* Use the available form data object (FormData in Browser, NodeFormData in * Use the available form data object (FormData in Browser, NodeFormData in
* Node) * Node)
*/ */
let formData = let formData = getFormData()
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) { if (data) {
// file upload approach // file upload approach
@@ -89,12 +92,18 @@ export class SasjsJobExecutor extends BaseJobExecutor {
) )
} }
const { result } = res
if (result && typeof result === 'string' && result.trim())
res.result = getValidJson(result)
this.requestClient!.appendRequest(res, sasJob, config.debug) this.requestClient!.appendRequest(res, sasJob, config.debug)
const responseObject = appendExtraResponseAttributes( const responseObject = appendExtraResponseAttributes(
res, res,
extraResponseAttributes extraResponseAttributes
) )
resolve(responseObject) resolve(responseObject)
}) })
.catch(async (e: Error) => { .catch(async (e: Error) => {

View File

@@ -16,10 +16,11 @@ import { SASViyaApiClient } from '../SASViyaApiClient'
import { import {
isRelativePath, isRelativePath,
parseSasViyaDebugResponse, parseSasViyaDebugResponse,
appendExtraResponseAttributes appendExtraResponseAttributes,
parseWeboutResponse,
getFormData
} from '../utils' } from '../utils'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
export interface WaitingRequstPromise { export interface WaitingRequstPromise {
promise: Promise<any> | null promise: Promise<any> | null
@@ -112,8 +113,7 @@ export class WebJobExecutor extends BaseJobExecutor {
* Use the available form data object (FormData in Browser, NodeFormData in * Use the available form data object (FormData in Browser, NodeFormData in
* Node) * Node)
*/ */
let formData = let formData = getFormData()
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) { if (data) {
const stringifiedData = JSON.stringify(data) const stringifiedData = JSON.stringify(data)

274
src/minified/sas9/SASjs.ts Normal file
View File

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

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

View File

@@ -0,0 +1,3 @@
import SASjs from './SASjs'
export * from '../../types'
export default SASjs

View File

@@ -1,4 +1,10 @@
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse
} from 'axios'
import axios from 'axios'
import * as https from 'https' import * as https from 'https'
import { CsrfToken } from '..' import { CsrfToken } from '..'
import { isAuthorizeFormRequired, isLogInRequired } from '../auth' import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
@@ -10,7 +16,7 @@ import {
JobExecutionError, JobExecutionError,
CertificateError CertificateError
} from '../types/errors' } from '../types/errors'
import { SASjsRequest } from '../types' import { SASjsRequest, HttpClient, VerboseMode } from '../types'
import { parseWeboutResponse } from '../utils/parseWeboutResponse' import { parseWeboutResponse } from '../utils/parseWeboutResponse'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { SAS9AuthError } from '../types/errors/SAS9AuthError' import { SAS9AuthError } from '../types/errors/SAS9AuthError'
@@ -20,45 +26,13 @@ import {
createAxiosInstance createAxiosInstance
} from '../utils' } from '../utils'
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError' import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
import { inspect } from 'util'
export interface HttpClient {
get<T>(
url: string,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
delete<T>(
url: string,
accessToken: string | undefined
): Promise<{ result: T; etag: string }>
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
saveLocalStorageToken(accessToken: string, refreshToken: string): void
clearCsrfTokens(): void
clearLocalStorageTokens(): void
getBaseUrl(): string
}
export class RequestClient implements HttpClient { export class RequestClient implements HttpClient {
private requests: SASjsRequest[] = [] private requests: SASjsRequest[] = []
private requestsLimit: number = 10 private requestsLimit: number = 10
private httpInterceptor?: number
private verboseMode: VerboseMode = false
protected csrfToken: CsrfToken = { headerName: '', value: '' } protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined protected fileUploadCsrfToken: CsrfToken | undefined
@@ -67,10 +41,17 @@ export class RequestClient implements HttpClient {
constructor( constructor(
protected baseUrl: string, protected baseUrl: string,
httpsAgentOptions?: https.AgentOptions, httpsAgentOptions?: https.AgentOptions,
requestsLimit?: number requestsLimit?: number,
verboseMode?: VerboseMode
) { ) {
this.createHttpClient(baseUrl, httpsAgentOptions) this.createHttpClient(baseUrl, httpsAgentOptions)
if (requestsLimit) this.requestsLimit = requestsLimit if (requestsLimit) this.requestsLimit = requestsLimit
if (verboseMode) {
this.setVerboseMode(verboseMode)
this.enableVerboseMode()
}
} }
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) { public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
@@ -90,6 +71,7 @@ export class RequestClient implements HttpClient {
this.csrfToken = { headerName: '', value: '' } this.csrfToken = { headerName: '', value: '' }
this.fileUploadCsrfToken = { headerName: '', value: '' } this.fileUploadCsrfToken = { headerName: '', value: '' }
} }
public clearLocalStorageTokens() { public clearLocalStorageTokens() {
localStorage.setItem('accessToken', '') localStorage.setItem('accessToken', '')
localStorage.setItem('refreshToken', '') localStorage.setItem('refreshToken', '')
@@ -180,6 +162,7 @@ export class RequestClient implements HttpClient {
responseType: contentType === 'text/plain' ? 'text' : 'json', responseType: contentType === 'text/plain' ? 'text' : 'json',
withCredentials: true withCredentials: true
} }
if (contentType === 'text/plain') { if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined requestConfig.transformResponse = undefined
} }
@@ -389,6 +372,181 @@ export class RequestClient implements HttpClient {
}) })
} }
/**
* Adds colors to the string.
* If verboseMode is set to 'bleached', colors should be disabled
* @param str - string to be prettified.
* @returns - prettified string
*/
private prettifyString = (str: any) =>
inspect(str, { colors: this.verboseMode !== 'bleached' })
/**
* Formats HTTP request/response body.
* @param body - HTTP request/response body.
* @returns - formatted string.
*/
private parseInterceptedBody = (body: any) => {
if (!body) return ''
let parsedBody
// Tries to parse body into JSON object.
if (typeof body === 'string') {
try {
parsedBody = JSON.parse(body)
} catch (error) {
parsedBody = body
}
} else {
parsedBody = body
}
const bodyLines = this.prettifyString(parsedBody).split('\n')
// Leaves first 50 lines
if (bodyLines.length > 51) {
bodyLines.splice(50)
bodyLines.push('...')
}
return bodyLines.join('\n')
}
private defaultInterceptionCallBack = (
axiosResponse: AxiosResponse | AxiosError
) => {
// Message indicating absent value.
const noValueMessage = 'Not provided'
// Fallback request object that can be safely used to form request summary.
type FallbackRequest = { _header?: string; res: { rawHeaders: string[] } }
// _header is not present in responses with status 1**
// rawHeaders are not present in responses with status 1**
let fallbackRequest: FallbackRequest = {
_header: `${noValueMessage}\n`,
res: { rawHeaders: [noValueMessage] }
}
// Fallback response object that can be safely used to form response summary.
type FallbackResponse = {
status?: number | string
request?: FallbackRequest
config: { data?: string }
data?: unknown
}
let fallbackResponse: FallbackResponse = axiosResponse
if (axios.isAxiosError(axiosResponse)) {
const { response, request, config } = axiosResponse
// Try to use axiosResponse.response to form response summary.
if (response) {
fallbackResponse = response
} else {
// Try to use axiosResponse.request to form request summary.
if (request) {
const { _header, _currentRequest } = request
// Try to use axiosResponse.request._header to form request summary.
if (_header) {
fallbackRequest._header = _header
}
// Try to use axiosResponse.request._currentRequest._header to form request summary.
else if (_currentRequest && _currentRequest._header) {
fallbackRequest._header = _currentRequest._header
}
const { res } = request
// Try to use axiosResponse.request.res.rawHeaders to form request summary.
if (res && res.rawHeaders) {
fallbackRequest.res.rawHeaders = res.rawHeaders
}
}
// Fallback config that can be safely used to form response summary.
const fallbackConfig = { data: noValueMessage }
fallbackResponse = {
status: noValueMessage,
request: fallbackRequest,
config: config || fallbackConfig,
data: noValueMessage
}
}
}
const { status, config, request, data: resData } = fallbackResponse
const { data: reqData } = config
const { _header: reqHeaders, res } = request || fallbackRequest
const { rawHeaders } = res
// Converts an array of strings into a single string with the following format:
// <headerName>: <headerValue>
const resHeaders = rawHeaders.reduce(
(acc: string, value: string, i: number) => {
if (i % 2 === 0) {
acc += `${i === 0 ? '' : '\n'}${value}`
} else {
acc += `: ${value}`
}
return acc
},
''
)
const parsedResBody = this.parseInterceptedBody(resData)
// HTTP response summary.
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(reqData)}
HTTP Response Code: ${this.prettifyString(status)}
HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return axiosResponse
}
/**
* Sets verbose mode.
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
*/
public setVerboseMode = (verboseMode: VerboseMode) => {
this.verboseMode = verboseMode
if (this.verboseMode) this.enableVerboseMode()
else this.disableVerboseMode()
}
/**
* Turns on verbose mode to log every HTTP response.
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode = (
successCallBack = this.defaultInterceptionCallBack,
errorCallBack = this.defaultInterceptionCallBack
) => {
this.httpInterceptor = this.httpClient.interceptors.response.use(
successCallBack,
errorCallBack
)
}
/**
* Turns off verbose mode to log every HTTP response.
*/
public disableVerboseMode = () => {
if (this.httpInterceptor) {
this.httpClient.interceptors.response.eject(this.httpInterceptor)
}
}
protected getHeaders = ( protected getHeaders = (
accessToken: string | undefined, accessToken: string | undefined,
contentType: string contentType: string
@@ -691,7 +849,9 @@ const parseError = (data: string) => {
const parts = data.split(/stored process not found: /i) const parts = data.split(/stored process not found: /i)
if (parts.length > 1) { if (parts.length > 1) {
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0] const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
const message = `Stored process not found: ${storedProcessPath}` const message = storedProcessPath.endsWith('runner')
? `SASJS runner not found. Here's the link (https://cli.sasjs.io/auth/#sasjs-runner) to the SAS code for registering the SASjs runner`
: `Stored process not found: ${storedProcessPath}`
return new JobExecutionError(500, message, '') return new JobExecutionError(500, message, '')
} }
} }

View File

@@ -4,6 +4,7 @@ import axiosCookieJarSupport from 'axios-cookiejar-support'
import * as tough from 'tough-cookie' import * as tough from 'tough-cookie'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient, throwIfError } from './RequestClient' import { RequestClient, throwIfError } from './RequestClient'
import { JobExecutionError } from '../types/errors'
/** /**
* Specific request client for SAS9 in Node.js environments. * Specific request client for SAS9 in Node.js environments.
@@ -69,6 +70,8 @@ export class Sas9RequestClient extends RequestClient {
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
}) })
.catch(async (e: any) => { .catch(async (e: any) => {
if (e instanceof JobExecutionError) throw e
return await this.handleError( return await this.handleError(
e, e,
() => () =>

View File

@@ -1,12 +1,11 @@
import { RequestClient } from './RequestClient' import { RequestClient } from './RequestClient'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import { SASJS_LOGS_SEPARATOR, getValidJson } from '../utils' import { SasjsParsedResponse } from '../types'
/** /**
* Specific request client for SASJS. * Specific request client for SASJS.
* Append tokens in headers. * Append tokens in headers.
*/ */
export class SasjsRequestClient extends RequestClient { export class SasjsRequestClient extends RequestClient {
getHeaders = (accessToken: string | undefined, contentType: string) => { getHeaders = (accessToken: string | undefined, contentType: string) => {
const headers: any = {} const headers: any = {}
@@ -27,7 +26,7 @@ export class SasjsRequestClient extends RequestClient {
protected parseResponse<T>(response: AxiosResponse<any>) { protected parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : '' const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse = {} let parsedResponse = {}
let log let webout, log, printOutput
try { try {
if (typeof response.data === 'string') { if (typeof response.data === 'string') {
@@ -37,18 +36,47 @@ export class SasjsRequestClient extends RequestClient {
} }
} catch { } catch {
if (response.data.includes(SASJS_LOGS_SEPARATOR)) { if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR) const { data } = response
log = splittedResponse[1] const splittedResponse: string[] = data.split(SASJS_LOGS_SEPARATOR)
if (splittedResponse[0].trim())
parsedResponse = getValidJson(splittedResponse[0]) webout = splittedResponse.splice(0, 1)[0]
} else parsedResponse = response.data if (webout !== undefined) parsedResponse = webout
// log can contain nested logs
const logs = splittedResponse.splice(0, splittedResponse.length - 1)
// tests if string ends with SASJS_LOGS_SEPARATOR
const endingWithLogSepRegExp = new RegExp(`${SASJS_LOGS_SEPARATOR}$`)
// at this point splittedResponse can contain only one item
const lastChunk = splittedResponse[0]
if (lastChunk) {
// if the last chunk doesn't end with SASJS_LOGS_SEPARATOR, then it is a printOutput
// else the last chunk is part of the log and has to be joined
if (!endingWithLogSepRegExp.test(data)) printOutput = lastChunk
else if (logs.length > 1) logs.push(lastChunk)
}
// join logs into single log with SASJS_LOGS_SEPARATOR
log = logs.join(SASJS_LOGS_SEPARATOR)
} else {
parsedResponse = response.data
}
} }
return { const returnResult: SasjsParsedResponse<T> = {
result: parsedResponse as T, result: parsedResponse as T,
log, log: log || '',
etag, etag,
status: response.status status: response.status
} }
if (printOutput) returnResult.printOutput = printOutput
return returnResult
} }
} }
export const SASJS_LOGS_SEPARATOR =
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'

View File

@@ -0,0 +1,169 @@
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
import { SasjsParsedResponse } from '../../types'
import { AxiosResponse } from 'axios'
describe('SasjsRequestClient', () => {
const requestClient = new SasjsRequestClient('')
const etag = 'etag'
const status = 200
const webout = `hello`
const log = `1 The SAS System Tuesday, 25 July 2023 12:51:00
PROC MIGRATE will preserve current SAS file attributes and is
recommended for converting all your SAS libraries from any
SAS 8 release to SAS 9. For details and examples, please see
http://support.sas.com/rnd/migration/index.html
NOTE: SAS initialization used:
real time 0.01 seconds
cpu time 0.02 seconds
`
const printOutput = 'printOutPut'
describe('parseResponse', () => {})
it('should parse response with 1 log', () => {
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${log}
`,
etag,
status
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
it('should parse response with 1 log and printOutput', () => {
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
${printOutput}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${log}
`,
etag,
status,
printOutput: `
${printOutput}`
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
it('should parse response with nested logs', () => {
const logWithNestedLog = `root log start
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
root log end`
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${logWithNestedLog}
${SASJS_LOGS_SEPARATOR}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${logWithNestedLog}
`,
etag,
status
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
it('should parse response with nested logs and printOutput', () => {
const logWithNestedLog = `root log start
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
log with indentation
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
some SAS code containing ${SASJS_LOGS_SEPARATOR}
root log end`
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${logWithNestedLog}
${SASJS_LOGS_SEPARATOR}
${printOutput}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${logWithNestedLog}
`,
etag,
status,
printOutput: `
${printOutput}`
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
})
describe('SASJS_LOGS_SEPARATOR', () => {
it('SASJS_LOGS_SEPARATOR should be hardcoded', () => {
expect(SASJS_LOGS_SEPARATOR).toEqual(
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
)
})
})

View File

@@ -2,17 +2,22 @@ import * as pem from 'pem'
import * as http from 'http' import * as http from 'http'
import * as https from 'https' import * as https from 'https'
import { app, mockedAuthResponse } from './SAS_server_app' import { app, mockedAuthResponse } from './SAS_server_app'
import { ServerType } from '@sasjs/utils' import { ServerType } from '@sasjs/utils/types'
import SASjs from '../SASjs' import SASjs from '../SASjs'
import * as axiosModules from '../utils/createAxiosInstance' import * as axiosModules from '../utils/createAxiosInstance'
import axios from 'axios'
import { import {
LoginRequiredError, LoginRequiredError,
AuthorizeError, AuthorizeError,
NotFoundError, NotFoundError,
InternalServerError InternalServerError,
} from '../types/errors' VerboseMode
import { prefixMessage } from '@sasjs/utils/error' } from '../types'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix'
import { AxiosResponse, AxiosError } from 'axios'
import { Logger, LogLevel } from '@sasjs/utils/logger'
import * as UtilsModule from 'util'
const axiosActual = jest.requireActual('axios') const axiosActual = jest.requireActual('axios')
@@ -25,16 +30,6 @@ jest
const PORT = 8000 const PORT = 8000
const SERVER_URL = `https://localhost:${PORT}/` const SERVER_URL = `https://localhost:${PORT}/`
const ERROR_MESSAGES = {
selfSigned: 'self signed certificate',
CCA: 'unable to verify the first certificate'
}
const incorrectAuthCodeErr = {
error: 'unauthorized',
error_description: 'Bad credentials'
}
describe('RequestClient', () => { describe('RequestClient', () => {
let server: http.Server let server: http.Server
@@ -66,14 +61,423 @@ describe('RequestClient', () => {
}) })
it('should response the POST method with Unauthorized', async () => { it('should response the POST method with Unauthorized', async () => {
await expect( const expectedError = new LoginRequiredError({
adapter.getAccessToken('clientId', 'clientSecret', 'incorrect') error: 'unauthorized',
).rejects.toEqual( error_description: 'Bad credentials'
prefixMessage( })
new LoginRequiredError(incorrectAuthCodeErr),
'Error while getting access token. ' const rejectionErrorMessage = await adapter
.getAccessToken('clientId', 'clientSecret', 'incorrect')
.catch((err) =>
getTokenRequestErrorPrefixResponse(err.message, ServerType.SasViya)
) )
)
expect(rejectionErrorMessage).toEqual(expectedError.message)
})
describe('defaultInterceptionCallBack', () => {
const reqHeaders = `POST https://sas.server.com/compute/sessions/session_id/jobs HTTP/1.1
Accept: application/json
Content-Type: application/json
User-Agent: axios/0.27.2
Content-Length: 334
host: sas.server.io
Connection: close
`
const reqData = `{
name: 'test_job',
description: 'Powered by SASjs',
code: ['test_code'],
variables: {
SYS_JES_JOB_URI: '',
_program: '/Public/sasjs/jobs/jobs/test_job'
},
arguments: {
_contextName: 'SAS Job Execution compute context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
}`
const resHeaders = ['content-type', 'application/json']
const resData = {
id: 'id_string',
name: 'name_string',
uri: 'uri_string',
createdBy: 'createdBy_string',
code: 'TEST CODE',
links: [
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'self',
href: 'self_href_string',
uri: 'uri_string',
type: 'type_string'
}
],
results: { '_webout.json': '_webout.json_string' },
logStatistics: {
lineCount: 1,
modifiedTimeStamp: 'modifiedTimeStamp_string'
}
}
beforeAll(() => {
;(process as any).logger = new Logger(LogLevel.Off)
jest.spyOn((process as any).logger, 'info')
})
it('should log parsed response with status 1**', () => {
const spyIsAxiosError = jest
.spyOn(axios, 'isAxiosError')
.mockImplementation(() => true)
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
}
} as AxiosError
const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedAxiosError)
const noValueMessage = 'Not provided'
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
HTTP Response (first 50 lines):
${noValueMessage}
\n${requestClient['parseInterceptedBody'](noValueMessage)}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
spyIsAxiosError.mockReset()
})
it('should log parsed response with status 2**', () => {
const status = getRandomStatus([
200, 201, 202, 203, 204, 205, 206, 207, 208, 226
])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: { data: reqData },
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedResponse)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
})
it('should log parsed response with status 3**', () => {
const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: { data: reqData },
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedResponse)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
})
it('should log parsed response with status 4**', () => {
const spyIsAxiosError = jest
.spyOn(axios, 'isAxiosError')
.mockImplementation(() => true)
const status = getRandomStatus([
400, 401, 402, 403, 404, 407, 408, 409, 410, 411, 412, 413, 414, 415,
416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451
])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: { data: reqData },
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
},
response: mockedResponse
} as AxiosError
const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedAxiosError)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
spyIsAxiosError.mockReset()
})
it('should log parsed response with status 5**', () => {
const spyIsAxiosError = jest
.spyOn(axios, 'isAxiosError')
.mockImplementation(() => true)
const status = getRandomStatus([
500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511
])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: { data: reqData },
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
},
response: mockedResponse
} as AxiosError
const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedAxiosError)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
spyIsAxiosError.mockReset()
})
})
describe('enableVerboseMode', () => {
it('should add defaultInterceptionCallBack functions to response interceptors', () => {
const requestClient = new RequestClient('')
const interceptorSpy = jest.spyOn(
requestClient['httpClient'].interceptors.response,
'use'
)
requestClient.enableVerboseMode()
expect(interceptorSpy).toHaveBeenCalledWith(
requestClient['defaultInterceptionCallBack'],
requestClient['defaultInterceptionCallBack']
)
})
it('should add callback functions to response interceptors', () => {
const requestClient = new RequestClient('')
const interceptorSpy = jest.spyOn(
requestClient['httpClient'].interceptors.response,
'use'
)
const successCallback = (response: AxiosResponse | AxiosError) => {
console.log('success')
return response
}
const failureCallback = (response: AxiosResponse | AxiosError) => {
console.log('failure')
return response
}
requestClient.enableVerboseMode(successCallback, failureCallback)
expect(interceptorSpy).toHaveBeenCalledWith(
successCallback,
failureCallback
)
})
})
describe('setVerboseMode', () => {
it(`should set verbose mode`, () => {
const requestClient = new RequestClient('')
let verbose: VerboseMode = false
requestClient.setVerboseMode(verbose)
expect(requestClient['verboseMode']).toEqual(verbose)
verbose = true
requestClient.setVerboseMode(verbose)
expect(requestClient['verboseMode']).toEqual(verbose)
verbose = 'bleached'
requestClient.setVerboseMode(verbose)
expect(requestClient['verboseMode']).toEqual(verbose)
})
})
describe('prettifyString', () => {
it(`should call inspect without colors when verbose mode is set to 'bleached'`, () => {
const requestClient = new RequestClient('')
let verbose: VerboseMode = 'bleached'
requestClient.setVerboseMode(verbose)
jest.spyOn(UtilsModule, 'inspect')
const testStr = JSON.stringify({ test: 'test' })
requestClient['prettifyString'](testStr)
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
colors: false
})
})
it(`should call inspect with colors when verbose mode is set to 'true'`, () => {
const requestClient = new RequestClient('')
let verbose: VerboseMode = true
requestClient.setVerboseMode(verbose)
jest.spyOn(UtilsModule, 'inspect')
const testStr = JSON.stringify({ test: 'test' })
requestClient['prettifyString'](testStr)
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
colors: true
})
})
})
describe('disableVerboseMode', () => {
it('should eject interceptor', () => {
const requestClient = new RequestClient('')
const interceptorSpy = jest.spyOn(
requestClient['httpClient'].interceptors.response,
'eject'
)
const interceptorId = 100
requestClient['httpInterceptor'] = interceptorId
requestClient.disableVerboseMode()
expect(interceptorSpy).toHaveBeenCalledWith(interceptorId)
})
}) })
describe('handleError', () => { describe('handleError', () => {
@@ -209,15 +613,15 @@ describe('RequestClient - Self Signed Server', () => {
serverType: ServerType.SasViya serverType: ServerType.SasViya
}) })
await expect( const expectedError = 'self-signed certificate'
adapterWithoutCertificate.getAccessToken(
'clientId', const rejectionErrorMessage = await adapterWithoutCertificate
'clientSecret', .getAccessToken('clientId', 'clientSecret', 'authCode')
'authCode' .catch((err) =>
getTokenRequestErrorPrefixResponse(err.message, ServerType.SasViya)
) )
).rejects.toThrow(
`Error while getting access token. ${ERROR_MESSAGES.selfSigned}` expect(rejectionErrorMessage).toEqual(expectedError)
)
}) })
it('should response the POST method using insecure flag', async () => { it('should response the POST method using insecure flag', async () => {
@@ -247,14 +651,18 @@ describe('RequestClient - Self Signed Server', () => {
}) })
it('should response the POST method with Unauthorized', async () => { it('should response the POST method with Unauthorized', async () => {
await expect( const expectedError = new LoginRequiredError({
adapter.getAccessToken('clientId', 'clientSecret', 'incorrect') error: 'unauthorized',
).rejects.toEqual( error_description: 'Bad credentials'
prefixMessage( })
new LoginRequiredError(incorrectAuthCodeErr),
'Error while getting access token. ' const rejectionErrorMessage = await adapter
.getAccessToken('clientId', 'clientSecret', 'incorrect')
.catch((err) =>
getTokenRequestErrorPrefixResponse(err.message, ServerType.SasViya)
) )
)
expect(rejectionErrorMessage).toEqual(expectedError.message)
}) })
}) })
@@ -285,3 +693,11 @@ const createCertificate = async (): Promise<pem.CertificateCreationResult> => {
) )
}) })
} }
/**
* Returns a random status code.
* @param statuses - an array of available statuses.
* @returns - random item from an array of statuses.
*/
const getRandomStatus = (statuses: number[]) =>
statuses[Math.floor(Math.random() * statuses.length)]

View File

@@ -2,20 +2,48 @@ import { SessionManager } from '../SessionManager'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import axios from 'axios' import axios from 'axios'
import { Logger, LogLevel } from '@sasjs/utils' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { Session } from '../types' import { Session, SessionState, Context } from '../types'
jest.mock('axios') jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('SessionManager', () => { describe('SessionManager', () => {
dotenv.config() dotenv.config()
process.env.SERVER_URL = 'https://server.com'
const sessionManager = new SessionManager( const sessionManager = new SessionManager(
process.env.SERVER_URL as string, process.env.SERVER_URL as string,
process.env.DEFAULT_COMPUTE_CONTEXT as string, process.env.DEFAULT_COMPUTE_CONTEXT as string,
new RequestClient('https://sample.server.com') requestClient
) )
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
const sessionEtag = 'etag-string'
const getMockSession = (): 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: sessionStateLink,
etag: sessionEtag
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('getVariable', () => { describe('getVariable', () => {
it('should fetch session variable', async () => { it('should fetch session variable', async () => {
@@ -45,24 +73,50 @@ describe('SessionManager', () => {
) )
).resolves.toEqual(expectedResponse) ).resolves.toEqual(expectedResponse)
}) })
it('should throw an error if GET request failed', async () => {
const responseStatus = 500
const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}`
const response = {
status: responseStatus,
data: {
message: responseErrorMessage
}
}
const testVariable = 'testVariable'
jest.spyOn(requestClient, 'get').mockImplementation(() =>
Promise.reject({
response
})
)
const expectedError = `Error while fetching session variable '${testVariable}'. GET request to ${process.env.SERVER_URL}/compute/sessions/testId/variables/${testVariable} failed with status code ${responseStatus}. ${responseErrorMessage}`
await expect(
sessionManager.getVariable('testId', testVariable)
).rejects.toEqual(expectedError)
})
}) })
describe('waitForSession', () => { describe('waitForSession', () => {
const session: Session = { const session: Session = {
id: 'id', id: 'id',
state: '', state: SessionState.NoState,
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }], links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
attributes: { attributes: {
sessionInactiveTimeout: 0 sessionInactiveTimeout: 0
}, },
creationTimeStamp: '' creationTimeStamp: '',
stateUrl: sessionStateLink,
etag: sessionEtag
} }
beforeEach(() => { beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off) ;(process as any).logger = new Logger(LogLevel.Off)
}) })
it('should reject with NoSessionStateError if SAS server did not provide session state', async () => { it('should log http response code and session state if SAS server did not provide session state', async () => {
let requestAttempt = 0 let requestAttempt = 0
const requestAttemptLimit = 10 const requestAttemptLimit = 10
const sessionState = 'idle' const sessionState = 'idle'
@@ -85,15 +139,17 @@ describe('SessionManager', () => {
sessionManager['waitForSession'](session, null, 'access_token') sessionManager['waitForSession'](session, null, 'access_token')
).resolves.toEqual(sessionState) ).resolves.toEqual(sessionState)
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit) expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
expect((process as any).logger.info).toHaveBeenCalledTimes(3) expect((process as any).logger.info).toHaveBeenCalledTimes(3)
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
1, 1,
`Polling: ${process.env.SERVER_URL}` `Polling: ${sessionStateUrl}`
) )
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
2, 2,
`Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}` `Could not get session state. Server responded with 304 whilst checking state: ${sessionStateUrl}`
) )
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
3, 3,
@@ -103,7 +159,7 @@ describe('SessionManager', () => {
it('should throw an error if there is no session link', async () => { it('should throw an error if there is no session link', async () => {
const customSession = JSON.parse(JSON.stringify(session)) const customSession = JSON.parse(JSON.stringify(session))
customSession.links = [] customSession.stateUrl = ''
mockedAxios.get.mockImplementation(() => mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: customSession.state, status: 200 }) Promise.resolve({ data: customSession.state, status: 200 })
@@ -115,11 +171,26 @@ describe('SessionManager', () => {
}) })
it('should throw an error if could not get session state', async () => { it('should throw an error if could not get session state', async () => {
mockedAxios.get.mockImplementation(() => Promise.reject('Mocked error')) const gettingSessionStatus = 500
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
mockedAxios.get.mockImplementation(() =>
Promise.reject({
response: {
status: gettingSessionStatus,
data: {
message: sessionStatusError
}
}
})
)
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${sessionStateUrl}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
await expect( await expect(
sessionManager['waitForSession'](session, null, 'access_token') sessionManager['waitForSession'](session, null, 'access_token')
).rejects.toContain('Error while getting session state.') ).rejects.toEqual(expectedError)
}) })
it('should return session state', async () => { it('should return session state', async () => {
@@ -135,4 +206,284 @@ describe('SessionManager', () => {
).resolves.toEqual(customSession.state) ).resolves.toEqual(customSession.state)
}) })
}) })
describe('isSessionValid', () => {
const session: Session = getMockSession()
it('should return false if not a session provided', () => {
expect(sessionManager['isSessionValid'](undefined as any)).toEqual(false)
})
it('should return true if session is not expired', () => {
expect(sessionManager['isSessionValid'](session)).toEqual(true)
})
it('should return false if session is expired', () => {
session.creationTimeStamp = `${new Date(
new Date().getTime() -
(session.attributes.sessionInactiveTimeout * 1000 + 1000)
).toISOString()}`
expect(sessionManager['isSessionValid'](session)).toEqual(false)
})
})
describe('removeSessionFromPool', () => {
it('should remove session from the pool of sessions', () => {
const session: Session = getMockSession()
const sessions: Session[] = [getMockSession(), session]
sessionManager['sessions'] = sessions
sessionManager['removeSessionFromPool'](session)
expect(sessionManager['sessions'].length).toEqual(1)
})
})
describe('getSession', () => {
it('should return session if there is a valid session and create new session', async () => {
jest
.spyOn(sessionManager as any, 'createAndWaitForSession')
.mockImplementation(async () => Promise.resolve(getMockSession()))
const session = getMockSession()
sessionManager['sessions'] = [session]
await expect(sessionManager.getSession()).resolves.toEqual(session)
expect(sessionManager['createAndWaitForSession']).toHaveBeenCalled()
})
it('should return a session and keep one session if there is no sessions available', async () => {
jest
.spyOn(sessionManager as any, 'createAndWaitForSession')
.mockImplementation(async () => {
const session = getMockSession()
sessionManager['sessions'].push(session)
return Promise.resolve(session)
})
const session = await sessionManager.getSession()
expect(Object.keys(session)).toEqual(Object.keys(getMockSession()))
expect(sessionManager['createAndWaitForSession']).toHaveBeenCalledTimes(2)
expect(sessionManager['sessions'].length).toEqual(1)
})
it.concurrent(
'should throw an error if session creation request returned 500',
async () => {
const sessionCreationStatus = 500
const sessionCreationError = `The process initialization for the Compute server with the ID 'ed40398a-ec8a-422b-867a-61493ee8a57f' timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}`
jest.spyOn(requestClient, 'post').mockImplementation(() =>
Promise.reject({
response: {
status: sessionCreationStatus,
data: {
message: sessionCreationError
}
}
})
)
const contextId = 'testContextId'
const context: Context = {
name: 'testContext',
id: contextId,
createdBy: 'createdBy',
version: 1
}
sessionManager['currentContext'] = context
const expectedError = new Error(
`Error while creating session. POST request to ${process.env.SERVER_URL}/compute/contexts/${contextId}/sessions failed with status code ${sessionCreationStatus}. ${sessionCreationError}`
)
await expect(sessionManager.getSession()).rejects.toEqual(expectedError)
}
)
})
describe('clearSession', () => {
it('should clear session', async () => {
jest
.spyOn(requestClient, 'delete')
.mockImplementation(() =>
Promise.resolve({ result: '', etag: '', status: 200 })
)
const sessionToBeCleared = getMockSession()
const sessionToStay = getMockSession()
sessionManager['sessions'] = [sessionToBeCleared, sessionToStay]
await sessionManager.clearSession(sessionToBeCleared.id)
expect(sessionManager['sessions']).toEqual([sessionToStay])
})
it('should throw error if DELETE request failed', async () => {
const sessionCreationStatus = 500
const sessionDeleteError = `The process timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}`
jest.spyOn(requestClient, 'delete').mockImplementation(() =>
Promise.reject({
response: {
status: sessionCreationStatus,
data: {
message: sessionDeleteError
}
}
})
)
const session = getMockSession()
sessionManager['sessions'] = [session]
const expectedError = `Error while deleting session. DELETE request to /compute/sessions/${session.id} failed with status code ${sessionCreationStatus}. ${sessionDeleteError}`
await expect(sessionManager.clearSession(session.id)).rejects.toEqual(
expectedError
)
})
})
describe('waitForCurrentContext', () => {
it('should resolve when current context is ready', async () => {
sessionManager['settingContext'] = true
sessionManager['contextName'] = 'test context'
await expect(sessionManager['waitForCurrentContext']()).toResolve()
expect(sessionManager['settingContext']).toEqual(false)
})
})
describe('setCurrentContext', () => {
it('should set current context', async () => {
const contextName = 'test context'
const testContext: Context = {
name: contextName,
id: 'string',
createdBy: 'string',
version: 1
}
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: {
items: [testContext]
},
etag: '',
status: 200
})
})
sessionManager['currentContext'] = null
sessionManager['contextName'] = contextName
sessionManager['settingContext'] = false
await expect(sessionManager['setCurrentContext']()).toResolve()
expect(sessionManager['currentContext']).toEqual(testContext)
})
it('should throw error if GET request failed', async () => {
const responseStatus = 500
const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}`
const response = {
status: responseStatus,
data: {
message: responseErrorMessage
}
}
jest.spyOn(requestClient, 'get').mockImplementation(() =>
Promise.reject({
response
})
)
const expectedError = `Error while getting list of contexts. GET request to ${process.env.SERVER_URL}/compute/contexts?limit=10000 failed with status code ${responseStatus}. ${responseErrorMessage}`
sessionManager['currentContext'] = null
await expect(sessionManager['setCurrentContext']()).rejects.toEqual(
expectedError
)
})
it('should throw an error if current context is not in the list of contexts', async () => {
const contextName = 'test context'
const testContext: Context = {
name: `${contextName} does not exist`,
id: 'string',
createdBy: 'string',
version: 1
}
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: {
items: [testContext]
},
etag: '',
status: 200
})
})
sessionManager['currentContext'] = null
sessionManager['contextName'] = contextName
sessionManager['settingContext'] = false
const expectedError = new Error(
`The context '${contextName}' was not found on the server ${process.env.SERVER_URL}.`
)
await expect(sessionManager['setCurrentContext']()).rejects.toEqual(
expectedError
)
})
})
describe('createAndWaitForSession', () => {
it('should create session with etag and stateUrl', async () => {
const etag = sessionEtag
const customSession: any = getMockSession()
delete customSession.etag
delete customSession.stateUrl
jest.spyOn(requestClient, 'post').mockImplementation(() =>
Promise.resolve({
result: customSession,
etag
})
)
jest
.spyOn(sessionManager as any, 'setCurrentContext')
.mockImplementation(() => Promise.resolve())
sessionManager['currentContext'] = {
name: 'context name',
id: 'string',
createdBy: 'string',
version: 1
}
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() =>
Promise.resolve({ result: SessionState.Idle, responseStatus: 200 })
)
const expectedSession = await sessionManager['createAndWaitForSession']()
expect(customSession.id).toEqual(expectedSession.id)
expect(
customSession.links.find((l: any) => l.rel === 'state').href
).toEqual(expectedSession.stateUrl)
expect(expectedSession.etag).toEqual(etag)
})
})
}) })

View File

@@ -1,6 +1,9 @@
export interface PollOptions { export interface PollOptions {
maxPollCount: number maxPollCount: number
pollInterval: number pollInterval: number // milliseconds
streamLog: boolean pollStrategy?: PollStrategy
streamLog?: boolean
logFolderPath?: string logFolderPath?: string
} }
export type PollStrategy = PollOptions[]

View File

@@ -0,0 +1,55 @@
import { CsrfToken } from '..'
export interface HttpClient {
get<T>(
url: string,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
delete<T>(
url: string,
accessToken: string | undefined
): Promise<{ result: T; etag: string }>
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
saveLocalStorageToken(accessToken: string, refreshToken: string): void
clearCsrfTokens(): void
clearLocalStorageTokens(): void
getBaseUrl(): string
}
export interface SASjsRequest {
serviceLink: string
timestamp: Date
sourceCode: string
generatedCode: string
logFile: string
SASWORK: any
}
export interface SasjsParsedResponse<T> {
result: T
log: string
etag: string
status: number
printOutput?: string
}
export type VerboseMode = boolean | 'bleached'

View File

@@ -1,5 +1,6 @@
import * as https from 'https' import * as https from 'https'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { VerboseMode } from '../types'
/** /**
* Specifies the configuration for the SASjs instance - eg where and how to * Specifies the configuration for the SASjs instance - eg where and how to
@@ -45,6 +46,10 @@ export class SASjsConfig {
* Set to `true` to enable additional debugging. * Set to `true` to enable additional debugging.
*/ */
debug: boolean = true debug: boolean = true
/**
* Set to `true` to enable verbose mode that will log a summary of every HTTP response.
*/
verbose?: VerboseMode = true
/** /**
* The name of the compute context to use when calling the Viya services directly. * The name of the compute context to use when calling the Viya services directly.
* Example value: 'SAS Job Execution compute context' * Example value: 'SAS Job Execution compute context'

View File

@@ -1,12 +0,0 @@
/**
* Represents a SASjs request, its response and logs.
*
*/
export interface SASjsRequest {
serviceLink: string
timestamp: Date
sourceCode: string
generatedCode: string
logFile: string
SASWORK: any
}

View File

@@ -1,15 +1,34 @@
import { Link } from './Link' import { Link } from './Link'
import { SessionManager } from '../SessionManager'
export enum SessionState {
Completed = 'completed',
Running = 'running',
Pending = 'pending',
Idle = 'idle',
Unavailable = 'unavailable',
NoState = '',
Failed = 'failed',
Error = 'error'
}
export interface Session { export interface Session {
id: string id: string
state: string state: SessionState
stateUrl: string
links: Link[] links: Link[]
attributes: { attributes: {
sessionInactiveTimeout: number sessionInactiveTimeout: number
} }
creationTimeStamp: string creationTimeStamp: string
etag: string
} }
export interface SessionVariable { export interface SessionVariable {
value: string value: string
} }
export interface JobSessionManager {
session: Session
sessionManager: SessionManager
}

View File

@@ -21,8 +21,8 @@ export class ErrorResponse {
} }
} }
interface ErrorBody { export interface ErrorBody {
message: string message: string
details: string details: any
raw: any raw: any
} }

View File

@@ -7,7 +7,7 @@ describe('RootFolderNotFoundError', () => {
const error = new RootFolderNotFoundError( const error = new RootFolderNotFoundError(
'/myProject', '/myProject',
'https://analytium.co.uk', 'https://sas.4gl.io',
token token
) )
@@ -19,7 +19,7 @@ describe('RootFolderNotFoundError', () => {
it('when access token is not provided, error message should not contain scopes', () => { it('when access token is not provided, error message should not contain scopes', () => {
const error = new RootFolderNotFoundError( const error = new RootFolderNotFoundError(
'/myProject', '/myProject',
'https://analytium.co.uk' 'https://sas.4gl.io'
) )
expect(error).toBeInstanceOf(RootFolderNotFoundError) expect(error).toBeInstanceOf(RootFolderNotFoundError)
@@ -30,7 +30,7 @@ describe('RootFolderNotFoundError', () => {
it('should include the folder path and SASDrive URL in the message', () => { it('should include the folder path and SASDrive URL in the message', () => {
const folderPath = '/myProject' const folderPath = '/myProject'
const serverUrl = 'https://analytium.co.uk' const serverUrl = 'https://sas.4gl.io'
const error = new RootFolderNotFoundError(folderPath, serverUrl) const error = new RootFolderNotFoundError(folderPath, serverUrl)
expect(error).toBeInstanceOf(RootFolderNotFoundError) expect(error).toBeInstanceOf(RootFolderNotFoundError)

View File

@@ -6,10 +6,12 @@ export * from './Job'
export * from './JobDefinition' export * from './JobDefinition'
export * from './JobResult' export * from './JobResult'
export * from './Link' export * from './Link'
export * from './Login'
export * from './SASjsConfig' export * from './SASjsConfig'
export * from './SASjsRequest' export * from './RequestClient'
export * from './Session' export * from './Session'
export * from './UploadFile' export * from './UploadFile'
export * from './PollOptions' export * from './PollOptions'
export * from './WriteStream' export * from './WriteStream'
export * from './ExecuteScript' export * from './ExecuteScript'
export * from './errors'

View File

@@ -1,4 +1,4 @@
import { SASjsRequest } from '../types/SASjsRequest' import { SASjsRequest } from '../types'
/** /**
* Comparator for SASjs request timestamps. * Comparator for SASjs request timestamps.

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