1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-07 20:40:05 +00:00

Compare commits

...

52 Commits

Author SHA1 Message Date
Allan Bowe
e9422898b6 Merge pull request #751 from sasjs/quick-fix
fix: should not assign error to parsedSasjsServeLog
2022-08-28 22:42:07 +01:00
2b7281e70c fix: should not assign error to parsedSasjsServeLog 2022-08-29 02:34:13 +05:00
Allan Bowe
c1e3a9f5bb Merge pull request #745 from sasjs/matrix
chore: moving github docs to .github folder and migrating discord hook to matrix
2022-08-25 12:44:12 +01:00
Sabir Hassan
d249295b49 Merge pull request #749 from sasjs/update-sasjs-request-client
fix: update parse response method of sasjsRequestClient
2022-08-25 00:19:33 +05:00
010fd063df fix: update parse response method of sasjsRequestClient 2022-08-25 00:06:10 +05:00
Sabir Hassan
c6bbf1ff34 Merge pull request #746 from sasjs/handle-updated-sasjs-response
fix: handled updated sasjs response
2022-08-24 21:03:17 +05:00
Allan Bowe
f1df27fdf1 Merge pull request #748 from sasjs/issue-741
fix: error when multiple redirections happen while debug is on
2022-08-24 14:44:45 +01:00
eb739a83a4 chore: fix 2022-08-24 15:16:47 +02:00
d8b686dd7e fix: error when multiple redirections happen while debug is on 2022-08-24 15:12:13 +02:00
3d8eb762d0 chore: quick fix 2022-08-23 15:56:13 +05:00
c551cd0311 fix: created sasjsJobExecutor class and overrided parseResponse for sasjsRequestClient 2022-08-23 15:47:50 +05:00
4a319f1aef fix: handled updated sasjs response 2022-08-19 16:10:05 +05:00
Allan Bowe
a0b8316d7c Update README.md 2022-08-12 15:48:38 +01:00
munja
3b53d5b3ae chore: fix body 2022-08-07 14:52:42 +01:00
munja
dfebab5abc chore: fix body 2022-08-07 14:52:08 +01:00
munja
8d5ee0d6e1 chore: json payload 2022-08-07 14:16:36 +01:00
munja
1ed7a11fc3 chore: testing json payload 2022-08-07 14:03:59 +01:00
munja
abe95f5432 chore: moving github docs to .github folder and migrating discord hook to matrix 2022-08-07 13:47:50 +01:00
Allan Bowe
92be5a2dca Merge pull request #744 from sasjs/sasjs-server-csrf-cookie
fix(server): csrf cookie is created explicitly
2022-08-04 02:03:06 +01:00
Saad Jutt
f58f2eba97 chore: error needs to be more specific 2022-08-04 05:59:39 +05:00
Saad Jutt
e37bb182c3 fix(server): csrf cookie is created explicitly 2022-08-04 05:04:43 +05:00
Allan Bowe
504777603c Merge pull request #743 from sasjs/issue-722
fix: improve input validations
2022-07-28 13:03:03 +01:00
706cbe5513 chore: add unit tests for validateInput 2022-07-28 14:23:00 +05:00
88eadd27aa chore: moved utils specs to spec folder 2022-07-28 14:22:16 +05:00
4ed9f87434 fix: moved validateInput method to separate file and added some additional validation 2022-07-28 14:20:41 +05:00
Allan Bowe
f0f80a1c1f Merge pull request #742 from sasjs/issue-721
fix: add additional check for string type before converting data to lower case
2022-07-27 20:42:48 +01:00
d0d8d58945 fix: add additional check for string type before converting data to lower case 2022-07-27 18:30:47 +05:00
Yury Shkoda
657721d7a3 Merge pull request #736 from sasjs/issue-735
fix(refresh-token): improved error message
2022-07-18 16:56:58 +03:00
Yury Shkoda
a39faa0f4b fix(refresh-token): improved error message 2022-07-18 14:35:33 +03:00
Allan Bowe
7b8fb774cc Update dependabot.yml 2022-07-13 22:25:50 +01:00
Allan Bowe
982c4c329c Merge pull request #734 from sasjs/issue-733
fix: special missing double dot issue
2022-07-07 19:23:02 +01:00
8617e2dc57 fix: special missing double dot issue 2022-07-07 17:58:27 +02:00
Allan Bowe
d3d62f6888 Merge pull request #731 from sasjs/issue-730
fix: do not throw job execution error if response contains >>weboutBEGIN<<
2022-06-29 16:33:03 +02:00
Allan Bowe
bf35e52962 Merge pull request #713 from sasjs/critical-deps-issues
Fixed critical dependencies issues
2022-06-29 14:24:29 +02:00
22eca50e3f fix: do not throw job execution error if response contains >>weboutBEGIN<< 2022-06-29 16:59:03 +05:00
Yury Shkoda
eb83101dbf fix(sasjs-test): addede appLoc to useEffect deps 2022-06-29 08:46:52 +03:00
Yury Shkoda
56d84e1940 fix(sasjs-tests): used appLoc from config 2022-06-29 08:37:59 +03:00
Yury Shkoda
283800dfa6 fix(special-missings): fixed formats table sent as part of sasjs_tables 2022-06-28 10:17:22 +03:00
Yury Shkoda
c073d72dd4 chore(deps): regenerated package-locks 2022-06-24 16:16:36 +03:00
Yury Shkoda
f5d40eaaf7 chore: Merge branch 'deps-fix' into critical-deps-issues 2022-06-24 16:13:43 +03:00
Allan Bowe
79ba044dea Update README.md 2022-06-24 11:49:48 +01:00
Allan Bowe
9329dc848a Update README.md 2022-06-24 11:46:09 +01:00
Allan Bowe
98c492e85e Merge pull request #729 from sasjs/update-AuthManager
fix: update logout url
2022-06-21 22:10:26 +02:00
d1fcc2ca0a fix: update logout url 2022-06-22 00:53:49 +05:00
Yury Shkoda
122f302bae Merge pull request #728 from sasjs/deps-fix
fix(workflow): added actions/setup-node@v2
2022-06-20 20:44:57 +03:00
Yury Shkoda
a28b48f815 Merge pull request #726 from sasjs/deps-fix
fix(workflows): fixed npmpublish workflow
2022-06-20 20:36:13 +03:00
Allan Bowe
db60962c1e Merge pull request #725 from sasjs/allanbowe-patch-1
fix: bumping with README updates
2022-06-20 19:29:15 +02:00
Allan Bowe
1eae59ad3b fix: bumping with README updates 2022-06-20 18:28:47 +01:00
Allan Bowe
d485023d65 Update dependabot.yml 2022-06-20 18:09:07 +01:00
Allan Bowe
c2f21babb4 Merge pull request #723 from sasjs/deps-fix
Regenerated package-lock and fixed linting issues
2022-06-20 19:03:17 +02:00
Yury Shkoda
f602d5baf0 chore(deps): added prettier 2022-06-01 10:08:50 +03:00
Yury Shkoda
4744dbf196 fix(deps): fixed critical vulnerabilities 2022-06-01 09:53:22 +03:00
39 changed files with 1513 additions and 1168 deletions

View File

@@ -1,7 +1,7 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: npm - package-ecosystem: "npm"
directory: '/' directory: "/"
schedule: schedule:
interval: monthly interval: "monthly"
open-pull-requests-limit: 2 open-pull-requests-limit: 1

View File

@@ -24,16 +24,16 @@ jobs:
- name: Check npm audit - name: Check npm audit
run: npm audit --production --audit-level=low run: npm audit --production --audit-level=low
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Check code style - name: Check code style
run: npm run lint run: npm run lint
- name: Run unit tests - name: Run unit tests
run: npm test run: npm test
- name: Build Package - name: Build Package
run: npm run package:lib run: npm run package:lib
env: env:
@@ -41,7 +41,7 @@ jobs:
- name: Install SSH Key - name: Install SSH Key
uses: shimataro/ssh-key-action@v2 uses: shimataro/ssh-key-action@v2
with: with:
key: ${{ secrets.DCGITLAB_KEY }} key: ${{ secrets.DCGITLAB_KEY }}
known_hosts: 'placeholder' known_hosts: 'placeholder'
@@ -88,7 +88,7 @@ jobs:
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json replace-in-files --regex='"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-cypress-run.sh ${{ secrets.DISCORD_WEBHOOK }} 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}}
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on # For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
- name: Generate coverage report - name: Generate coverage report

View File

@@ -2,7 +2,6 @@
[![npm package][npm-image]][npm-url] [![npm package][npm-image]][npm-url]
[![Github Workflow][githubworkflow-image]][githubworkflow-url] [![Github Workflow][githubworkflow-image]][githubworkflow-url]
[![Dependency Status][dependency-image]][dependency-url]
[![npm](https://img.shields.io/npm/dt/@sasjs/adapter)]() [![npm](https://img.shields.io/npm/dt/@sasjs/adapter)]()
![Snyk Vulnerabilities for npm package](https://img.shields.io/snyk/vulnerabilities/npm/@sasjs/adapter) ![Snyk Vulnerabilities for npm package](https://img.shields.io/snyk/vulnerabilities/npm/@sasjs/adapter)
[![License](https://img.shields.io/apm/l/atomic-design-ui.svg)](/LICENSE) [![License](https://img.shields.io/apm/l/atomic-design-ui.svg)](/LICENSE)
@@ -16,7 +15,6 @@
[githubworkflow-image]:https://github.com/sasjs/adapter/actions/workflows/build.yml/badge.svg [githubworkflow-image]:https://github.com/sasjs/adapter/actions/workflows/build.yml/badge.svg
[githubworkflow-url]:https://github.com/sasjs/adapter/blob/main/.github/workflows/build.yml [githubworkflow-url]:https://github.com/sasjs/adapter/blob/main/.github/workflows/build.yml
[dependency-image]:https://david-dm.org/sasjs/adapter.svg [dependency-image]:https://david-dm.org/sasjs/adapter.svg
[dependency-url]:https://github.com/sasjs/adapter/blob/main/package.json
SASjs is a open-source framework for building Web Apps on SAS® platforms. You can use as much or as little of it as you like. This repository contains the JS adapter, the part that handles the to/from SAS communication on the client side. There are 3 ways to install it: SASjs is a open-source framework for building Web Apps on SAS® platforms. You can use as much or as little of it as you like. This repository contains the JS adapter, the part that handles the to/from SAS communication on the client side. There are 3 ways to install it:
@@ -32,7 +30,7 @@ For more information on building web apps with SAS, check out [sasjs.io](https:/
## None of this makes sense. How do I build an app with it? ## None of this makes sense. How do I build an app with it?
Ok ok. Deploy this [example.html](https://raw.githubusercontent.com/sasjs/adapter/master/example.html) file to your web server, and update `servertype` to `SAS9` or `SASVIYA` depending on your backend. Ok ok. Deploy this [example.html](https://raw.githubusercontent.com/sasjs/adapter/master/example.html) file to your web server, and update `servertype` to `SAS9`, `SASVIYA`, or `SASJS` depending on your backend.
The backend part can be deployed as follows: The backend part can be deployed as follows:
@@ -52,7 +50,7 @@ parmcards4;
%webout(OBJ,areas) %webout(OBJ,areas)
%webout(CLOSE) %webout(CLOSE)
;;;; ;;;;
%mp_createwebservice(path=&appLoc/common,name=getdata) %mx_createwebservice(path=&appLoc/common,name=getdata)
``` ```
You now have a simple web app with a backend service! You now have a simple web app with a backend service!
@@ -96,10 +94,10 @@ const sasJs = new SASjs({your config})
More on the config later. More on the config later.
### SAS Logon ### SAS Logon
All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `LoginMechanism` attribute of the sasJs config object (above): All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `loginMechanism` attribute of the sasJs config object (above):
* `LoginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS. * `loginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS.
* `LoginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing. * `loginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing.
Sample code for logging in with the `Default` approach: Sample code for logging in with the `Default` approach:
@@ -127,7 +125,11 @@ sasJs.request("/path/to/my/service", dataObject)
}) })
``` ```
We supply the path to the SAS service, and a data object. The data object can be null (for services with no input), or can contain one or more tables in the following format: We supply the path to the SAS service, and a data object.
If the path starts with a `/` then it should be a full path to the service. If there is no leading `/` then it is relative to the `appLoc`.
The data object can be null (for services with no input), or can contain one or more "tables" in the following format:
```javascript ```javascript
let dataObject={ let dataObject={
@@ -143,7 +145,9 @@ let dataObject={
}; };
``` ```
There are optional parameters such as a config object and a callback login function. These tables (`tablewith2cols1row` and `tablewith1col2rows`) will be created in SAS WORK after running `%webout(FETCH)` in your SAS service.
The `request()` method also has optional parameters such as a config object and a callback login function.
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.
@@ -221,7 +225,7 @@ The SAS side is handled by a number of macros in the [macro core](https://github
The following snippet shows the process of SAS tables arriving / leaving: The following snippet shows the process of SAS tables arriving / leaving:
```sas ```sas
/* fetch all input tables sent from frontend - they arrive as work tables */ /* convert frontend input tables from into SASWORK datasets */
%webout(FETCH) %webout(FETCH)
/* some sas code */ /* some sas code */
@@ -233,7 +237,8 @@ run;
%webout(OBJ,a) /* Rows in table `a` are objects (easy to use) */ %webout(OBJ,a) /* Rows in table `a` are objects (easy to use) */
%webout(ARR,b) /* Rows in table `b` are arrays (compact) */ %webout(ARR,b) /* Rows in table `b` are arrays (compact) */
%webout(OBJ,c,fmt=N) /* Table `c` is sent unformatted (raw) */ %webout(OBJ,c,fmt=N) /* Table `c` is sent unformatted (raw) */
%webout(OBJ,c,label=d) /* Rename as `d` on JS side */ %webout(OBJ,c,label=d) /* Rename table as `d` in output JSON */
%webout(OBJ,c,label=e, maxobs=10) /* send only 10 rows back */
%webout(CLOSE) /* Close the JSON and add default variables */ %webout(CLOSE) /* Close the JSON and add default variables */
``` ```
@@ -250,6 +255,8 @@ Where an entire column is made up of special missing numerics, there would be no
%webout(OBJ,a,missing=STRING,showmeta=YES) %webout(OBJ,a,missing=STRING,showmeta=YES)
``` ```
The `%webout()` macro itself is just a wrapper for the [mp_jsonout](https://core.sasjs.io/mp__jsonout_8sas.html) macro.
## Configuration ## Configuration
Configuration on the client side involves passing an object on startup, which can also be passed with each request. Technical documentation on the SASjsConfig class is available [here](https://adapter.sasjs.io/classes/types.sasjsconfig.html). The main config items are: Configuration on the client side involves passing an object on startup, which can also be passed with each request. Technical documentation on the SASjsConfig class is available [here](https://adapter.sasjs.io/classes/types.sasjsconfig.html). The main config items are:
@@ -258,7 +265,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.
* `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`.
* `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10. * `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.
@@ -314,7 +321,7 @@ For more information and examples specific to this adapter you can check out the
For more information on building web apps in general, check out these [resources](https://sasjs.io/training/resources/) or contact the [author](https://www.linkedin.com/in/allanbowe/) directly. For more information on building web apps in general, check out these [resources](https://sasjs.io/training/resources/) or contact the [author](https://www.linkedin.com/in/allanbowe/) directly.
If you are a SAS 9 or SAS Viya customer you can also request a copy of [Data Controller](https://datacontroller.io) - free for up to 5 users, this tool makes use of all parts of the SASjs framework. As a SAS customer you can also request a copy of [Data Controller](https://datacontroller.io) - free for up to 5 users, this tool makes use of all parts of the SASjs framework.
## Star Gazing ## Star Gazing

12
package-lock.json generated
View File

@@ -12973,7 +12973,7 @@
}, },
"node_modules/npm/node_modules/rimraf/node_modules/brace-expansion": { "node_modules/npm/node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"dev": true, "extraneous": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -12983,7 +12983,7 @@
}, },
"node_modules/npm/node_modules/rimraf/node_modules/glob": { "node_modules/npm/node_modules/rimraf/node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"dev": true, "extraneous": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -13003,7 +13003,7 @@
}, },
"node_modules/npm/node_modules/rimraf/node_modules/minimatch": { "node_modules/npm/node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"dev": true, "extraneous": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -26856,7 +26856,7 @@
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "extraneous": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -26865,7 +26865,7 @@
"glob": { "glob": {
"version": "7.2.3", "version": "7.2.3",
"bundled": true, "bundled": true,
"dev": true, "extraneous": true,
"requires": { "requires": {
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
@@ -26878,7 +26878,7 @@
"minimatch": { "minimatch": {
"version": "3.1.2", "version": "3.1.2",
"bundled": true, "bundled": true,
"dev": true, "extraneous": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }

View File

@@ -1,10 +0,0 @@
#!/bin/bash
if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then
echo "Cypress sasjs testing passed!"
else
curl -X POST --header "Content-Type:application/json" --data '{"username":"GitHub CI - Adapter SASJS-TESTS (FAIL)", "content":"Automated sasjs-tests failed on the @sasjs/adapter PR on following link.\n'$2'", "avatar_url":"https://i.ibb.co/Lpk7Xvq/error-outline.png"}' $1
echo "Cypress sasjs testing failed!"
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
#!/bin/bash
if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then
echo "Cypress sasjs testing passed!"
else
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21BDUPBPEGVvRLKLQUxY:4gl.io/send/m.room.message?access_token=$1
echo "Cypress sasjs testing failed!"
exit 1
fi

View File

@@ -11,12 +11,13 @@ import { fileUploadTests } from './testSuites/FileUpload'
const App = (): ReactElement<{}> => { const App = (): ReactElement<{}> => {
const { adapter, config } = useContext(AppContext) const { adapter, config } = useContext(AppContext)
const [testSuites, setTestSuites] = useState<TestSuite[]>([]) const [testSuites, setTestSuites] = useState<TestSuite[]>([])
const appLoc = config.sasJsConfig.appLoc
useEffect(() => { useEffect(() => {
if (adapter) { if (adapter) {
const testSuites = [ const testSuites = [
basicTests(adapter, config.userName, config.password), basicTests(adapter, config.userName, config.password),
sendArrTests(adapter), sendArrTests(adapter, appLoc),
sendObjTests(adapter), sendObjTests(adapter),
specialCaseTests(adapter), specialCaseTests(adapter),
sasjsRequestTests(adapter), sasjsRequestTests(adapter),
@@ -24,12 +25,12 @@ const App = (): ReactElement<{}> => {
] ]
if (adapter.getSasjsConfig().serverType === 'SASVIYA') { if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
testSuites.push(computeTests(adapter)) testSuites.push(computeTests(adapter, appLoc))
} }
setTestSuites(testSuites) setTestSuites(testSuites)
} }
}, [adapter, config]) }, [adapter, config, appLoc])
return ( return (
<div className="app"> <div className="app">

View File

@@ -3,7 +3,7 @@ import { TestSuite } from '@sasjs/test-framework'
const stringData: any = { table1: [{ col1: 'first col value' }] } const stringData: any = { table1: [{ col1: 'first col value' }] }
export const computeTests = (adapter: SASjs): TestSuite => ({ export const computeTests = (adapter: SASjs, appLoc: string): TestSuite => ({
name: 'Compute', name: 'Compute',
tests: [ tests: [
{ {
@@ -35,7 +35,7 @@ export const computeTests = (adapter: SASjs): TestSuite => ({
description: 'Should start a compute job and return the session', description: 'Should start a compute job and return the session',
test: () => { test: () => {
const data: any = { table1: [{ col1: 'first col value' }] } const data: any = { table1: [{ col1: 'first col value' }] }
return adapter.startComputeJob('/Public/app/common/sendArr', data) return adapter.startComputeJob(`${appLoc}/common/sendArr`, data)
}, },
assertion: (res: any) => { assertion: (res: any) => {
const expectedProperties = ['id', 'applicationName', 'attributes'] const expectedProperties = ['id', 'applicationName', 'attributes']

View File

@@ -45,14 +45,14 @@ const getLargeObjectData = () => {
return data return data
} }
export const sendArrTests = (adapter: SASjs): TestSuite => ({ export const sendArrTests = (adapter: SASjs, appLoc: string): TestSuite => ({
name: 'sendArr', name: 'sendArr',
tests: [ tests: [
{ {
title: 'Absolute paths', title: 'Absolute paths',
description: 'Should work with absolute paths to SAS jobs', description: 'Should work with absolute paths to SAS jobs',
test: () => { test: () => {
return adapter.request('/Public/app/common/sendArr', stringData) return adapter.request(`${appLoc}/common/sendArr`, stringData)
}, },
assertion: (res: any) => { assertion: (res: any) => {
return res.table1[0][0] === stringData.table1[0].col1 return res.table1[0][0] === stringData.table1[0].col1

View File

@@ -1,4 +1,4 @@
import { compareTimestamps, asyncForEach } from './utils' import { compareTimestamps, asyncForEach, validateInput } from './utils'
import { import {
SASjsConfig, SASjsConfig,
UploadFile, UploadFile,
@@ -24,6 +24,7 @@ import { SasjsRequestClient } from './request/SasjsRequestClient'
import { import {
JobExecutor, JobExecutor,
WebJobExecutor, WebJobExecutor,
SasjsJobExecutor,
ComputeJobExecutor, ComputeJobExecutor,
JesJobExecutor, JesJobExecutor,
Sas9JobExecutor, Sas9JobExecutor,
@@ -59,6 +60,7 @@ export default class SASjs {
private authManager: AuthManager | null = null private authManager: AuthManager | null = null
private requestClient: RequestClient | null = null private requestClient: RequestClient | null = null
private webJobExecutor: JobExecutor | null = null private webJobExecutor: JobExecutor | null = null
private sasjsJobExecutor: JobExecutor | null = null
private computeJobExecutor: JobExecutor | null = null private computeJobExecutor: JobExecutor | null = null
private jesJobExecutor: JobExecutor | null = null private jesJobExecutor: JobExecutor | null = null
private sas9JobExecutor: JobExecutor | null = null private sas9JobExecutor: JobExecutor | null = null
@@ -102,10 +104,14 @@ export default class SASjs {
* @param code - a string of code from the file to run. * @param code - a string of code from the file to run.
* @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute scripts. * @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute scripts.
*/ */
public async executeScriptSASjs(code: string, authConfig?: AuthConfig) { public async executeScriptSASjs(
code: string,
runTime?: string,
authConfig?: AuthConfig
) {
this.isMethodSupported('executeScriptSASJS', [ServerType.Sasjs]) this.isMethodSupported('executeScriptSASJS', [ServerType.Sasjs])
return await this.sasJSApiClient?.executeScript(code, authConfig) return await this.sasJSApiClient?.executeScript(code, runTime, authConfig)
} }
/** /**
@@ -686,12 +692,12 @@ export default class SASjs {
...config ...config
} }
const validationResult = this.validateInput(data) const validationResult = validateInput(data)
// status is true if the data passes validation checks above // status is true if the data passes validation checks above
if (validationResult.status) { if (validationResult.status) {
if (config.serverType === ServerType.Sasjs) { if (config.serverType === ServerType.Sasjs) {
return await this.webJobExecutor!.execute( return await this.sasjsJobExecutor!.execute(
sasJob, sasJob,
data, data,
config, config,
@@ -748,74 +754,6 @@ export default class SASjs {
} }
} }
/**
* This function validates the input data structure and table naming convention
*
* @param data A json object that contains one or more tables, it can also be null
* @returns An object which contains two attributes: 1) status: boolean, 2) msg: string
*/
private validateInput(data: { [key: string]: any } | null): {
status: boolean
msg: string
} {
if (data === null) return { status: true, msg: '' }
const isSasFormatsTable = (key: string) =>
key.match(/^\$.*/) && Object.keys(data).includes(key.replace(/^\$/, ''))
for (const key in data) {
if (!key.match(/^[a-zA-Z_]/) && !isSasFormatsTable(key)) {
return {
status: false,
msg: 'First letter of table should be alphabet or underscore.'
}
}
if (!key.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/) && !isSasFormatsTable(key)) {
return { status: false, msg: 'Table name should be alphanumeric.' }
}
if (key.length > 32) {
return {
status: false,
msg: 'Maximum length for table name could be 32 characters.'
}
}
if (this.getType(data[key]) !== 'Array' && !isSasFormatsTable(key)) {
return {
status: false,
msg: 'Parameter data contains invalid table structure.'
}
}
for (let i = 0; i < data[key].length; i++) {
if (this.getType(data[key][i]) !== 'object') {
return {
status: false,
msg: `Table ${key} contains invalid structure.`
}
}
}
}
return { status: true, msg: '' }
}
/**
* this function returns the type of variable
*
* @param data it could be anything, like string, array, object etc.
* @returns a string which tells the type of input parameter
*/
private getType(data: any): string {
if (Array.isArray(data)) {
return 'Array'
} else {
return typeof data
}
}
/** /**
* 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 serviceJson - the JSON specifying the folders and services to be created. * @param serviceJson - the JSON specifying the folders and services to be created.
@@ -1117,6 +1055,12 @@ export default class SASjs {
this.sasViyaApiClient! this.sasViyaApiClient!
) )
this.sasjsJobExecutor = new SasjsJobExecutor(
this.sasjsConfig.serverUrl,
this.jobsPath,
this.requestClient
)
this.sas9JobExecutor = new Sas9JobExecutor( this.sas9JobExecutor = new Sas9JobExecutor(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!, this.sasjsConfig.serverType!,

View File

@@ -3,7 +3,7 @@ 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 { parseWeboutResponse } from './utils' import { parseWeboutResponse, SASJS_LOGS_SEPARATOR } from './utils'
import { getTokens } from './auth/getTokens' import { getTokens } from './auth/getTokens'
export class SASjsApiClient { export class SASjsApiClient {
@@ -64,9 +64,14 @@ export class SASjsApiClient {
/** /**
* Executes code on a SASJS server. * Executes code on a SASJS server.
* @param code - a string of code to execute. * @param code - a string of code to execute.
* @param runTime - a string to representing runTime for code execution
* @param authConfig - an object for authentication. * @param authConfig - an object for authentication.
*/ */
public async executeScript(code: string, authConfig?: AuthConfig) { public async executeScript(
code: string,
runTime: string = 'sas',
authConfig?: AuthConfig
) {
let access_token = (authConfig || {}).access_token let access_token = (authConfig || {}).access_token
if (authConfig) { if (authConfig) {
;({ access_token } = await getTokens( ;({ access_token } = await getTokens(
@@ -79,16 +84,9 @@ export class SASjsApiClient {
let parsedSasjsServerLog = '' let parsedSasjsServerLog = ''
await this.requestClient await this.requestClient
.post('SASjsApi/code/execute', { code }, access_token) .post('SASjsApi/code/execute', { code, runTime }, access_token)
.then((res: any) => { .then((res: any) => {
if (res.result?.log) { if (res.log) parsedSasjsServerLog = res.log
parsedSasjsServerLog = res.result.log
.map((logLine: any) => logLine.line)
.join('\n')
}
})
.catch((err) => {
parsedSasjsServerLog = err
}) })
return parsedSasjsServerLog return parsedSasjsServerLog

View File

@@ -23,7 +23,7 @@ export class AuthManager {
? '/SASLogon/logout?' ? '/SASLogon/logout?'
: this.serverType === ServerType.SasViya : this.serverType === ServerType.SasViya
? '/SASLogon/logout.do?' ? '/SASLogon/logout.do?'
: '/SASjsApi/auth/logout' : '/SASLogon/logout'
} }
/** /**
@@ -223,9 +223,17 @@ export class AuthManager {
private async getNewLoginForm() { private async getNewLoginForm() {
if (this.serverType === ServerType.Sasjs) { if (this.serverType === ServerType.Sasjs) {
// server will be sending CSRF cookie, // server will be sending CSRF token in response,
// need to save in cookie so that,
// http client will use it automatically // http client will use it automatically
return this.requestClient.get('/', undefined) return this.requestClient.get('/', undefined).then(({ result }) => {
const cookie =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
result as string
)?.[1]
if (cookie) document.cookie = cookie
})
} }
const { result: formResponse } = await this.requestClient.get<string>( const { result: formResponse } = await this.requestClient.get<string>(
@@ -334,19 +342,9 @@ export class AuthManager {
/** /**
* Logs out of the configured SAS server. * Logs out of the configured SAS server.
* @param accessToken - an optional access token is required for SASjs server type. *
*/ */
public async logOut() { public async logOut() {
if (this.serverType === ServerType.Sasjs) {
return this.requestClient
.delete(this.logoutUrl)
.catch(() => true)
.finally(() => {
this.requestClient.clearLocalStorageTokens()
return true
})
}
this.requestClient.clearCsrfTokens() this.requestClient.clearCsrfTokens()
return this.requestClient.get(this.logoutUrl, undefined).then(() => true) return this.requestClient.get(this.logoutUrl, undefined).then(() => true)

View File

@@ -28,7 +28,7 @@ export async function refreshTokensForSasjs(
} }
}) })
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while refreshing tokens') throw prefixMessage(err, 'Error while refreshing tokens: ')
}) })
return authResponse return authResponse

View File

@@ -42,7 +42,7 @@ 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, 'Error while refreshing tokens: ')
}) })
return authResponse return authResponse

View File

@@ -27,17 +27,20 @@ describe('refreshTokensForSasjs', () => {
it('should handle errors while refreshing tokens', async () => { it('should handle errors while refreshing tokens', async () => {
setupMocks() setupMocks()
const refresh_token = generateToken(30) const refresh_token = generateToken(30)
const tokenError = 'unable to verify the first certificate'
jest jest
.spyOn(requestClient, 'post') .spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Token Error')) .mockImplementation(() => Promise.reject(tokenError))
const error = await refreshTokensForSasjs( const error = await refreshTokensForSasjs(
requestClient, requestClient,
refresh_token refresh_token
).catch((e: any) => e) ).catch((e: any) => e)
expect(error).toContain('Error while refreshing tokens') expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
}) })
}) })

View File

@@ -46,17 +46,20 @@ describe('refreshTokensForViya', () => {
it('should handle errors while refreshing tokens', async () => { it('should handle errors while refreshing tokens', async () => {
setupMocks() setupMocks()
const access_token = generateToken(30) const access_token = generateToken(30)
const refresh_token = generateToken(30) const refresh_token = generateToken(30)
const tokenError = 'unable to verify the first certificate'
const authConfig: AuthConfig = { const authConfig: AuthConfig = {
access_token, access_token,
refresh_token, refresh_token,
client: 'cl13nt', client: 'cl13nt',
secret: 's3cr3t' secret: 's3cr3t'
} }
jest jest
.spyOn(requestClient, 'post') .spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Token Error')) .mockImplementation(() => Promise.reject(tokenError))
const error = await refreshTokensForViya( const error = await refreshTokensForViya(
requestClient, requestClient,
@@ -65,7 +68,7 @@ describe('refreshTokensForViya', () => {
authConfig.refresh_token authConfig.refresh_token
).catch((e: any) => e) ).catch((e: any) => e)
expect(error).toContain('Error while refreshing tokens') expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
}) })
}) })

View File

@@ -1,5 +1,5 @@
import * as NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { convertToCSV } from '../utils/convertToCsv' import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
import { splitChunks } from '../utils/splitChunks' import { splitChunks } from '../utils/splitChunks'
export const generateTableUploadForm = ( export const generateTableUploadForm = (
@@ -13,7 +13,8 @@ export const generateTableUploadForm = (
for (const tableName in data) { for (const tableName in data) {
tableCounter++ tableCounter++
sasjsTables.push(tableName) // Formats table should not be sent as part of 'sasjs_tables'
if (!isFormatsTable(tableName)) sasjsTables.push(tableName)
const csv = convertToCSV(data, tableName) const csv = convertToCSV(data, tableName)

View File

@@ -1,7 +1,8 @@
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 {
@@ -99,21 +100,8 @@ export class FileUploader extends BaseJobExecutor {
? parseWeboutResponse(res.result, uploadUrl) ? parseWeboutResponse(res.result, uploadUrl)
: res.result : res.result
break break
case ServerType.Sasjs:
if (typeof res.result._webout === 'object') {
jsonResponse = res.result._webout
} else {
const webout = parseWeboutResponse(
res.result._webout,
uploadUrl
)
jsonResponse = getValidJson(webout)
}
break
} }
} else if (this.serverType === ServerType.Sasjs) { } else if (this.serverType !== ServerType.Sasjs) {
jsonResponse = getValidJson(res.result._webout)
} else {
jsonResponse = jsonResponse =
typeof res.result === 'string' typeof res.result === 'string'
? getValidJson(res.result) ? getValidJson(res.result)

View File

@@ -1,6 +1,6 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types' import { AuthConfig, ServerType } from '@sasjs/utils/types'
import { ExtraResponseAttributes } from '@sasjs/utils/types' import { ExtraResponseAttributes } from '@sasjs/utils/types'
import { asyncForEach } from '../utils' import { asyncForEach, isRelativePath } from '../utils'
export type ExecuteFunction = () => Promise<any> export type ExecuteFunction = () => Promise<any>
@@ -45,4 +45,17 @@ export abstract class BaseJobExecutor implements JobExecutor {
protected appendWaitingRequest(request: ExecuteFunction) { protected appendWaitingRequest(request: ExecuteFunction) {
this.waitingRequests.push(request) this.waitingRequests.push(request)
} }
protected getRequestParams(config: any): any {
const requestParams: any = {}
if (config.debug) {
requestParams['_omittextlog'] = 'false'
requestParams['_omitsessionresults'] = 'false'
requestParams['_debug'] = 131
}
return requestParams
}
} }

View File

@@ -102,7 +102,7 @@ export class Sas9JobExecutor extends BaseJobExecutor {
return requestPromise return requestPromise
} }
private getRequestParams(config: any): any { protected getRequestParams(config: any): any {
const requestParams: any = {} const requestParams: any = {}
if (config.debug) { if (config.debug) {

View File

@@ -0,0 +1,141 @@
import * as NodeFormData from 'form-data'
import {
AuthConfig,
ExtraResponseAttributes,
ServerType
} from '@sasjs/utils/types'
import {
ErrorResponse,
JobExecutionError,
LoginRequiredError
} from '../types/errors'
import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { RequestClient } from '../request/RequestClient'
import { isRelativePath, appendExtraResponseAttributes } from '../utils'
import { BaseJobExecutor } from './JobExecutor'
export class SasjsJobExecutor extends BaseJobExecutor {
constructor(
serverUrl: string,
private jobsPath: string,
private requestClient: RequestClient
) {
super(serverUrl, ServerType.Sasjs)
}
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
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
let requestParams = {
...this.getRequestParams(config)
}
/**
* Use the available form data object (FormData in Browser, NodeFormData in
* Node)
*/
let formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) {
// file upload approach
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])
}
}
/* The NodeFormData object does not set the request header - so, set it */
const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${formData.getBoundary()}`
: undefined
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(
apiUrl,
formData,
authConfig?.access_token,
contentType
)
.then(async (res: any) => {
if (Object.entries(res.result).length < 1) {
throw new JobExecutionError(
0,
`No webout was returned by job ${program}. Please check the SAS log for more info.`,
res.log
)
}
this.requestClient!.appendRequest(res, sasJob, config.debug)
const responseObject = appendExtraResponseAttributes(
res,
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
}
}

View File

@@ -16,12 +16,10 @@ import { SASViyaApiClient } from '../SASViyaApiClient'
import { import {
isRelativePath, isRelativePath,
parseSasViyaDebugResponse, parseSasViyaDebugResponse,
appendExtraResponseAttributes, appendExtraResponseAttributes
getValidJson
} from '../utils' } from '../utils'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse' import { parseWeboutResponse } from '../utils/parseWeboutResponse'
import { Server } from 'https'
export interface WaitingRequstPromise { export interface WaitingRequstPromise {
promise: Promise<any> | null promise: Promise<any> | null
@@ -121,7 +119,6 @@ export class WebJobExecutor extends BaseJobExecutor {
const stringifiedData = JSON.stringify(data) const stringifiedData = JSON.stringify(data)
if ( if (
config.serverType === ServerType.Sas9 || config.serverType === ServerType.Sas9 ||
config.serverType === ServerType.Sasjs ||
stringifiedData.length > 500000 || stringifiedData.length > 500000 ||
stringifiedData.includes(';') stringifiedData.includes(';')
) { ) {
@@ -164,31 +161,7 @@ export class WebJobExecutor extends BaseJobExecutor {
contentType contentType
) )
.then(async (res: any) => { .then(async (res: any) => {
const parsedSasjsServerLog = this.requestClient!.appendRequest(res, sasJob, config.debug)
this.serverType === ServerType.Sasjs
? res.result.log.map((logLine: any) => logLine.line).join('\n')
: res.result.log
const resObj =
this.serverType === ServerType.Sasjs
? {
result: res.result._webout,
log: parsedSasjsServerLog
}
: res
if (
this.serverType === ServerType.Sasjs &&
res.result._webout.length < 1
) {
throw new JobExecutionError(
0,
`No webout was returned by job ${program}. Server type is SASJS and the calling function is WebJobExecutor. Please check the SAS log for more info.`,
parsedSasjsServerLog
)
}
this.requestClient!.appendRequest(resObj, sasJob, config.debug)
let jsonResponse = res.result let jsonResponse = res.result
@@ -207,21 +180,11 @@ export class WebJobExecutor extends BaseJobExecutor {
? parseWeboutResponse(res.result, apiUrl) ? parseWeboutResponse(res.result, apiUrl)
: res.result : res.result
break break
case ServerType.Sasjs:
if (typeof res.result._webout === 'object') {
jsonResponse = res.result._webout
} else {
const webout = parseWeboutResponse(res.result._webout, apiUrl)
jsonResponse = getValidJson(webout)
}
break
} }
} else if (this.serverType === ServerType.Sasjs) {
jsonResponse = getValidJson(res.result._webout)
} }
const responseObject = appendExtraResponseAttributes( const responseObject = appendExtraResponseAttributes(
{ result: jsonResponse, log: parsedSasjsServerLog }, { result: jsonResponse, log: res.log },
extraResponseAttributes extraResponseAttributes
) )
resolve(responseObject) resolve(responseObject)
@@ -261,9 +224,7 @@ export class WebJobExecutor extends BaseJobExecutor {
}) })
if (loginCallback) await loginCallback() if (loginCallback) await loginCallback()
} else { } else reject(new ErrorResponse(e?.message, e))
reject(new ErrorResponse(e?.message, e))
}
}) })
}) })
@@ -301,39 +262,4 @@ export class WebJobExecutor extends BaseJobExecutor {
} }
return uri return uri
} }
private getRequestParams(config: any): any {
const requestParams: any = {}
if (config.debug) {
requestParams['_omittextlog'] = 'false'
requestParams['_omitsessionresults'] = 'false'
requestParams['_debug'] = 131
}
return requestParams
}
private parseSAS9ErrorResponse(response: string) {
const logLines = response.split('\n')
const parsedLines: string[] = []
let firstErrorLineIndex: number = -1
logLines.map((line: string, index: number) => {
if (
line.toLowerCase().includes('error') &&
!line.toLowerCase().includes('this request completed with errors.') &&
firstErrorLineIndex === -1
) {
firstErrorLineIndex = index
}
})
for (let i = firstErrorLineIndex - 10; i <= firstErrorLineIndex + 10; i++) {
parsedLines.push(logLines[i])
}
return parsedLines.join(', ')
}
} }

View File

@@ -4,3 +4,4 @@ export * from './JesJobExecutor'
export * from './JobExecutor' export * from './JobExecutor'
export * from './Sas9JobExecutor' export * from './Sas9JobExecutor'
export * from './WebJobExecutor' export * from './WebJobExecutor'
export * from './SasjsJobExecutor'

View File

@@ -19,7 +19,7 @@ import {
parseSourceCode, parseSourceCode,
createAxiosInstance createAxiosInstance
} from '../utils' } from '../utils'
import { InvalidCsrfError } from '../types/errors/InvalidCsrfError' import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
export interface HttpClient { export interface HttpClient {
get<T>( get<T>(
@@ -133,29 +133,13 @@ export class RequestClient implements HttpClient {
} else { } else {
sasWork = response.log sasWork = response.log
} }
} else if (response?.result?.log) {
//In this scenario we know we got the response from SASJS server
//Log is array of `{ line: '' }` so we need to convert it back to text
//To be able to parse it with current functions.
let log: string = ''
if (typeof log !== 'string') {
log = response.result.log
.map((logLine: any) => logLine.line)
.join('\n')
}
sourceCode = parseSourceCode(log)
generatedCode = parseGeneratedCode(log)
if (response?.result?._webout) {
sasWork = response.result._webout.WORK
} else {
sasWork = log
}
} else if (response?.result) { } else if (response?.result) {
sourceCode = parseSourceCode(response.result) // We parse only if it's a string, otherwise it would throw error
generatedCode = parseGeneratedCode(response.result) if (typeof response.result === 'string') {
sourceCode = parseSourceCode(response.result)
generatedCode = parseGeneratedCode(response.result)
}
sasWork = response.result.WORK sasWork = response.result.WORK
} }
} }
@@ -499,12 +483,20 @@ export class RequestClient implements HttpClient {
throw e throw e
} }
if (e instanceof InvalidCsrfError) { if (e instanceof InvalidSASjsCsrfError) {
// Fetching root will inject CSRF token in cookie // Fetching root and creating CSRF cookie
await this.httpClient await this.httpClient
.get('/', { .get('/', {
withCredentials: true withCredentials: true
}) })
.then((response) => {
const cookie =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
response.data
)?.[1]
if (cookie) document.cookie = cookie
})
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while re-fetching CSRF token.') throw prefixMessage(err, 'Error while re-fetching CSRF token.')
}) })
@@ -611,8 +603,11 @@ export const throwIfError = (response: AxiosResponse) => {
throw new LoginRequiredError(response.data) throw new LoginRequiredError(response.data)
} }
if (response.data.toLowerCase() === 'invalid csrf token!') { if (
throw new InvalidCsrfError() typeof response.data === 'string' &&
response.data.toLowerCase() === 'invalid csrf token!'
) {
throw new InvalidSASjsCsrfError()
} }
break break
case 401: case 401:
@@ -703,7 +698,14 @@ const parseError = (data: string) => {
} catch (_) {} } catch (_) {}
try { try {
// There are some edge cases in which the SAS mp_abort macro
// (https://core.sasjs.io/mp__abort_8sas.html) is unable to
// provide a clean exit. In this case the JSON response will
// be wrapped in >>weboutBEGIN<< and >>weboutEND<< strings.
// Therefore, if the first string exists, we won't throw an
// error just yet (the parser may yet throw one instead)
const hasError = const hasError =
!data?.match(/>>weboutBEGIN<</) &&
!!data?.match(/Stored Process Error/i) && !!data?.match(/Stored Process Error/i) &&
!!data?.match(/This request completed with errors./i) !!data?.match(/This request completed with errors./i)
if (hasError) { if (hasError) {

View File

@@ -1,9 +1,12 @@
import { RequestClient } from './RequestClient' import { RequestClient } from './RequestClient'
import { AxiosResponse } from 'axios'
import { SASJS_LOGS_SEPARATOR, getValidJson } from '../utils'
/** /**
* 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 = {}
@@ -20,4 +23,32 @@ export class SasjsRequestClient extends RequestClient {
return headers return headers
} }
protected parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse = {}
let log
try {
if (typeof response.data === 'string') {
parsedResponse = JSON.parse(response.data)
} else {
parsedResponse = response.data
}
} catch {
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR)
log = splittedResponse[1]
if (splittedResponse[0].trim())
parsedResponse = getValidJson(splittedResponse[0])
} else parsedResponse = response.data
}
return {
result: parsedResponse as T,
log,
etag,
status: response.status
}
}
} }

View File

@@ -11,13 +11,16 @@ describe('formatDataForRequest', () => {
{ var1: 'string', var2: 232, nullvar: '_' }, { var1: 'string', var2: 232, nullvar: '_' },
{ var1: 'string', var2: 232, nullvar: 0 }, { var1: 'string', var2: 232, nullvar: 0 },
{ var1: 'string', var2: 232, nullvar: 'z' }, { var1: 'string', var2: 232, nullvar: 'z' },
{ var1: 'string', var2: 232, nullvar: null } { var1: 'string', var2: 232, nullvar: null },
{ var1: 'string', var2: 232, nullvar: '.A' },
{ var1: 'string', var2: 232, nullvar: '._' },
{ var1: 'string', var2: 232, nullvar: '.' }
], ],
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } } [`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
} }
const expectedOutput = { const expectedOutput = {
sasjs1data: `var1:$char12. var2:best. nullvar:best.\r\nstring,232,.a\r\nstring,232,.b\r\nstring,232,._\r\nstring,232,0\r\nstring,232,.z\r\nstring,232,.`, sasjs1data: `var1:$char12. var2:best. nullvar:best.\r\nstring,232,.a\r\nstring,232,.b\r\nstring,232,._\r\nstring,232,0\r\nstring,232,.z\r\nstring,232,.\r\nstring,232,.a\r\nstring,232,._\r\nstring,232,.`,
sasjs_tables: testTable sasjs_tables: testTable
} }

View File

@@ -1,9 +0,0 @@
export class InvalidCsrfError extends Error {
constructor() {
const message = 'Invalid CSRF token!'
super(`Auth error: ${message}`)
this.name = 'InvalidCsrfError'
Object.setPrototypeOf(this, InvalidCsrfError.prototype)
}
}

View File

@@ -0,0 +1,9 @@
export class InvalidSASjsCsrfError extends Error {
constructor() {
const message = 'Invalid CSRF token!'
super(`Auth error: ${message}`)
this.name = 'InvalidSASjsCsrfError'
Object.setPrototypeOf(this, InvalidSASjsCsrfError.prototype)
}
}

2
src/utils/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const SASJS_LOGS_SEPARATOR =
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'

View File

@@ -1,4 +1,5 @@
import { isSpecialMissing } from '@sasjs/utils/input/validators' import { isSpecialMissing } from '@sasjs/utils/input/validators'
import { prefixMessage } from '@sasjs/utils/error'
/** /**
* Converts the given JSON object array to a CSV string. * Converts the given JSON object array to a CSV string.
@@ -9,7 +10,10 @@ export const convertToCSV = (
tableName: string tableName: string
) => { ) => {
if (!data[tableName]) { if (!data[tableName]) {
throw new Error('No table provided to be converted to CSV') throw prefixMessage(
'No table provided to be converted to CSV.',
'Error while converting to CSV. '
)
} }
const table = data[tableName] const table = data[tableName]
@@ -137,7 +141,9 @@ export const convertToCSV = (
) )
} }
return `.${value.toLowerCase()}` const dot = value.includes('.') ? '' : '.'
return `${dot}${value.toLowerCase()}`
} }
// if there any present, it should have preceding (") for escaping // if there any present, it should have preceding (") for escaping
@@ -170,6 +176,12 @@ export const convertToCSV = (
return finalCSV return finalCSV
} }
/**
* Checks if table is table of formats (table name should start from '$' character).
* @param tableName - table name.
*/
export const isFormatsTable = (tableName: string) => /^\$.*/.test(tableName)
const getByteSize = (str: string) => { const getByteSize = (str: string) => {
let byteSize = str.length let byteSize = str.length
for (let i = str.length - 1; i >= 0; i--) { for (let i = str.length - 1; i >= 0; i--) {

View File

@@ -1,4 +1,4 @@
import { convertToCSV } from './convertToCsv' import { convertToCSV, isFormatsTable } from './convertToCsv'
import { splitChunks } from './splitChunks' import { splitChunks } from './splitChunks'
export const formatDataForRequest = (data: any) => { export const formatDataForRequest = (data: any) => {
@@ -8,7 +8,7 @@ export const formatDataForRequest = (data: any) => {
for (const tableName in data) { for (const tableName in data) {
if ( if (
tableName.match(/^\$.*/) && isFormatsTable(tableName) &&
Object.keys(data).includes(tableName.replace(/^\$/, '')) Object.keys(data).includes(tableName.replace(/^\$/, ''))
) { ) {
continue continue
@@ -16,7 +16,8 @@ export const formatDataForRequest = (data: any) => {
tableCounter++ tableCounter++
sasjsTables.push(tableName) // Formats table should not be sent as part of 'sasjs_tables'
if (!isFormatsTable(tableName)) sasjsTables.push(tableName)
const csv = convertToCSV(data, tableName) const csv = convertToCSV(data, tableName)

View File

@@ -1,20 +1,22 @@
export * from './appendExtraResponseAttributes'
export * from './asyncForEach' export * from './asyncForEach'
export * from './compareTimestamps' export * from './compareTimestamps'
export * from './convertToCsv' export * from './convertToCsv'
export * from './constants'
export * from './createAxiosInstance' export * from './createAxiosInstance'
export * from './delay' export * from './delay'
export * from './fetchLogByChunks'
export * from './getValidJson'
export * from './isNode' export * from './isNode'
export * from './isRelativePath' export * from './isRelativePath'
export * from './isUri' export * from './isUri'
export * from './isUrl' export * from './isUrl'
export * from './needsRetry' export * from './needsRetry'
export * from './parseGeneratedCode' export * from './parseGeneratedCode'
export * from './parseSourceCode'
export * from './parseSasViyaLog' export * from './parseSasViyaLog'
export * from './parseSourceCode'
export * from './parseViyaDebugResponse'
export * from './parseWeboutResponse'
export * from './serialize' export * from './serialize'
export * from './splitChunks' export * from './splitChunks'
export * from './parseWeboutResponse' export * from './validateInput'
export * from './fetchLogByChunks'
export * from './getValidJson'
export * from './parseViyaDebugResponse'
export * from './appendExtraResponseAttributes'

View File

@@ -1,4 +1,4 @@
import { convertToCSV } from './convertToCsv' import { convertToCSV, isFormatsTable } from '../convertToCsv'
describe('convertToCsv', () => { describe('convertToCsv', () => {
const tableName = 'testTable' const tableName = 'testTable'
@@ -216,7 +216,9 @@ describe('convertToCsv', () => {
const data = { [tableName]: [{ var1: 'string' }] } const data = { [tableName]: [{ var1: 'string' }] }
expect(() => convertToCSV(data, 'wrongTableName')).toThrow( expect(() => convertToCSV(data, 'wrongTableName')).toThrow(
new Error('No table provided to be converted to CSV') new Error(
'Error while converting to CSV. No table provided to be converted to CSV.'
)
) )
}) })
@@ -226,3 +228,15 @@ describe('convertToCsv', () => {
expect(convertToCSV(data, tableName)).toEqual('') expect(convertToCSV(data, tableName)).toEqual('')
}) })
}) })
describe('isFormatsTable', () => {
const tableName = 'sometable'
it('should return true if table name match pattern of formats table', () => {
expect(isFormatsTable(`$${tableName}`)).toEqual(true)
})
it('should return false if table name does not match pattern of formats table', () => {
expect(isFormatsTable(tableName)).toEqual(false)
})
})

View File

@@ -0,0 +1,84 @@
import {
validateInput,
INVALID_TABLE_STRUCTURE,
MORE_INFO
} from '../validateInput'
const tableArray = [{ col1: 'first col value' }]
const stringData: any = { table1: tableArray }
describe('validateInput', () => {
it('should not return an error message if input data valid', () => {
const validationResult = validateInput(stringData)
expect(validationResult).toEqual({
status: true,
msg: ''
})
})
it('should not return an error message if input data is null', () => {
const validationResult = validateInput(null)
expect(validationResult).toEqual({
status: true,
msg: ''
})
})
it('should return an error message if input data is an array', () => {
const validationResult = validateInput(tableArray)
expect(validationResult).toEqual({
status: false,
msg: INVALID_TABLE_STRUCTURE
})
})
it('should return an error message if first letter of table is neither alphabet nor underscore', () => {
const validationResult = validateInput({ '1stTable': tableArray })
expect(validationResult).toEqual({
status: false,
msg: 'First letter of table should be alphabet or underscore.'
})
})
it('should return an error message if table name contains a character other than alphanumeric or underscore', () => {
const validationResult = validateInput({ 'table!': tableArray })
expect(validationResult).toEqual({
status: false,
msg: 'Table name should be alphanumeric.'
})
})
it('should return an error message if length of table name contains exceeds 32', () => {
const validationResult = validateInput({
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: tableArray
})
expect(validationResult).toEqual({
status: false,
msg: 'Maximum length for table name could be 32 characters.'
})
})
it('should return an error message if table does not have array of objects', () => {
const validationResult = validateInput({ table: stringData })
expect(validationResult).toEqual({
status: false,
msg: INVALID_TABLE_STRUCTURE
})
})
it('should return an error message if a table array has an item other than object', () => {
const validationResult = validateInput({ table1: ['invalid'] })
expect(validationResult).toEqual({
status: false,
msg: `Table table1 contains invalid structure. ${MORE_INFO}`
})
})
it('should return an error message if a row in a table contains an column with undefined value', () => {
const validationResult = validateInput({ table1: [{ column: undefined }] })
expect(validationResult).toEqual({
status: false,
msg: `A row in table table1 contains invalid value. Can't assign undefined to column.`
})
})
})

View File

@@ -0,0 +1,90 @@
export const MORE_INFO =
'For more info see https://sasjs.io/sasjs-adapter/#request-response'
export const INVALID_TABLE_STRUCTURE = `Parameter data contains invalid table structure. ${MORE_INFO}`
/**
* This function validates the input data structure and table naming convention
*
* @param data A json object that contains one or more tables, it can also be null
* @returns An object which contains two attributes: 1) status: boolean, 2) msg: string
*/
export const validateInput = (
data: { [key: string]: any } | null
): {
status: boolean
msg: string
} => {
if (data === null) return { status: true, msg: '' }
if (getType(data) !== 'object') {
return {
status: false,
msg: INVALID_TABLE_STRUCTURE
}
}
const isSasFormatsTable = (key: string) =>
key.match(/^\$.*/) && Object.keys(data).includes(key.replace(/^\$/, ''))
for (const key in data) {
if (!key.match(/^[a-zA-Z_]/) && !isSasFormatsTable(key)) {
return {
status: false,
msg: 'First letter of table should be alphabet or underscore.'
}
}
if (!key.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/) && !isSasFormatsTable(key)) {
return { status: false, msg: 'Table name should be alphanumeric.' }
}
if (key.length > 32) {
return {
status: false,
msg: 'Maximum length for table name could be 32 characters.'
}
}
if (getType(data[key]) !== 'Array' && !isSasFormatsTable(key)) {
return {
status: false,
msg: INVALID_TABLE_STRUCTURE
}
}
for (const item of data[key]) {
if (getType(item) !== 'object') {
return {
status: false,
msg: `Table ${key} contains invalid structure. ${MORE_INFO}`
}
} else {
const attributes = Object.keys(item)
for (const attribute of attributes) {
if (item[attribute] === undefined) {
return {
status: false,
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.`
}
}
}
}
}
}
return { status: true, msg: '' }
}
/**
* this function returns the type of variable
*
* @param data it could be anything, like string, array, object etc.
* @returns a string which tells the type of input parameter
*/
const getType = (data: any): string => {
if (Array.isArray(data)) {
return 'Array'
} else {
return typeof data
}
}